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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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;
}
}
|