src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/timesync.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/timesync.ts')
-rw-r--r--src/timesync.ts268
1 files changed, 268 insertions, 0 deletions
diff --git a/src/timesync.ts b/src/timesync.ts
new file mode 100644
index 0000000..b3a7aea
--- /dev/null
+++ b/src/timesync.ts
@@ -0,0 +1,268 @@
+import { TimeSynchronization } from "@matter/main/clusters/time-synchronization";
+import type { PairedNode } from "@project-chip/matter.js/device";
+import type { TimeSyncSummary } from "./inspection.js";
+import { Log } from "./logging.js";
+import {
+ compareDeviceClock,
+ currentUnixUtcMicroseconds,
+ formatDurationMicros,
+ formatUnixMicros,
+ type ClockComparison,
+} from "./sync.js";
+import {
+ buildDstOffsetList,
+ buildTimeZoneList,
+ formatUtcOffset,
+ type MatterDstOffsetEntry,
+ type MatterTimeZoneEntry,
+} from "./timezone.js";
+
+const log = new Log("sync");
+
+/**
+ * One clock synchronization against a live device, driven entirely by the
+ * capabilities the device itself reports: SetUTCTime always (it is the
+ * mandatory command), SetTimeZone/SetDSTOffset only when the TimeZone
+ * feature and the commands are present, and the DST list bounded by the
+ * device's DSTOffsetListMaxSize. Devices without optional features degrade
+ * gracefully; a missing Time Synchronization cluster is skipped with a
+ * warning.
+ */
+
+/** What the device's Time Synchronization cluster supports. */
+export interface TimeSyncCapabilities {
+ timeZoneFeature: boolean;
+ supportsSetUtcTime: boolean;
+ supportsSetTimeZone: boolean;
+ supportsSetDstOffset: boolean;
+ timeZoneListMaxSize: number;
+ dstOffsetListMaxSize: number;
+}
+
+/** Derives capabilities from an inspection summary (live or captured fixture). */
+export function capabilitiesFromTimeSync(summary: TimeSyncSummary): TimeSyncCapabilities {
+ const attributeValue = (name: string): unknown =>
+ summary.attributes.find(attribute => attribute.name === name)?.value;
+ const sizeAttribute = (name: string): number => {
+ const value = attributeValue(name);
+ // The spec minimum for both list sizes is 1; assume it when unreported.
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : 1;
+ };
+ return {
+ timeZoneFeature: summary.supportedFeatures["timeZone"] === true,
+ supportsSetUtcTime: summary.supportedCommands["setUtcTime"] === true,
+ supportsSetTimeZone: summary.supportedCommands["setTimeZone"] === true,
+ supportsSetDstOffset: summary.supportedCommands["setDstOffset"] === true,
+ timeZoneListMaxSize: sizeAttribute("timeZoneListMaxSize"),
+ dstOffsetListMaxSize: sizeAttribute("dstOffsetListMaxSize"),
+ };
+}
+
+/** The writes one sync run will perform, decided before touching the device. */
+export interface TimeSyncPlan {
+ setUtcTime: boolean;
+ timeZone: MatterTimeZoneEntry[] | null;
+ dstOffsets: MatterDstOffsetEntry[] | null;
+}
+
+export function planTimeSync(
+ capabilities: TimeSyncCapabilities,
+ timezone: string,
+ from: Date = new Date(),
+): TimeSyncPlan {
+ const timeZoneSupported = capabilities.timeZoneFeature && capabilities.supportsSetTimeZone;
+ return {
+ setUtcTime: capabilities.supportsSetUtcTime,
+ timeZone: timeZoneSupported ? buildTimeZoneList(timezone, from) : null,
+ dstOffsets:
+ timeZoneSupported && capabilities.supportsSetDstOffset
+ ? buildDstOffsetList(timezone, capabilities.dstOffsetListMaxSize, from)
+ : null,
+ };
+}
+
+/** Read-back verification after the writes. */
+export interface SyncVerification {
+ /** Device utcTime after sync as ISO-8601 (epoch-corrected), or null if unreadable. */
+ utcTimeAfter: string | null;
+ /** Effective clock error after sync in microseconds (epoch shift removed). */
+ deltaAfterMicroseconds: bigint | null;
+ /** True when the device reports Unix-epoch instead of Matter-epoch time. */
+ epochShifted: boolean;
+ verified: boolean;
+}
+
+const VERIFY_TOLERANCE_MICROS = 5_000_000n;
+
+export interface SyncNodeResult {
+ nodeId: string;
+ success: boolean;
+ /** Set (with success=false) when the device has no Time Synchronization cluster. */
+ skipped: boolean;
+ error: string | null;
+ clockBefore: ClockComparison | null;
+ /** The UTC instant written, ISO-8601 and Unix-epoch microseconds (matter.js convention). */
+ utcTimeWritten: string | null;
+ utcTimeWrittenUnixMicroseconds: bigint | null;
+ timeZoneWritten: MatterTimeZoneEntry[] | null;
+ dstOffsetsWritten: MatterDstOffsetEntry[] | null;
+ verification: SyncVerification | null;
+}
+
+function emptyResult(nodeId: bigint): SyncNodeResult {
+ return {
+ nodeId: nodeId.toString(),
+ success: false,
+ skipped: false,
+ error: null,
+ clockBefore: null,
+ utcTimeWritten: null,
+ utcTimeWrittenUnixMicroseconds: null,
+ timeZoneWritten: null,
+ dstOffsetsWritten: null,
+ verification: null,
+ };
+}
+
+/** Coerces a utcTime attribute value (number | bigint | null | undefined) to bigint | null. */
+function utcTimeToBigint(value: number | bigint | null | undefined): bigint | null {
+ if (value === null || value === undefined) return null;
+ return BigInt(value);
+}
+
+/**
+ * Synchronizes one connected node's clock. Throws only on programming
+ * errors; device and interaction failures are reported in the result so the
+ * caller can continue with other nodes.
+ */
+export async function syncNode(node: PairedNode, timezone: string): Promise<SyncNodeResult> {
+ const nodeId = BigInt(node.nodeId);
+ const result = emptyResult(nodeId);
+
+ const client = node.getRootClusterClient(TimeSynchronization.Complete);
+ if (client === undefined) {
+ result.skipped = true;
+ result.error = "no Time Synchronization cluster on the root endpoint";
+ log.warn(`Node ${nodeId}: ${result.error}; skipping`);
+ return result;
+ }
+
+ try {
+ const supportedCommands = {
+ setUtcTime: client.isCommandSupportedByName("setUtcTime"),
+ setTimeZone: client.isCommandSupportedByName("setTimeZone"),
+ setDstOffset: client.isCommandSupportedByName("setDstOffset"),
+ };
+ const capabilities: TimeSyncCapabilities = {
+ timeZoneFeature: client.supportedFeatures["timeZone"] === true,
+ supportsSetUtcTime: supportedCommands.setUtcTime,
+ supportsSetTimeZone: supportedCommands.setTimeZone,
+ supportsSetDstOffset: supportedCommands.setDstOffset,
+ timeZoneListMaxSize: (await client.attributes.timeZoneListMaxSize.get()) ?? 1,
+ dstOffsetListMaxSize: (await client.attributes.dstOffsetListMaxSize.get()) ?? 1,
+ };
+ if (!capabilities.supportsSetUtcTime) {
+ throw new Error("device does not accept SetUTCTime; its clock cannot be synchronized");
+ }
+ const plan = planTimeSync(capabilities, timezone);
+
+ // Read the device clock live (not from cache) for the before/after report.
+ const deviceBefore = utcTimeToBigint(await client.attributes.utcTime.get(true));
+ result.clockBefore = compareDeviceClock(deviceBefore, currentUnixUtcMicroseconds());
+ log.info(`Node ${nodeId}: ${result.clockBefore.description}`);
+
+ // Write UTC time last-moment-fresh, as Unix-epoch microseconds:
+ // matter.js's TlvEpochUs converts to the Matter epoch on the wire.
+ // Granularity and time source matter for acceptance: a device that
+ // already has good time may reject (TimeNotAccepted) a claim no better
+ // than what it holds, so state the strongest truthful claim: the host
+ // clock is NTP-disciplined (NonMatterSntp) and the timestamp is
+ // microsecond-precise at send time.
+ const utcTime = currentUnixUtcMicroseconds();
+ await client.commands.setUtcTime({
+ utcTime,
+ granularity: TimeSynchronization.Granularity.MicrosecondsGranularity,
+ timeSource: TimeSynchronization.TimeSource.NonMatterSntp,
+ });
+ result.utcTimeWritten = formatUnixMicros(utcTime);
+ result.utcTimeWrittenUnixMicroseconds = utcTime;
+ log.info(`Node ${nodeId}: SetUTCTime ${result.utcTimeWritten}`);
+
+ if (plan.timeZone !== null) {
+ const response = await client.commands.setTimeZone({ timeZone: plan.timeZone });
+ result.timeZoneWritten = plan.timeZone;
+ log.info(
+ `Node ${nodeId}: SetTimeZone ${plan.timeZone[0]!.name} ` +
+ `(standard offset ${formatUtcOffset(plan.timeZone[0]!.offset)})`,
+ );
+ // dstOffsetRequired=false means the device knows this zone's DST
+ // rules itself; pushing our list anyway would be redundant.
+ const dstRequired = response?.dstOffsetRequired !== false;
+ if (plan.dstOffsets !== null && dstRequired) {
+ await client.commands.setDstOffset({ dstOffset: plan.dstOffsets });
+ result.dstOffsetsWritten = plan.dstOffsets;
+ log.info(`Node ${nodeId}: SetDSTOffset with ${plan.dstOffsets.length} entries`);
+ }
+ }
+
+ // Read back and verify against the host clock at read time.
+ const deviceAfter = utcTimeToBigint(await client.attributes.utcTime.get(true));
+ const after = compareDeviceClock(deviceAfter, currentUnixUtcMicroseconds());
+ const effective = after.effectiveDeltaMicroseconds;
+ const verified =
+ effective !== null && (effective < 0n ? -effective : effective) <= VERIFY_TOLERANCE_MICROS;
+ result.verification = {
+ utcTimeAfter: after.deviceTime,
+ deltaAfterMicroseconds: effective,
+ epochShifted: after.epochShifted,
+ verified,
+ };
+ if (!verified) {
+ throw new Error(
+ `read-back verification failed: device utcTime is ${after.deviceTime ?? "unset"} ` +
+ `(${after.description})`,
+ );
+ }
+
+ result.success = true;
+ return result;
+ } catch (cause) {
+ result.error = cause instanceof Error ? cause.message : String(cause);
+ return result;
+ }
+}
+
+export function formatSyncResult(result: SyncNodeResult): string {
+ const lines: string[] = [`Node ${result.nodeId}:`];
+ if (result.skipped) {
+ lines.push(` Skipped: ${result.error}`);
+ return lines.join("\n");
+ }
+ if (result.clockBefore !== null) {
+ lines.push(` Device clock: ${result.clockBefore.deviceTime ?? "unset"}`);
+ lines.push(` Assessment: ${result.clockBefore.description}`);
+ }
+ if (result.utcTimeWritten !== null) {
+ lines.push(` Time written: ${result.utcTimeWritten}`);
+ }
+ if (result.timeZoneWritten !== null) {
+ const zone = result.timeZoneWritten[0]!;
+ lines.push(` Time zone: ${zone.name} (standard offset ${formatUtcOffset(zone.offset)})`);
+ }
+ if (result.dstOffsetsWritten !== null && result.dstOffsetsWritten.length > 0) {
+ const last = result.dstOffsetsWritten[result.dstOffsetsWritten.length - 1]!;
+ const horizon =
+ last.validUntil === null ? "indefinitely" : `through ${formatUnixMicros(last.validUntil)}`;
+ lines.push(` DST offsets: ${result.dstOffsetsWritten.length} entries, valid ${horizon}`);
+ }
+ if (result.verification !== null && result.verification.deltaAfterMicroseconds !== null) {
+ const delta = result.verification.deltaAfterMicroseconds;
+ const magnitude = delta < 0n ? -delta : delta;
+ lines.push(
+ ` Verification: device clock within ${formatDurationMicros(magnitude)} of host after sync` +
+ `${result.verification.epochShifted ? " (device reports Unix-epoch time; corrected)" : ""}`,
+ );
+ }
+ lines.push(result.success ? " Result: OK" : ` Result: FAILED (${result.error})`);
+ return lines.join("\n");
+}