diff options
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. |
