Add TimeSync support for nodes with TimeSynchronization cluster#440
Add TimeSync support for nodes with TimeSynchronization cluster#440markvp wants to merge 1 commit intomatter-js:mainfrom
Conversation
…n 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 matter-js#245 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds proactive UTC time synchronization for Matter nodes that implement the TimeSynchronization cluster, ensuring devices that lose time after power loss are resynced on reconnect, on timeFailure, and periodically.
Changes:
- Introduce
TimeSyncManagerwith cluster detection and periodic resync scheduling - Integrate
TimeSyncManagerinto controller lifecycle (connect, events, removal, close) - Add ws-controller test compilation config and unit tests for sync triggers
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/ws-controller/tsconfig.json | Adds test project reference so ws-controller tests are typechecked/compiled. |
| packages/ws-controller/test/tsconfig.json | New tsconfig for ws-controller tests, wiring in shared test settings and references. |
| packages/ws-controller/test/controller/TimeSyncManagerTest.ts | New unit tests for cluster detection + immediate/reactive sync behavior. |
| packages/ws-controller/src/controller/TimeSyncManager.ts | Implements the TimeSyncManager (register/unregister, event-driven sync, periodic resync). |
| packages/ws-controller/src/controller/ControllerCommandHandler.ts | Wires TimeSyncManager into node lifecycle/events and adds setUtcTime command sender. |
| handleEvent(nodeId: NodeId, data: DecodedEventReportValue<any>): 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
This logs syncing time even when the manager is stopped or the node isn’t registered/connected (since #syncNode will early-return). Consider guarding here (e.g., check this.#closed, this.#registeredNodes.has(nodeId), and optionally this.#connector.nodeConnected(nodeId)) before logging, or have #syncNode return a boolean indicating whether a sync was actually attempted so the log reflects reality.
| 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); | ||
| } |
There was a problem hiding this comment.
syncedNodes is incremented before syncTime succeeds, but later the summary log says synced ${syncedNodes} nodes, which will over-report when there are failures. Track separate counters (e.g., attempted vs succeeded) or increment only after a successful await this.#connector.syncTime(nodeId) to keep the summary accurate.
| } | ||
| // Small delay between nodes to avoid overwhelming the network | ||
| if (i < nodes.length - 1) { | ||
| this.#currentDelayPromise = Time.sleep("sleep", Millis(2_000)).finally(() => { |
There was a problem hiding this comment.
The timer/sleep label \"sleep\" is very generic and makes diagnostics harder (and can be ambiguous if the underlying timer system uses names for tracing). Use a more specific name (e.g., \"time-sync-resync-delay\") so logs/traces clearly attribute these delays to TimeSyncManager.
| this.#currentDelayPromise = Time.sleep("sleep", Millis(2_000)).finally(() => { | |
| this.#currentDelayPromise = Time.sleep("time-sync-resync-delay", Millis(2_000)).finally(() => { |
| async #resyncAllNodes(): Promise<void> { | ||
| 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)}`, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
The PR adds significant periodic-resync behavior (interval adjustment, node iteration with inter-node delay, error handling, and rescheduling in finally), but the new unit tests don’t appear to cover this path. Add tests using fake timers to validate: (1) resync timer scheduling is started only when nodes are registered, (2) stop() cancels any in-flight delay and prevents further resync, (3) per-node delay is applied between nodes, and (4) failures don’t prevent remaining nodes from being processed/rescheduled.
|
How will it work when multiple matter controller wants to sync time. For example, if I connected my IKEA ALPSTUGA to 2 matters server with different time. I know it's an edge case but we have concurrency issue because each control may have different logic to sync time. |
| */ | ||
| handleEvent(nodeId: NodeId, data: DecodedEventReportValue<any>): void { | ||
| const { path } = data; | ||
| if (path.clusterId === TIME_SYNC_CLUSTER_ID && path.eventId === TIME_FAILURE_EVENT_ID) { |
There was a problem hiding this comment.
The event can also be received because of the update on a reconnect so we need to ensure we do not execute the sync to often in parallel or such
There was a problem hiding this comment.
Also see above directly subscribe to the event and not check any triggered event. thats too inefficient
| /** | ||
| * Send a setUtcTime command to a node's TimeSynchronization cluster. | ||
| */ | ||
| async #syncNodeTime(nodeId: NodeId): Promise<void> { |
There was a problem hiding this comment.
I think thats more needed for it ... check home-assistant/core#166133 which basically implements anything correctly ... and we might also need a trigger on DST changes?
| async #syncNodeTime(nodeId: NodeId): Promise<void> { | ||
| const client = this.#nodes.clusterClientByIdFor(nodeId, EndpointNumber(0), TimeSynchronization.Cluster.id); | ||
| await client.commands.setUtcTime({ | ||
| utcTime: Date.now() * 1000, |
There was a problem hiding this comment.
we have matter.js Time.nowMs that can be used here
| 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); |
There was a problem hiding this comment.
if we only care about one event then it is better to subscribe for this one event using node.eventsOf(TimeSynchronizationClient).xxy.on(...) and add this to an ObserverGroup to clean up on close
| * Syncs UTC time on three triggers: | ||
| * 1. Node connects/reconnects (immediate) | ||
| * 2. timeFailure event from the node (reactive) | ||
| * 3. Periodic resync every 12 hours |
There was a problem hiding this comment.
i think 24h is sufficient because it is an intermediate solution anyway
| 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; |
There was a problem hiding this comment.
Use "Seconds(60)" and also no _MS and better use 30-60mins because we need to ensure to not add additional traffic while the server tries to connect all nodes initially ... we have a bit of time when we do all this normally. We should also prevent initial "hey i connected" triggers from being executed while we startup ...
| } | ||
|
|
||
| // Sync time immediately on connect/reconnect | ||
| this.#syncNode(nodeId); |
There was a problem hiding this comment.
see above: we need an initial startup protection delay!
| */ | ||
| stop(): void { | ||
| this.#closed = true; | ||
| this.#currentDelayPromise?.cancel(new Error("Close")); |
There was a problem hiding this comment.
we should track the current "time sync promise" and await this here if any is in progress and so make this async to have a clean stop
| logger.debug(`Node ${nodeId} not connected, skipping time sync`); | ||
| return; | ||
| } | ||
| this.#connector.syncTime(nodeId).then( |
There was a problem hiding this comment.
as said above. track that promise and cleanup so that we can await it on close and it is not hidden
| /** | ||
| * Manages time synchronization for nodes with the TimeSynchronization cluster. | ||
| */ | ||
| export class TimeSyncManager { |
There was a problem hiding this comment.
the whole class adds a lot of code duplication with the CustomClusterPoller.ts ... because this is done by AI anyway please refactor to use a common "NodeProcessor" class as basis and try to streamline the two use cases as sub classes
|
@piitaya I completely agree :-) Thats why also the real official solution is a bit more effort :-) ... and I need to real all spec on it |
In Home Assistant the Supervisor exposes if the time go synchronized through NTP (see Currently we don't have an event when synchronization state changes 😢 . So we'd probably have to poll the flag for a while if it is false on startup. |
|
Ok, so from what I get ... the interestung question is how to determine IF a host time is synced with any relevant source and so a valid source for it ... and when that happened ... that's maybe a tough one |
Summary
TimeSyncManagerthat proactively syncs UTC time on nodes with the TimeSynchronization cluster (0x38)timeFailureevent (reactive), and periodic resync every 12 hoursCustomClusterPollerpattern for consistencyContext
Devices like IKEA ALPSTUGA lack battery backup and lose their time after power loss. The controller previously only set time during commissioning, causing
timeSynchronization.timeFailureevents after reconnection.The
setUtcTimecommand is sent withMillisecondsGranularityandTimeSource.Admin.TlvEpochUshandles automatic conversion from Unix epoch to Matter epoch (2000-01-01).Changes
packages/ws-controller/src/controller/TimeSyncManager.ts—TimeSyncManagerclass andhasTimeSyncCluster()utilitypackages/ws-controller/test/controller/TimeSyncManagerTest.ts— 12 unit testspackages/ws-controller/test/tsconfig.json— enables test compilation for ws-controllerpackages/ws-controller/src/controller/ControllerCommandHandler.ts— integrates TimeSyncManager at all lifecycle pointspackages/ws-controller/tsconfig.json— adds test referenceTest plan
npm run formatpassesnpm run lintpasses (0 warnings, 0 errors)npm run buildpasses (including test type validation)npm test— all 12 ws-controller unit tests passCloses #245
🤖 Generated with Claude Code