From 2aa6bb1d5f7870e4d5b561056316525adbf0ccf9 Mon Sep 17 00:00:00 2001 From: Mark van Proctor <6936351+markvp@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:46 +0800 Subject: [PATCH] Add TimeSyncManager to sync UTC time on nodes with TimeSynchronization cluster Devices like IKEA ALPSTUGA lack battery backup and lose their time after power loss. The controller now proactively syncs time on three triggers: 1. Node connects/reconnects (immediate) 2. timeFailure event from the node (reactive) 3. Periodic resync every 12 hours Closes #245 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/ControllerCommandHandler.ts | 37 ++- .../src/controller/TimeSyncManager.ts | 203 ++++++++++++++++ .../test/controller/TimeSyncManagerTest.ts | 225 ++++++++++++++++++ packages/ws-controller/test/tsconfig.json | 18 ++ packages/ws-controller/tsconfig.json | 2 +- 5 files changed, 480 insertions(+), 5 deletions(-) create mode 100644 packages/ws-controller/src/controller/TimeSyncManager.ts create mode 100644 packages/ws-controller/test/controller/TimeSyncManagerTest.ts create mode 100644 packages/ws-controller/test/tsconfig.json diff --git a/packages/ws-controller/src/controller/ControllerCommandHandler.ts b/packages/ws-controller/src/controller/ControllerCommandHandler.ts index 5bb5dd29..220f397c 100644 --- a/packages/ws-controller/src/controller/ControllerCommandHandler.ts +++ b/packages/ws-controller/src/controller/ControllerCommandHandler.ts @@ -33,6 +33,7 @@ import { BridgedDeviceBasicInformation, GeneralCommissioning, OperationalCredentials, + TimeSynchronization, } from "@matter/main/clusters"; import { DecodedAttributeReportValue, @@ -117,6 +118,7 @@ import { formatNodeId } from "../util/formatNodeId.js"; import { pingIp } from "../util/network.js"; import { CustomClusterPoller } from "./CustomClusterPoller.js"; import { Nodes } from "./Nodes.js"; +import { TimeSyncManager } from "./TimeSyncManager.js"; const logger = Logger.get("ControllerCommandHandler"); @@ -174,6 +176,8 @@ export class ControllerCommandHandler { #availableUpdates = new Map(); /** Poller for custom cluster attributes (Eve energy, etc.) */ #customClusterPoller: CustomClusterPoller; + /** Manages time synchronization for nodes with the TimeSynchronization cluster */ + #timeSyncManager: TimeSyncManager; /** Track the last known availability for each node to detect changes */ #lastAvailability = new Map(); /** Track in-flight invoke-commands for deduplication across all WebSocket connections */ @@ -207,6 +211,12 @@ export class ControllerCommandHandler { handleReadAttributes: (nodeId, paths, fabricFiltered) => this.handleReadAttributes(nodeId, paths, fabricFiltered), }); + + // Initialize time sync manager for nodes with TimeSynchronization cluster + this.#timeSyncManager = new TimeSyncManager({ + syncTime: nodeId => this.#syncNodeTime(nodeId), + nodeConnected: nodeId => !!(this.#nodes.has(nodeId) && this.#nodes.get(nodeId).isConnected), + }); } /** @@ -286,9 +296,22 @@ export class ControllerCommandHandler { } } + /** + * Send a setUtcTime command to a node's TimeSynchronization cluster. + */ + async #syncNodeTime(nodeId: NodeId): Promise { + const client = this.#nodes.clusterClientByIdFor(nodeId, EndpointNumber(0), TimeSynchronization.Cluster.id); + await client.commands.setUtcTime({ + utcTime: Date.now() * 1000, + granularity: TimeSynchronization.Granularity.MillisecondsGranularity, + timeSource: TimeSynchronization.TimeSource.Admin, + }); + } + close() { if (!this.#started) return; this.#customClusterPoller.stop(); + this.#timeSyncManager.stop(); return this.#controller.close(); } @@ -317,7 +340,10 @@ export class ControllerCommandHandler { this.events.nodeStructureChanged.emit(nodeId); } }); - node.events.eventTriggered.on(data => this.events.eventChanged.emit(nodeId, data)); + node.events.eventTriggered.on(data => { + this.events.eventChanged.emit(nodeId, data); + this.#timeSyncManager.handleEvent(nodeId, data); + }); node.events.stateChanged.on(state => { if (state === NodeStates.Disconnected) { return; @@ -331,10 +357,11 @@ export class ControllerCommandHandler { // Only refresh cache on Connected state (not Reconnecting, WaitingForDiscovery, etc.) if (state === NodeStates.Connected) { attributeCache.update(node); - // Register for custom cluster polling (e.g., Eve energy) after cache is updated + // Register for custom cluster polling (e.g., Eve energy) and time sync after cache is updated const attributes = attributeCache.get(nodeId); if (attributes) { this.#customClusterPoller.registerNode(nodeId, attributes); + this.#timeSyncManager.registerNode(nodeId, attributes); } } @@ -380,10 +407,11 @@ export class ControllerCommandHandler { // Initialize attribute cache if node is already initialized if (node.initialized) { attributeCache.add(node); - // Register for custom cluster polling (e.g., Eve energy) + // Register for custom cluster polling (e.g., Eve energy) and time sync const attributes = attributeCache.get(nodeId); if (attributes) { this.#customClusterPoller.registerNode(nodeId, attributes); + this.#timeSyncManager.registerNode(nodeId, attributes); } } @@ -1193,8 +1221,9 @@ export class ControllerCommandHandler { await this.#controller.removeNode(nodeId, !!node?.isConnected); // Remove node from storage (also clears attribute cache) this.#nodes.delete(nodeId); - // Unregister from custom cluster polling + // Unregister from custom cluster polling and time sync this.#customClusterPoller.unregisterNode(nodeId); + this.#timeSyncManager.unregisterNode(nodeId); } async openCommissioningWindow(data: OpenCommissioningWindowRequest): Promise { diff --git a/packages/ws-controller/src/controller/TimeSyncManager.ts b/packages/ws-controller/src/controller/TimeSyncManager.ts new file mode 100644 index 00000000..7f27228d --- /dev/null +++ b/packages/ws-controller/src/controller/TimeSyncManager.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Handles time synchronization for nodes with the TimeSynchronization cluster. + * Syncs UTC time on three triggers: + * 1. Node connects/reconnects (immediate) + * 2. timeFailure event from the node (reactive) + * 3. Periodic resync every 12 hours + */ + +import { CancelablePromise, Duration, Logger, Millis, NodeId, Time, Timer } from "@matter/main"; +import { DecodedEventReportValue } from "@matter/main/protocol"; +import { AttributesData } from "../types/CommandHandler.js"; + +const logger = Logger.get("TimeSyncManager"); + +// TimeSynchronization cluster ID (0x0038 = 56 decimal) +const TIME_SYNC_CLUSTER_ID = 0x0038; + +// timeFailure event ID within TimeSynchronization cluster +const TIME_FAILURE_EVENT_ID = 0x03; + +// Periodic resync interval: 12 hours +const RESYNC_INTERVAL_MS = 12 * 60 * 60 * 1000; + +// Maximum initial delay in milliseconds (random 0-60s to stagger startup) +const MAX_INITIAL_DELAY_MS = 60_000; + +export interface TimeSyncConnector { + syncTime(nodeId: NodeId): Promise; + nodeConnected(nodeId: NodeId): boolean; +} + +/** + * Check if a node has the TimeSynchronization cluster based on its attribute cache. + * The cluster is always on endpoint 0 per the Matter spec. + */ +export function hasTimeSyncCluster(attributes: AttributesData): boolean { + const prefix = `0/${TIME_SYNC_CLUSTER_ID}/`; + for (const key of Object.keys(attributes)) { + if (key.startsWith(prefix)) { + return true; + } + } + return false; +} + +/** + * Manages time synchronization for nodes with the TimeSynchronization cluster. + */ +export class TimeSyncManager { + #registeredNodes = new Set(); + #resyncTimer: Timer; + #connector: TimeSyncConnector; + #isResyncing = false; + #currentDelayPromise?: CancelablePromise; + #closed = false; + + constructor(connector: TimeSyncConnector) { + this.#connector = connector; + const delay = Millis(Math.random() * MAX_INITIAL_DELAY_MS); + this.#resyncTimer = Time.getTimer("time-sync-resync", delay, () => this.#resyncAllNodes()); + } + + /** + * Register a node for time sync if it has the TimeSynchronization cluster. + * Call this after a node connects and its attributes are available. + * Immediately syncs time on the node (fire-and-forget). + */ + registerNode(nodeId: NodeId, attributes: AttributesData): void { + if (!hasTimeSyncCluster(attributes)) { + this.unregisterNode(nodeId); + return; + } + + const isNew = !this.#registeredNodes.has(nodeId); + this.#registeredNodes.add(nodeId); + + if (isNew) { + logger.info(`Registered node ${nodeId} for time synchronization`); + } + + // Sync time immediately on connect/reconnect + this.#syncNode(nodeId); + + // Start periodic resync if not already running + this.#scheduleResync(); + } + + /** + * Unregister a node from time sync tracking. + */ + unregisterNode(nodeId: NodeId): void { + if (this.#registeredNodes.delete(nodeId)) { + logger.info(`Unregistered node ${nodeId} from time synchronization`); + } + if (this.#registeredNodes.size === 0) { + this.#resyncTimer.stop(); + } + } + + /** + * Handle an event from a node. If it's a timeFailure event, sync time. + */ + handleEvent(nodeId: NodeId, data: DecodedEventReportValue): void { + const { path } = data; + if (path.clusterId === TIME_SYNC_CLUSTER_ID && path.eventId === TIME_FAILURE_EVENT_ID) { + logger.info(`Received timeFailure event from node ${nodeId}, syncing time`); + this.#syncNode(nodeId); + } + } + + /** + * Stop all time sync operations and cleanup. + */ + stop(): void { + this.#closed = true; + this.#currentDelayPromise?.cancel(new Error("Close")); + this.#resyncTimer?.stop(); + this.#registeredNodes.clear(); + logger.info("Time sync manager stopped"); + } + + /** + * Sync time on a single node (fire-and-forget with error handling). + */ + #syncNode(nodeId: NodeId): void { + if (this.#closed || !this.#registeredNodes.has(nodeId)) { + return; + } + if (!this.#connector.nodeConnected(nodeId)) { + logger.debug(`Node ${nodeId} not connected, skipping time sync`); + return; + } + this.#connector.syncTime(nodeId).then( + () => logger.info(`Synced time on node ${nodeId}`), + error => logger.warn(`Failed to sync time on node ${nodeId}:`, error), + ); + } + + #scheduleResync(): void { + if (this.#registeredNodes.size === 0 || this.#closed) { + return; + } + if (this.#resyncTimer?.isRunning || this.#isResyncing) { + return; + } + this.#resyncTimer.start(); + } + + async #resyncAllNodes(): Promise { + if (this.#isResyncing) { + return; + } + + const targetInterval = Millis(RESYNC_INTERVAL_MS); + if (this.#resyncTimer.interval !== targetInterval) { + this.#resyncTimer.interval = targetInterval; + } + + this.#isResyncing = true; + + let syncedNodes = 0; + try { + const nodes = Array.from(this.#registeredNodes); + for (let i = 0; i < nodes.length; i++) { + const nodeId = nodes[i]; + if (!this.#registeredNodes.has(nodeId)) { + continue; + } + if (!this.#connector.nodeConnected(nodeId)) { + continue; + } + syncedNodes++; + try { + await this.#connector.syncTime(nodeId); + logger.info(`Periodic resync: synced time on node ${nodeId}`); + } catch (error) { + logger.warn(`Periodic resync: failed to sync time on node ${nodeId}:`, error); + } + // Small delay between nodes to avoid overwhelming the network + if (i < nodes.length - 1) { + this.#currentDelayPromise = Time.sleep("sleep", Millis(2_000)).finally(() => { + this.#currentDelayPromise = undefined; + }); + await this.#currentDelayPromise; + } + } + } finally { + this.#isResyncing = false; + this.#scheduleResync(); + } + if (syncedNodes > 0) { + logger.info( + `Periodic resync complete: synced ${syncedNodes} nodes. Next resync in ${Duration.format(this.#resyncTimer.interval)}`, + ); + } + } +} diff --git a/packages/ws-controller/test/controller/TimeSyncManagerTest.ts b/packages/ws-controller/test/controller/TimeSyncManagerTest.ts new file mode 100644 index 00000000..789f7407 --- /dev/null +++ b/packages/ws-controller/test/controller/TimeSyncManagerTest.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NodeId } from "@matter/main"; +import { hasTimeSyncCluster, TimeSyncManager, TimeSyncConnector } from "../../src/controller/TimeSyncManager.js"; +import { AttributesData } from "../../src/types/CommandHandler.js"; + +// TimeSynchronization cluster ID = 0x38 (56 decimal), always on endpoint 0 +// Attribute keys follow the format: endpoint/cluster/attribute +const TIME_SYNC_ATTRIBUTE_PREFIX = "0/56/"; + +// timeFailure event: clusterId=0x38, eventId=0x3 +const TIME_SYNC_CLUSTER_ID = 0x0038; +const TIME_FAILURE_EVENT_ID = 0x03; + +function createMockConnector(overrides?: Partial): TimeSyncConnector & { + syncTimeCalls: NodeId[]; +} { + const syncTimeCalls: NodeId[] = []; + + return { + syncTimeCalls, + syncTime: + overrides?.syncTime ?? + (async (nodeId: NodeId) => { + syncTimeCalls.push(nodeId); + }), + nodeConnected: overrides?.nodeConnected ?? (() => true), + }; +} + +function makeTimeFailureEvent(nodeId?: NodeId) { + return { + path: { + nodeId: nodeId ?? NodeId(1), + endpointId: 0, + clusterId: TIME_SYNC_CLUSTER_ID, + eventId: TIME_FAILURE_EVENT_ID, + }, + events: [], + } as any; +} + +function makeUnrelatedEvent() { + return { + path: { + nodeId: NodeId(1), + endpointId: 0, + clusterId: 0x0028, // BasicInformation + eventId: 0x00, + }, + events: [], + } as any; +} + +describe("hasTimeSyncCluster", () => { + it("returns true when TimeSynchronization attributes exist", () => { + const attributes: AttributesData = { + "0/40/0": 17, // BasicInformation + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, // TimeSynchronization.utcTime + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, // TimeSynchronization.granularity + }; + expect(hasTimeSyncCluster(attributes)).to.be.true; + }); + + it("returns false when no TimeSynchronization attributes exist", () => { + const attributes: AttributesData = { + "0/40/0": 17, // BasicInformation + "0/40/2": 4874, // VendorID + "1/6/0": false, // OnOff + }; + expect(hasTimeSyncCluster(attributes)).to.be.false; + }); + + it("returns false for empty attributes", () => { + expect(hasTimeSyncCluster({})).to.be.false; + }); + + it("returns false when TimeSynchronization cluster is on non-root endpoint", () => { + const attributes: AttributesData = { + "1/56/0": null, // TimeSynchronization on endpoint 1 (not spec-compliant) + }; + expect(hasTimeSyncCluster(attributes)).to.be.false; + }); +}); + +describe("TimeSyncManager", () => { + it("syncs time immediately when registering a node with TimeSynchronization cluster", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + + manager.registerNode(nodeId, attributes); + expect(connector.syncTimeCalls).to.have.lengthOf(1); + expect(connector.syncTimeCalls[0]).to.equal(nodeId); + manager.stop(); + }); + + it("does not sync nodes without TimeSynchronization cluster", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + "0/40/0": 17, + "1/6/0": false, + }; + + manager.registerNode(nodeId, attributes); + expect(connector.syncTimeCalls).to.have.lengthOf(0); + manager.stop(); + }); + + it("syncs time on timeFailure event", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + // Register the node first + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + manager.registerNode(nodeId, attributes); + connector.syncTimeCalls.length = 0; // clear the initial sync call + + manager.handleEvent(nodeId, makeTimeFailureEvent(nodeId)); + expect(connector.syncTimeCalls).to.have.lengthOf(1); + expect(connector.syncTimeCalls[0]).to.equal(nodeId); + manager.stop(); + }); + + it("does not sync on unrelated events", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + manager.registerNode(nodeId, attributes); + connector.syncTimeCalls.length = 0; + + manager.handleEvent(nodeId, makeUnrelatedEvent()); + expect(connector.syncTimeCalls).to.have.lengthOf(0); + manager.stop(); + }); + + it("does not sync after unregisterNode", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + manager.registerNode(nodeId, attributes); + connector.syncTimeCalls.length = 0; + + manager.unregisterNode(nodeId); + manager.handleEvent(nodeId, makeTimeFailureEvent(nodeId)); + expect(connector.syncTimeCalls).to.have.lengthOf(0); + manager.stop(); + }); + + it("does not sync after stop", () => { + const connector = createMockConnector(); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + manager.registerNode(nodeId, attributes); + connector.syncTimeCalls.length = 0; + + manager.stop(); + manager.handleEvent(nodeId, makeTimeFailureEvent(nodeId)); + expect(connector.syncTimeCalls).to.have.lengthOf(0); + }); + + it("skips sync when node is not connected", () => { + const connector = createMockConnector({ nodeConnected: () => false }); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + manager.registerNode(nodeId, attributes); + expect(connector.syncTimeCalls).to.have.lengthOf(0); + manager.stop(); + }); + + it("handles syncTime errors without throwing", () => { + const connector = createMockConnector({ + syncTime: async () => { + throw new Error("connection lost"); + }, + }); + const manager = new TimeSyncManager(connector); + const nodeId = NodeId(1); + + const attributes: AttributesData = { + [`${TIME_SYNC_ATTRIBUTE_PREFIX}0`]: null, + [`${TIME_SYNC_ATTRIBUTE_PREFIX}1`]: 0, + }; + + // Should not throw + expect(() => manager.registerNode(nodeId, attributes)).to.not.throw(); + manager.stop(); + }); +}); diff --git a/packages/ws-controller/test/tsconfig.json b/packages/ws-controller/test/tsconfig.json new file mode 100644 index 00000000..07716500 --- /dev/null +++ b/packages/ws-controller/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tools/tsc/tsconfig.test.json", + "compilerOptions": { + "types": [ + "node", + "mocha", + "@matter/testing" + ] + }, + "references": [ + { + "path": "../../tools/src" + }, + { + "path": "../src" + } + ] +} diff --git a/packages/ws-controller/tsconfig.json b/packages/ws-controller/tsconfig.json index 60582e0c..1a108f0b 100644 --- a/packages/ws-controller/tsconfig.json +++ b/packages/ws-controller/tsconfig.json @@ -1,5 +1,5 @@ { "compilerOptions": { "composite": true }, "files": [], - "references": [{ "path": "src" }] + "references": [{ "path": "src" }, { "path": "test" }] }