src.nth.io/

summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md320
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.