diff options
| author | Luke Hoersten <[email protected]> | 2026-07-26 05:38:33 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-26 21:13:51 -0500 |
| commit | 0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 (patch) | |
| tree | c96198815a3928a69ef06f5f0e4d1882317a6276 /README.md | |
A standalone CLI Matter controller that joins Matter devices' existing
setups as a secondary administrator (multi-admin) and sets their clocks
via the standard Time Synchronization cluster. Built on matter.js 0.17.6
using the CommissioningController API. One-shot runs from a systemd
timer, no daemon: being a secondary controller is fabric membership, not
a running process, so each invocation loads the persisted keys,
discovers the device via operational DNS-SD, opens a fresh CASE session,
does its work, and exits.
- TypeScript scaffold: strict tsc, oxlint, prettier, vitest (66 tests:
Matter epoch conversion, config validation, IANA timezone offsets and
exact DST transition search, atomic state writes, pairing-code
parsing, clock-delta reporting, capability planning against the
captured device fixture)
- Validated JSON configuration with bigint-safe values; unknown fields
rejected loudly
- Persistent controller fabric with the same identity across restarts.
The fabric doubles as the device registry: every node commissioned
onto it is kept in sync, and membership changes only through
commission and decommission, so no separate device list can drift
- On-network multi-admin commissioning from a manual or QR pairing code;
any number of devices, one per-device pairing code each. A device
already on the fabric rejects re-commissioning via fabric conflict
- sync, validated against real hardware (IKEA ALPSTUGA air quality
monitor over Thread, commissioned alongside Apple Home): refuses to
run unless the host is NTP-synchronized, then per node reads utcTime,
reports the correction with direction ("device clock was 1m 23s
behind", handling unset clocks in exact bigint microseconds), writes
SetUTCTime, SetTimeZone (standard offset plus IANA name), and
SetDSTOffset (transition list bounded by the device's capacity), and
verifies by reading the clock back. Capability-driven throughout:
UTC-only devices degrade gracefully, cluster-less devices are skipped
with a warning. Devices whose firmware encodes Unix-epoch time on the
wire are detected by the exact 946684800s shift and reported
corrected. Note matter.js's TlvEpochUs API convention: values are
Unix-epoch microseconds at the API boundary; the library converts to
Matter epoch on the wire
- Commands: nodes, inspect (human/JSON; also reads the fabric table
fabric-unfiltered, matching our own entry by fabric ID plus root
public key and flagging likely-stale orphans; strictly read-only,
since this tool never uses its admin rights against other fabrics'
entries), status, and decommission (each device drops our fabric
while staying paired to its primary ecosystem; targets all devices
unless --node narrows it)
- Logs (service and matter.js library, plain format) go to stderr;
stdout carries only command output, so --json (available on nodes,
inspect, sync, status, fabrics) is always clean parseable JSON with
64-bit values as decimal strings. Node's node:sqlite
ExperimentalWarning is filtered before matter.js loads
- systemd oneshot service (dedicated non-root user, hardening) plus
hourly timer with randomized delay
- Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB):
all runtime deps are pure JavaScript, so the target needs only
Node 20+, with no npm, registry access, or build toolchain. The Bun
sqlite driver stays external behind its runtime guard and node:sqlite
is aliased to a lazy shim so the bundle runs under plain Node. npm
pack remains an alternative install path
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..0387ad0 --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +# 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 <https://src.nth.io/luke/mattertimesync/>; 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 <repository> +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 <tarball>` pulls dependencies from the +registry (adjust the unit's `ExecStart` accordingly). + +## Configuration + +The CLI reads `/etc/mattertimesync/config.json` by default; override with `--config <path>`. +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 <id>] [--json] +``` + +Commands with side effects: + +```bash +mattertimesync commission <pairing-code> # writes the device's fabric table + local storage +mattertimesync sync [--node <id>] [--json] # sets the device's clock, time zone, DST offsets +mattertimesync decommission [--node <id>] # device(s) drop this controller's fabric +``` + +`--node <id>` 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 <the device's fd... address>` 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. |
