src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/sync.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/sync.ts')
-rw-r--r--src/sync.ts181
1 files changed, 181 insertions, 0 deletions
diff --git a/src/sync.ts b/src/sync.ts
new file mode 100644
index 0000000..64ff832
--- /dev/null
+++ b/src/sync.ts
@@ -0,0 +1,181 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+/**
+ * Matter epoch conversion and clock comparison.
+ *
+ * Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter
+ * epoch"), not the Unix epoch. All arithmetic stays in bigint; the full
+ * microsecond timestamp must never pass through a JavaScript number.
+ */
+
+export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n;
+
+/**
+ * The exact distance between the Unix and Matter epochs in microseconds;
+ * equivalently, Matter epoch zero (2000-01-01T00:00:00Z) expressed as
+ * Unix-epoch microseconds.
+ *
+ * Epoch handling has two layers. On the wire, all epoch-us fields are
+ * Matter-epoch. At the matter.js API boundary, however, TlvEpochUs values
+ * are Unix-epoch microseconds in both directions: the library performs the
+ * wire conversion itself (and rejects pre-converted values). All values
+ * passed to or read from matter.js are therefore Unix-epoch microseconds.
+ */
+export const MATTER_EPOCH_AS_UNIX_MICROS = MATTER_EPOCH_UNIX_SECONDS * 1_000_000n;
+
+/**
+ * A device is treated as epoch-confused when its clock delta lands within
+ * this window of the exact Unix/Matter epoch distance: firmware that encodes
+ * Unix-epoch values on the wire reads as ~30 years ahead after decoding. A
+ * week comfortably covers any real drift (a wrong clock is stale by hours,
+ * not decades) while remaining astronomically far from every honest delta.
+ */
+const EPOCH_SHIFT_DETECTION_WINDOW_MICROS = 7n * 86_400n * 1_000_000n;
+
+/** Converts a Unix-epoch timestamp in milliseconds to Matter-epoch microseconds. */
+export function unixMillisToMatterMicros(unixMilliseconds: bigint): bigint {
+ return unixMilliseconds * 1_000n - MATTER_EPOCH_UNIX_SECONDS * 1_000_000n;
+}
+
+/** Converts Matter-epoch microseconds back to Unix-epoch milliseconds (truncating). */
+export function matterMicrosToUnixMillis(matterMicroseconds: bigint): bigint {
+ return (matterMicroseconds + MATTER_EPOCH_UNIX_SECONDS * 1_000_000n) / 1_000n;
+}
+
+/** Current time as Matter-epoch microseconds. */
+export function currentMatterUtcMicroseconds(now: () => number = Date.now): bigint {
+ return unixMillisToMatterMicros(BigInt(now()));
+}
+
+/** Renders Matter-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */
+export function formatMatterMicros(matterMicroseconds: bigint): string {
+ return new Date(Number(matterMicrosToUnixMillis(matterMicroseconds))).toISOString();
+}
+
+/** Current time as Unix-epoch microseconds (matter.js's TlvEpochUs convention). */
+export function currentUnixUtcMicroseconds(now: () => number = Date.now): bigint {
+ return BigInt(now()) * 1_000n;
+}
+
+/** Renders Unix-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */
+export function formatUnixMicros(unixMicroseconds: bigint): string {
+ return new Date(Number(unixMicroseconds / 1_000n)).toISOString();
+}
+
+/**
+ * Comparison of the device's reported clock against the time we are about to
+ * set, for the sync report ("was X, set to Y, delta Z").
+ */
+export interface ClockComparison {
+ /**
+ * Device time before sync as ISO-8601 (epoch-corrected for off-spec
+ * devices, i.e. the wall clock the device actually believes), or null if
+ * its clock was unset.
+ */
+ deviceTime: string | null;
+ /** The time being written, as ISO-8601. */
+ hostTime: string;
+ /** deviceTime - hostTime in microseconds; null when the device clock was unset. */
+ deltaMicroseconds: bigint | null;
+ /** True when the device reports Unix-epoch microseconds instead of Matter-epoch. */
+ epochShifted: boolean;
+ /**
+ * The delta with any detected epoch shift removed: the device's real clock
+ * error. Equals deltaMicroseconds for spec-compliant devices.
+ */
+ effectiveDeltaMicroseconds: bigint | null;
+ /** Human-readable summary, e.g. "device clock was 3.2s behind". */
+ description: string;
+}
+
+/**
+ * Compares the device's utcTime attribute (null when the device lost its
+ * clock, e.g. after a power outage) against the host time being written.
+ * Both arguments are Unix-epoch microseconds, matter.js's API convention.
+ *
+ * Detects epoch-confused devices: firmware that encodes Unix-epoch values
+ * on the wire decodes to a delta of exactly the Unix/Matter epoch distance,
+ * which is folded out so such a device's real clock error stays visible
+ * instead of reading as 30 years ahead.
+ */
+export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison {
+ const hostTime = formatUnixMicros(hostMicros);
+ if (deviceMicros === null) {
+ return {
+ deviceTime: null,
+ hostTime,
+ deltaMicroseconds: null,
+ epochShifted: false,
+ effectiveDeltaMicroseconds: null,
+ description: "device clock was unset",
+ };
+ }
+ const delta = deviceMicros - hostMicros;
+ const shiftError = delta - MATTER_EPOCH_AS_UNIX_MICROS;
+ const epochShifted = (shiftError < 0n ? -shiftError : shiftError) <= EPOCH_SHIFT_DETECTION_WINDOW_MICROS;
+ const effective = epochShifted ? shiftError : delta;
+ const description = epochShifted
+ ? `device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was ${describeDelta(effective)}`
+ : `device clock was ${describeDelta(delta)}`;
+ return {
+ deviceTime: formatUnixMicros(epochShifted ? deviceMicros - MATTER_EPOCH_AS_UNIX_MICROS : deviceMicros),
+ hostTime,
+ deltaMicroseconds: delta,
+ epochShifted,
+ effectiveDeltaMicroseconds: effective,
+ description,
+ };
+}
+
+/** "within 1s of host time (412ms behind)" / "1m 23s ahead" for a signed delta. */
+function describeDelta(delta: bigint): string {
+ const magnitude = delta < 0n ? -delta : delta;
+ const direction = delta < 0n ? "behind" : "ahead";
+ return magnitude < 1_000_000n
+ ? `within 1s of host time (${formatDurationMicros(magnitude)} ${direction})`
+ : `${formatDurationMicros(magnitude)} ${direction}`;
+}
+
+/** Formats a non-negative duration in microseconds as a compact human string. */
+export function formatDurationMicros(microseconds: bigint): string {
+ if (microseconds < 0n) throw new Error("duration must be non-negative");
+ if (microseconds < 1_000n) return `${microseconds}us`;
+ if (microseconds < 1_000_000n) return `${microseconds / 1_000n}ms`;
+
+ const totalSeconds = microseconds / 1_000_000n;
+ if (totalSeconds < 60n) {
+ const tenths = (microseconds % 1_000_000n) / 100_000n;
+ return tenths === 0n ? `${totalSeconds}s` : `${totalSeconds}.${tenths}s`;
+ }
+ const days = totalSeconds / 86_400n;
+ const hours = (totalSeconds % 86_400n) / 3_600n;
+ const minutes = (totalSeconds % 3_600n) / 60n;
+ const seconds = totalSeconds % 60n;
+ const parts: string[] = [];
+ if (days > 0n) parts.push(`${days}d`);
+ if (hours > 0n) parts.push(`${hours}h`);
+ if (minutes > 0n) parts.push(`${minutes}m`);
+ if (seconds > 0n && days === 0n) parts.push(`${seconds}s`);
+ return parts.join(" ");
+}
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Whether the host clock is NTP-synchronized, via systemd-timesyncd. The
+ * device must never be set from an unsynchronized clock.
+ *
+ * Returns false on hosts without timedatectl (e.g. during development on
+ * macOS) so callers fail safe; pass `assumeSynchronized` explicitly in tests.
+ */
+export async function isHostClockSynchronized(): Promise<boolean> {
+ try {
+ const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], {
+ timeout: 5000,
+ });
+ return stdout.trim() === "yes";
+ } catch {
+ return false;
+ }
+}