# mattertimesync A small CLI Matter controller that sets the clocks of Matter devices via the standard Time Synchronization cluster. Some Matter devices with clock displays (air-quality monitors, thermostats, sensor hubs) lose their clock after a power outage, and not every ecosystem restores it reliably. This tool joins each device's existing Matter setup as an _additional_ controller (Matter multi-admin) and pushes the correct time to every device commissioned onto its fabric. Run it periodically from a systemd timer; the primary ecosystem (e.g. Apple Home) keeps working unchanged. There is no daemon: each invocation connects, syncs, and exits. The main repository is ; the [GitHub repository](https://github.com/lukehoersten/mattertimesync) is a backup mirror. ## How it works - The device stays commissioned to its primary ecosystem and attached to its existing Thread or Wi-Fi network. - A Raspberry Pi (or any Linux host) runs this tool as a second Matter administrator on its own fabric. - For Thread devices, the host needs no Thread radio, no OpenThread Border Router, no Thread credentials, and no BLE. It reaches the device over IPv6 through the existing Thread Border Routers (e.g. HomePod, Apple TV). A host on the same network segment as the border routers learns the route to the Thread network automatically from their Router Advertisements; a host on a different VLAN needs one static route on the gateway (see Troubleshooting). - The host's own clock is NTP-synchronized; the tool refuses to push time from an unsynchronized clock. Requirements on the host: - Node.js 20 or newer (current LTS recommended) - working IPv6 and multicast DNS on the network shared with the device's border router - system time synchronized via NTP (`systemd-timesyncd` or equivalent) ## Design Principles the implementation holds to: - **One-shot CLI, not a daemon.** Being a secondary Matter controller is fabric membership, not a running process. During commissioning the device permanently stores this controller's fabric entry (root certificate and operational credential) in its own flash, alongside the primary ecosystem's; that relationship persists with no process running. Each invocation loads the stored keys, discovers the device, opens a fresh CASE session, does its work, and exits. A daemon's only real advantage would be reactivity: a held subscription notices a device reboot within seconds. The one-shot design bounds clock staleness to the timer interval instead, and in exchange drops all long-lived-connection machinery (reconnect detection, backoff, debouncing, in-process scheduling). For a clock display after a rare power outage, "wrong for at most an hour" is an acceptable trade, and the interval can be tightened since each run costs only seconds. If second-level reactivity is ever wanted, a `watch` command holding a subscription is an additive feature on the same modules, not a redesign. - **Capability-driven, never vendor-specific.** Any Matter device exposing the standard Time Synchronization cluster (0x38) works. Sync behavior is driven by a live inspection of the device's actual features, never by assumptions about a particular product; optional features a device lacks are skipped with the limitation logged (UTC-only devices get UTC only), and a missing cluster is a clear compatibility error. - **The fabric is created exactly once.** All Matter identity and fabric data persists in `storagePath` and is never recreated on launch. Reconnection uses Matter operational discovery from the saved node identity, never a stored IP address, so border-router changes, device reboots, and mDNS churn are tolerated. - **Fail safely.** The device is never recommissioned automatically, and time is never pushed from a host clock that is not NTP-synchronized. Transient failures exit non-zero and the next timer tick retries. - **Scheduling belongs to systemd**, not the application: the timer unit owns the interval, and device membership lives in controller storage, so the configuration stays three fields. - **Matter time is bigint end to end.** Matter UTC time is microseconds since 2000-01-01T00:00:00Z (not the Unix epoch); the full timestamp never passes through a JavaScript `number`. Time zones are IANA names with offsets and DST transitions derived from the host's time-zone database, never fixed offsets. - **Intentionally small.** No GUI, database, cloud service, Home Assistant integration, MQTT, REST API, metrics, Thread Border Router functionality, BLE commissioning, or firmware updates. Multiple devices are supported, but only as the same sync applied to each commissioned node. ## Project status Fully implemented: configuration, persistent controller fabric, on-network multi-admin commissioning, endpoint/cluster inspection, and the `sync` command (SetUTCTime, SetTimeZone, SetDSTOffset with read-back verification), validated against real hardware (IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home). The device's `inspect --json` capture lives in `test/fixtures/` and drives the capability-handling unit tests. ## Build ```bash git clone cd mattertimesync npm install npm run build ``` Run tests and lint: ```bash npm test npm run lint ``` ## Packaging for deployment All runtime dependencies are pure JavaScript (no native modules), so the whole tool bundles into one self-contained file. The target machine needs only `node` (20 or newer): no npm, no registry access, no git checkout, no build. ```bash npm run bundle # produces mattertimesync.mjs (~4.5 MB) scp mattertimesync.mjs pi: ssh pi sudo install -D -m 0755 mattertimesync.mjs /opt/mattertimesync/mattertimesync.mjs ``` Run it as `node /opt/mattertimesync/mattertimesync.mjs ...` (the systemd unit does exactly this). Upgrades replace the one file; Matter state under `/var/lib/mattertimesync` is untouched. Alternative, if you prefer a `mattertimesync` command on PATH and don't mind npm on the target: `npm pack` produces a small tarball whose `npm install -g ` pulls dependencies from the registry (adjust the unit's `ExecStart` accordingly). ## Configuration The CLI reads `/etc/mattertimesync/config.json` by default; override with `--config `. See `config.example.json`: ```json { "storagePath": "/var/lib/mattertimesync", "timezone": "America/Chicago", "logLevel": "info" } ``` | Field | Default | Meaning | | ------------- | ----------------- | --------------------------------------------------------------- | | `storagePath` | (required) | Directory for persistent Matter fabric state. Contains secrets. | | `timezone` | `America/Chicago` | IANA time-zone name. Never a fixed UTC offset; DST is derived. | | `logLevel` | `info` | `debug`, `info`, `warn`, or `error`. | Scheduling lives in the systemd timer, and device membership lives in controller storage; neither is in the configuration. The set of devices kept in sync is exactly the set commissioned onto this controller's fabric, changed only by `commission` and `decommission`. The storage directory holds the controller's private keys and operational certificates. Keep it mode `0700`, owned by the service user, and out of Git. Deleting it destroys this controller's fabric identity; the stale fabric would then need to be removed from the device before commissioning again. ## Commissioning 1. In the device's primary ecosystem, open its pairing mode (Apple Home: device settings, _Turn On Pairing Mode_). The ecosystem shows a temporary setup code. 2. Within the pairing window, run (as the service user, so the timer job can reuse the same storage): ```bash sudo -u mattertimesync \ node /opt/mattertimesync/mattertimesync.mjs \ --config /etc/mattertimesync/config.json \ commission 12345678901 ``` The code is used once, never logged, and never stored. There is no interactive mode; the pairing code is a required argument. Commissioning discovers the device via `_matterc._udp` on the IP network, joins it to this controller's fabric, prints a full endpoint and cluster inspection, and records the node ID. The controller fabric is created once and reused for every device and every subsequent start. To sync several devices, repeat the process per device: Matter multi-admin pairing is per-device, so each device's pairing mode is opened individually in the primary ecosystem, yielding one code per device. A device already on this fabric rejects re-commissioning by itself (fabric conflict), so duplicates cannot occur. ## CLI Read-only commands, which never modify a device: ```bash mattertimesync status [--json] # contacts no device at all mattertimesync nodes [--json] mattertimesync inspect [--node ] [--json] ``` Commands with side effects: ```bash mattertimesync commission # writes the device's fabric table + local storage mattertimesync sync [--node ] [--json] # sets the device's clock, time zone, DST offsets mattertimesync decommission [--node ] # device(s) drop this controller's fabric ``` `--node ` selects which device a command targets; it may be omitted while only one device is commissioned. `sync` targets all commissioned devices by default. All logs go to stderr; stdout carries only command output. `--json` therefore always emits clean, parseable JSON (pipe it straight into `jq`), with 64-bit values (node IDs, Matter timestamps) rendered as decimal strings so nothing passes through a lossy JavaScript number. - `nodes` lists every commissioned device with its cached vendor/product name and per-device last connection and sync results. - `inspect` connects to the selected node and prints vendor/product information, every endpoint with its server and client clusters, the Time Synchronization cluster's feature map, supported commands, and attribute values, plus the device's fabric table: every commissioned controller (label, vendor, fabric and node IDs, slot usage), with this controller's own entry marked and likely stale entries flagged (ones carrying our label but belonging to an old identity whose storage was deleted). `inspect` never modifies the device, and this tool deliberately does not use its admin rights against other fabrics' entries; stale ones are cleaned up from the primary ecosystem (see "Removing this controller from the device"). `--json` emits the same data machine-readable (node IDs and timestamps as strings, safe for 64-bit values). - `sync` connects to each targeted device, verifies the host clock is NTP-synchronized (refusing to run otherwise), sets UTC time, configures the time zone and DST transitions when the device supports them (bounded by the device's list capacity), verifies by reading the clock back, then exits. For each device it reports the correction made: the device's clock before the sync (or "unset" after a power loss), the time written, and the delta, e.g. `device clock was 1m 23s behind`. A device whose firmware encodes Unix-epoch time on the wire (a known bug class) is detected by the exact 946,684,800s shift and reported with the shift corrected instead of as "30 years ahead". Exits non-zero if any device fails, so the systemd timer's next run retries. - `status` prints controller state, configured time zone with the current UTC offset and next DST transition, host NTP status, and per-device last connection/sync results. - `decommission` gracefully removes this controller's fabric from every commissioned device, or from just one with `--node` (primary ecosystems are untouched), and drops the matching state entries. Delete `storagePath` only after every device has been decommissioned. ## Install with a systemd timer Install the bundled file first (see "Packaging for deployment"), then create the service account and directories: ```bash sudo useradd \ --system \ --home /var/lib/mattertimesync \ --shell /usr/sbin/nologin \ mattertimesync sudo mkdir -p /etc/mattertimesync sudo mkdir -p /var/lib/mattertimesync sudo chown -R mattertimesync:mattertimesync /var/lib/mattertimesync sudo chmod 700 /var/lib/mattertimesync ``` Place the configuration at `/etc/mattertimesync/config.json` and install the units (the service runs the bundled file at `/opt/mattertimesync/mattertimesync.mjs`): ```bash sudo cp mattertimesync.service mattertimesync.timer /etc/systemd/system/ sudo systemctl daemon-reload ``` Commission first (see above), then enable the timer: ```bash sudo systemctl enable --now mattertimesync.timer ``` The timer runs a sync 2 minutes after boot and hourly thereafter (see `mattertimesync.timer` to adjust). Run a sync on demand with `sudo systemctl start mattertimesync.service`. ## Backup and restore Back up: ```text /var/lib/mattertimesync /etc/mattertimesync/config.json ``` Restoring both paths preserves the controller fabric credentials, the device's node ID, the controller identity, and service state, so no recommissioning is needed. Keep backups as restricted as the originals; the storage directory contains private keys. ## Removing this controller from the device Run `mattertimesync decommission` to undo commissioning: the device drops this controller's fabric while staying paired to its primary ecosystem, and deleting `/var/lib/mattertimesync` becomes safe. Order matters. Deleting the storage directory first destroys the keys, leaving a stale fabric entry occupying one of the device's limited fabric slots (spec minimum is 5). Orphans accumulate if storage is deleted and the device recommissioned repeatedly (e.g. during testing). To find them, run `mattertimesync inspect`: stale entries carry our label but not our current identity. This tool deliberately does not delete fabric-table entries (not even its own orphans); remove them in Apple Home (device settings, _Connected Services_) or, as a last resort, factory-reset the device and re-pair it. ## Troubleshooting ```bash ip -6 addr ip -6 route avahi-browse -art timedatectl status timedatectl show -p NTPSynchronized journalctl -u mattertimesync.service -f systemctl list-timers mattertimesync.timer ``` **Commissionable device not found**: confirm the pairing window is still open (it expires), that the host and the border router are on networks that permit mDNS/multicast, that IPv6 is enabled, and that no firewall or container bridge blocks Matter UDP traffic. **Commissioning succeeds but operational connection fails**: check IPv6 routing to the device's network, `_matter._tcp` DNS-SD discovery, that the persisted storage path is readable by the user running the CLI, and that the border routers are reachable. **Thread device is discovered but its `fd...` address is unreachable** (pings time out, commissioning or sync reports the address unreachable): this is the expected failure mode when the host sits on a different VLAN or subnet than the Thread border routers, and the fix is one static route on the gateway. Thread devices live behind the border routers on a ULA prefix (the off-mesh-routable "OMR" prefix, a random `fdxx:.../64`). The border routers advertise the route to that prefix in Router Advertisements (RFC 4191 Route Information Options), but RAs never leave their own link: hosts on the same L2 segment pick the route up automatically and send Thread-bound packets straight to a border router, while a host on another VLAN can only hand them to its gateway, and typical home gateways (UniFi included) do not listen to RA route options. The result is deceptive because mDNS reflection across VLANs still lets discovery find the device's address; the packets then die at the gateway with no ICMP error. Add a static IPv6 route on the gateway: destination = the `/64` of the device's `fd...` address, next hop = a border router's address on its VLAN. Prefer a wired, always-on border router (an Apple TV over a HomePod), and use its stable mDNS-advertised address, not a temporary privacy address that rotates daily and would silently blackhole the route. Verify from the host with `ping ` before retrying. **Clock is off by one hour**: a fixed offset was used instead of IANA rules, or DST configuration is missing or rejected. This tool always derives offsets from the IANA database. **Clock shows an absurd future date**: the Unix epoch was used instead of the Matter epoch (2000-01-01T00:00:00Z), or seconds were sent where microseconds were expected. All conversion here is bigint arithmetic, and epoch handling follows matter.js's convention: its `TlvEpochUs` API accepts and returns Unix-epoch microseconds and performs the Matter-epoch wire conversion itself. A device whose firmware gets this wrong on the wire reads as ~30 years off; `sync` detects the exact 946,684,800s shift and reports the corrected delta.