import { execFile } from "node:child_process"; import { promisify } from "node:util"; /** * Matter epoch conversion. * * 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. * * The Time Synchronization write commands (SetUtcTime, SetTimeZone, * SetDstOffset) are intentionally NOT implemented yet: per the project plan * they are Phase 5/6 work that must be driven by the real device's inspected * capabilities. Run `inspect` against the hardware first. */ export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n; /** 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(); } /** * 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, 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; /** 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. */ export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison { const hostTime = formatMatterMicros(hostMicros); if (deviceMicros === null) { return { deviceTime: null, hostTime, deltaMicroseconds: null, description: "device clock was unset", }; } const delta = deviceMicros - hostMicros; const magnitude = delta < 0n ? -delta : delta; const description = magnitude < 1_000_000n ? `device clock was within 1s of host time (${formatDurationMicros(magnitude)} ${ delta < 0n ? "behind" : "ahead" })` : `device clock was ${formatDurationMicros(magnitude)} ${delta < 0n ? "behind" : "ahead"}`; return { deviceTime: formatMatterMicros(deviceMicros), hostTime, deltaMicroseconds: delta, description }; } /** 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 { try { const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], { timeout: 5000, }); return stdout.trim() === "yes"; } catch { return false; } }