diff options
Diffstat (limited to 'src/timesync.ts')
| -rw-r--r-- | src/timesync.ts | 268 |
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"); +} |
