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 { 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"); }