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" }] }