diff options
| author | Luke Hoersten <[email protected]> | 2026-07-26 05:38:33 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-26 11:44:49 -0500 |
| commit | 0af1cf3b1e2b471f2bc06abb16520035efff252e (patch) | |
| tree | 4f1f5a2db4a7798ceb0e09cc30e8aa2b18b5d824 /src/timezone.ts | |
Implement mattertimesync: one-shot Matter time synchronization CLIv0.1.0
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 (51 tests:
Matter epoch conversion, config validation, IANA timezone offsets and
exact DST transition search, atomic state writes, pairing-code
parsing, clock-delta reporting)
- 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
- Commands: nodes, inspect (human/JSON), status, fabrics (reads the
Operational Credentials fabric table, marks our own entry by fabric ID
plus root public key, flags likely-stale orphans, and removes them
with --remove), and decommission (device drops our fabric while
staying paired to its primary ecosystem). --node selects a device
where relevant; optional while only one is commissioned
- sync delta reporting: reads the device utcTime before writing and
reports device time before, time written, and the delta with
direction, e.g. "device clock was 1m 23s behind". Handles unset clocks
after power loss and wrong-epoch garbage in exact bigint microseconds.
The Time Synchronization write commands themselves still await
real-device capability inspection
- 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 so the bundle
runs under plain Node. npm pack remains an alternative install path
Diffstat (limited to 'src/timezone.ts')
| -rw-r--r-- | src/timezone.ts | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/src/timezone.ts b/src/timezone.ts new file mode 100644 index 0000000..f4240f4 --- /dev/null +++ b/src/timezone.ts @@ -0,0 +1,106 @@ +/** + * IANA time-zone helpers. + * + * Phase 1-4 scope: validation and offset introspection used by config + * validation and status output. Matter TimeZone/DSTOffset structure + * generation (Phase 6) will build on these helpers once the real device's + * Time Synchronization capabilities have been inspected. + */ + +/** Returns true when the host's ICU data recognizes the IANA time-zone name. */ +export function isValidTimeZone(timezone: string): boolean { + if (typeof timezone !== "string" || timezone.length === 0) return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +/** + * Current UTC offset of `timezone` at `date`, in seconds. + * + * Derived from ICU rules, never from a hard-coded offset, so daylight-saving + * transitions are honored automatically. + */ +export function utcOffsetSeconds(timezone: string, date: Date = new Date()): number { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "longOffset", + }); + const offsetPart = formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value; + if (offsetPart === undefined) { + throw new Error(`Unable to determine UTC offset for time zone ${timezone}`); + } + // Formats look like "GMT-05:00", "GMT+05:30", or plain "GMT" for UTC itself. + const match = /^GMT(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(offsetPart); + if (!match) { + throw new Error(`Unrecognized UTC offset format "${offsetPart}" for time zone ${timezone}`); + } + if (match[1] === undefined) return 0; + const sign = match[1] === "-" ? -1 : 1; + const hours = Number(match[2]); + const minutes = Number(match[3]); + const seconds = match[4] === undefined ? 0 : Number(match[4]); + return sign * (hours * 3600 + minutes * 60 + seconds); +} + +/** Formats an offset in seconds as "UTC-05:00" style text for logs and status output. */ +export function formatUtcOffset(offsetSeconds: number): string { + const sign = offsetSeconds < 0 ? "-" : "+"; + const absolute = Math.abs(offsetSeconds); + const hours = String(Math.floor(absolute / 3600)).padStart(2, "0"); + const minutes = String(Math.floor((absolute % 3600) / 60)).padStart(2, "0"); + return `UTC${sign}${hours}:${minutes}`; +} + +/** + * Finds the next instant at which the zone's UTC offset changes, scanning up + * to `horizonDays` ahead. Returns null when no transition occurs in the + * window (e.g. fixed-offset zones). + * + * Uses day-granularity scan plus binary search, so it is exact to the second + * without iterating minute-by-minute. + */ +export function nextOffsetTransition( + timezone: string, + from: Date = new Date(), + horizonDays = 400, +): { at: Date; offsetBeforeSeconds: number; offsetAfterSeconds: number } | null { + const startOffset = utcOffsetSeconds(timezone, from); + const dayMs = 24 * 60 * 60 * 1000; + // Probe on whole-second boundaries so the binary search converges on the + // exact transition instant (transitions occur at whole seconds). + const startMs = Math.ceil(from.getTime() / 1000) * 1000; + + let previous = startMs; + let changedAtOrBefore: number | null = null; + for (let day = 1; day <= horizonDays; day++) { + const probe = startMs + day * dayMs; + if (utcOffsetSeconds(timezone, new Date(probe)) !== startOffset) { + changedAtOrBefore = probe; + break; + } + previous = probe; + } + if (changedAtOrBefore === null) return null; + + // Binary search the exact transition instant between the last unchanged + // probe and the first changed probe. + let low = previous; + let high = changedAtOrBefore; + while (high - low > 1000) { + const middle = low + Math.floor((high - low) / 2 / 1000) * 1000; + if (utcOffsetSeconds(timezone, new Date(middle)) === startOffset) { + low = middle; + } else { + high = middle; + } + } + return { + at: new Date(high), + offsetBeforeSeconds: startOffset, + offsetAfterSeconds: utcOffsetSeconds(timezone, new Date(high)), + }; +} |
