Skip to content

Add TimeSync support for nodes with TimeSynchronization cluster#440

Open
markvp wants to merge 1 commit intomatter-js:mainfrom
markvp:feat/245-time-sync
Open

Add TimeSync support for nodes with TimeSynchronization cluster#440
markvp wants to merge 1 commit intomatter-js:mainfrom
markvp:feat/245-time-sync

Conversation

@markvp
Copy link
Copy Markdown
Contributor

@markvp markvp commented Mar 24, 2026

Summary

  • Adds TimeSyncManager that proactively syncs UTC time on nodes with the TimeSynchronization cluster (0x38)
  • Syncs time on three triggers: node connect/reconnect (immediate), timeFailure event (reactive), and periodic resync every 12 hours
  • Follows the existing CustomClusterPoller pattern for consistency

Context

Devices like IKEA ALPSTUGA lack battery backup and lose their time after power loss. The controller previously only set time during commissioning, causing timeSynchronization.timeFailure events after reconnection.

The setUtcTime command is sent with MillisecondsGranularity and TimeSource.Admin. TlvEpochUs handles automatic conversion from Unix epoch to Matter epoch (2000-01-01).

Changes

  • New: packages/ws-controller/src/controller/TimeSyncManager.tsTimeSyncManager class and hasTimeSyncCluster() utility
  • New: packages/ws-controller/test/controller/TimeSyncManagerTest.ts — 12 unit tests
  • New: packages/ws-controller/test/tsconfig.json — enables test compilation for ws-controller
  • Modified: packages/ws-controller/src/controller/ControllerCommandHandler.ts — integrates TimeSyncManager at all lifecycle points
  • Modified: packages/ws-controller/tsconfig.json — adds test reference

Test plan

  • npm run format passes
  • npm run lint passes (0 warnings, 0 errors)
  • npm run build passes (including test type validation)
  • npm test — all 12 ws-controller unit tests pass
  • Manual test: commission an ALPSTUGA, power-cycle it, verify time sync on reconnect

Closes #245

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings March 24, 2026 11:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TimeSyncManager with cluster detection and periodic resync scheduling
  • Integrate TimeSyncManager into 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.

Comment on lines +109 to +115
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);
}
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +184
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);
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
// Small delay between nodes to avoid overwhelming the network
if (i < nodes.length - 1) {
this.#currentDelayPromise = Time.sleep("sleep", Millis(2_000)).finally(() => {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
this.#currentDelayPromise = Time.sleep("sleep", Millis(2_000)).finally(() => {
this.#currentDelayPromise = Time.sleep("time-sync-resync-delay", Millis(2_000)).finally(() => {

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +202
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)}`,
);
}
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@piitaya
Copy link
Copy Markdown

piitaya commented Mar 24, 2026

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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above: we need an initial startup protection delay!

*/
stop(): void {
this.#closed = true;
this.#currentDelayPromise?.cancel(new Error("Close"));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Apollon77
Copy link
Copy Markdown
Collaborator

@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

@agners
Copy link
Copy Markdown

agners commented Mar 25, 2026

@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 /supervisor/info endpoint in the Supervisor developer docs). I'd only sync time if that is true, since otherwise a freshly started Raspberry Pi (which has no RTC) may sync a wrong time to devices after power outage 🥶 ).

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.

@Apollon77
Copy link
Copy Markdown
Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add TimeSync support

5 participants