From 0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 26 Jul 2026 05:38:33 -0500 Subject: Implement mattertimesync: one-shot Matter time synchronization CLI A standalone CLI Matter controller that joins Matter devices' existing setups as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on matter.js 0.17.6 using the CommissioningController API. One-shot runs from a systemd timer, no daemon: being a secondary controller is fabric membership, not a running process, so each invocation loads the persisted keys, discovers the device via operational DNS-SD, opens a fresh CASE session, does its work, and exits. - TypeScript scaffold: strict tsc, oxlint, prettier, vitest (66 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting, capability planning against the captured device fixture) - Validated JSON configuration with bigint-safe values; unknown fields rejected loudly - Persistent controller fabric with the same identity across restarts. The fabric doubles as the device registry: every node commissioned onto it is kept in sync, and membership changes only through commission and decommission, so no separate device list can drift - On-network multi-admin commissioning from a manual or QR pairing code; any number of devices, one per-device pairing code each. A device already on the fabric rejects re-commissioning via fabric conflict - sync, validated against real hardware (IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home): refuses to run unless the host is NTP-synchronized, then per node reads utcTime, reports the correction with direction ("device clock was 1m 23s behind", handling unset clocks in exact bigint microseconds), writes SetUTCTime, SetTimeZone (standard offset plus IANA name), and SetDSTOffset (transition list bounded by the device's capacity), and verifies by reading the clock back. Capability-driven throughout: UTC-only devices degrade gracefully, cluster-less devices are skipped with a warning. Devices whose firmware encodes Unix-epoch time on the wire are detected by the exact 946684800s shift and reported corrected. Note matter.js's TlvEpochUs API convention: values are Unix-epoch microseconds at the API boundary; the library converts to Matter epoch on the wire - Commands: nodes, inspect (human/JSON; also reads the fabric table fabric-unfiltered, matching our own entry by fabric ID plus root public key and flagging likely-stale orphans; strictly read-only, since this tool never uses its admin rights against other fabrics' entries), status, and decommission (each device drops our fabric while staying paired to its primary ecosystem; targets all devices unless --node narrows it) - Logs (service and matter.js library, plain format) go to stderr; stdout carries only command output, so --json (available on nodes, inspect, sync, status, fabrics) is always clean parseable JSON with 64-bit values as decimal strings. Node's node:sqlite ExperimentalWarning is filtered before matter.js loads - systemd oneshot service (dedicated non-root user, hardening) plus hourly timer with randomized delay - Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB): all runtime deps are pure JavaScript, so the target needs only Node 20+, with no npm, registry access, or build toolchain. The Bun sqlite driver stays external behind its runtime guard and node:sqlite is aliased to a lazy shim so the bundle runs under plain Node. npm pack remains an alternative install path --- test/timezone.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/timezone.test.ts (limited to 'test/timezone.test.ts') diff --git a/test/timezone.test.ts b/test/timezone.test.ts new file mode 100644 index 0000000..d4ba6d6 --- /dev/null +++ b/test/timezone.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + buildDstOffsetList, + buildTimeZoneList, + formatUtcOffset, + isValidTimeZone, + nextOffsetTransition, + standardOffsetSeconds, + utcOffsetSeconds, +} from "../src/timezone.js"; + +// All builder timestamps are Unix-epoch microseconds (matter.js TlvEpochUs +// convention); "since the beginning of time" is Matter epoch zero in Unix us. +const MATTER_ZERO = 946_684_800_000_000n; +const FALL_BACK_2026 = BigInt(Date.parse("2026-11-01T07:00:00Z")) * 1_000n; +const SPRING_FORWARD_2027 = BigInt(Date.parse("2027-03-14T08:00:00Z")) * 1_000n; + +describe("isValidTimeZone", () => { + it("accepts IANA names", () => { + expect(isValidTimeZone("America/Chicago")).toBe(true); + expect(isValidTimeZone("Europe/Berlin")).toBe(true); + expect(isValidTimeZone("UTC")).toBe(true); + }); + + it("rejects invalid names", () => { + expect(isValidTimeZone("Central Time")).toBe(false); + expect(isValidTimeZone("")).toBe(false); + expect(isValidTimeZone("America/Springfield")).toBe(false); + }); +}); + +describe("utcOffsetSeconds", () => { + it("returns CST offset in January", () => { + expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600); + }); + + it("returns CDT offset in July", () => { + expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600); + }); + + it("returns 0 for UTC", () => { + expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0); + }); + + it("handles half-hour offsets", () => { + expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600); + }); +}); + +describe("formatUtcOffset", () => { + it("formats negative, positive, and zero offsets", () => { + expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00"); + expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30"); + expect(formatUtcOffset(0)).toBe("UTC+00:00"); + }); +}); + +describe("nextOffsetTransition", () => { + it("finds the exact spring-forward instant for Chicago", () => { + const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z")); + expect(transition).not.toBeNull(); + // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC. + expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z"); + expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600); + expect(transition!.offsetAfterSeconds).toBe(-5 * 3600); + }); + + it("finds the exact fall-back instant for Chicago", () => { + const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z")); + expect(transition).not.toBeNull(); + // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC. + expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z"); + expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600); + expect(transition!.offsetAfterSeconds).toBe(-6 * 3600); + }); + + it("returns null for fixed-offset zones", () => { + expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull(); + }); +}); + +describe("standardOffsetSeconds", () => { + it("returns the non-DST offset regardless of season", () => { + expect(standardOffsetSeconds("America/Chicago", new Date("2026-07-15T00:00:00Z"))).toBe(-6 * 3600); + expect(standardOffsetSeconds("America/Chicago", new Date("2026-01-15T00:00:00Z"))).toBe(-6 * 3600); + }); + + it("handles the southern hemisphere", () => { + // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer. + expect(standardOffsetSeconds("Australia/Sydney", new Date("2026-01-15T00:00:00Z"))).toBe(10 * 3600); + }); + + it("handles fixed-offset zones", () => { + expect(standardOffsetSeconds("UTC", new Date("2026-07-15T00:00:00Z"))).toBe(0); + expect(standardOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T00:00:00Z"))).toBe(5.5 * 3600); + }); +}); + +describe("buildTimeZoneList", () => { + it("builds a single always-valid entry with the standard offset and IANA name", () => { + const list = buildTimeZoneList("America/Chicago", new Date("2026-07-26T00:00:00Z")); + expect(list).toEqual([{ offset: -6 * 3600, validAt: MATTER_ZERO, name: "America/Chicago" }]); + }); +}); + +describe("buildDstOffsetList", () => { + const from = new Date("2026-07-26T00:00:00Z"); + + it("builds the active DST period plus the following one (max 2, as on ALPSTUGA)", () => { + expect(buildDstOffsetList("America/Chicago", 2, from)).toEqual([ + { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 }, + { offset: 0, validStarting: FALL_BACK_2026, validUntil: SPRING_FORWARD_2027 }, + ]); + }); + + it("caps the list at the device's maximum", () => { + expect(buildDstOffsetList("America/Chicago", 1, from)).toEqual([ + { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 }, + ]); + }); + + it("emits a single open-ended zero entry for zones without DST", () => { + expect(buildDstOffsetList("UTC", 2, from)).toEqual([ + { offset: 0, validStarting: MATTER_ZERO, validUntil: null }, + ]); + expect(buildDstOffsetList("Asia/Kolkata", 5, from)).toEqual([ + { offset: 0, validStarting: MATTER_ZERO, validUntil: null }, + ]); + }); + + it("starts from standard time when DST is not in effect", () => { + const winter = new Date("2026-01-15T00:00:00Z"); + const springForward2026 = BigInt(Date.parse("2026-03-08T08:00:00Z")) * 1_000n; + const list = buildDstOffsetList("America/Chicago", 2, winter); + expect(list[0]).toEqual({ offset: 0, validStarting: MATTER_ZERO, validUntil: springForward2026 }); + expect(list[1]!.offset).toBe(3600); + expect(list[1]!.validStarting).toBe(springForward2026); + }); +}); -- cgit v1.2.3