diff options
Diffstat (limited to 'src/sync.ts')
| -rw-r--r-- | src/sync.ts | 181 |
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; + } +} |
