# 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.