1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
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<boolean> {
try {
const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], {
timeout: 5000,
});
return stdout.trim() === "yes";
} catch {
return false;
}
}
|