diff options
| author | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
| commit | 07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch) | |
| tree | e9675e5e8a7cfce544927339c8f93ee7f412a71b /README.md | |
Implement mattertimesync: one-shot Matter time synchronization CLI
A standalone CLI Matter controller that joins Matter devices as a
secondary administrator (multi-admin) and sets their clocks via the
standard Time Synchronization cluster. Built on rs-matter 0.2.0, the
official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner
flow, builtin mDNS, and generated cluster clients. One-shot runs from a
systemd timer; there is no daemon.
Hardware-validated end to end (commission, inspect, sync, decommission)
against an IKEA ALPSTUGA air quality monitor over Thread, commissioned
alongside Apple Home. Design and operation are documented in the
README. 34 unit tests, clippy clean.
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c70450 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# mattertimesync + +A small CLI Matter controller that sets the clocks of Matter devices via the standard Time +Synchronization cluster. Rust implementation, built on [rs-matter](https://crates.io/crates/rs-matter) +(the official CSA Rust Matter stack). + +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 builds the Matter stack, connects, syncs, and exits. + +## 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. + +## Design + +The same principles as the TypeScript implementation this replaces, plus the Rust-specific ones: + +- **One-shot CLI, not a daemon.** Being a secondary Matter controller is fabric membership, not a + running process. Each invocation loads the persisted fabric identity, races the Matter transport and + mDNS pumps against the one operation, and exits. Clock staleness is bounded by the systemd timer + interval. +- **Capability-driven, never vendor-specific.** Any device exposing the standard Time Synchronization + cluster (0x38) works. SetTimeZone/SetDSTOffset are gated on the device's feature map and bounded by + its DSTOffsetListMaxSize; devices without the cluster are skipped with a warning; devices whose + firmware encodes Unix-epoch time on the wire are detected by the exact 946,684,800s shift and + reported corrected. +- **The fabric is created exactly once.** First run mints the controller's root CA and operational + identity (RCAC-direct mode); every later run loads it. The identity lifecycle is guarded: runs hold + an exclusive lock on the storage directory, a fresh identity is only ever minted over provably + virgin storage (no fabric, no identity file, no registry entries), the one partial state an + interrupted first run can produce is discarded automatically, and any state a commissioned device + could depend on is refused loudly rather than overwritten. Reconnection uses Matter operational + discovery (mDNS), never a stored IP address. +- **Fail safely.** Never recommissions automatically; never pushes time unless the host clock is + trusted, checked by directly measuring its offset against an NTP server (one SNTP query, accepted + under 1s; `timedatectl` as fallback when no server is reachable). Every sync is verified by reading + the clock back and comparing against the written instant; non-zero exit lets the timer retry. CASE + connects retry three times with backoff, since an mDNS resolve answer can be lost per-attempt. +- **Flat files, no database.** All state is plain files under `storagePath`: rs-matter's KV blobs + (`matter/k_XXXX`, written atomically via temp+rename), `identity.json` (the root CA private key; + mode 0600), `nodes.json` (device registry), and `service-state.json` (per-node sync results). The + config and `service-state.json` are file-compatible with the TypeScript implementation. +- **Time is jiff end to end.** Timestamps are `jiff::Timestamp`; Matter-epoch microseconds exist only + at the wire boundary. DST transitions come directly from the IANA tzdb transition table, never from + offset probing. Time zones are IANA names, never fixed offsets. +- **Intentionally small.** No GUI, database, cloud, QR-code pairing (the primary ecosystem always + shows the numeric code), general Matter administration, or debugging surfaces beyond what operating + this tool needs. + +## Commands + +Local, read-only (no device contact): + +```bash +mattertimesync status [--node <id>] [--json] # config, identity, and per-device sync state +``` + +Fabric interrogation (connects to the device, reads only): + +```bash +mattertimesync inspect [--node <id>] [--json] +``` + +Fabric-writing: + +```bash +mattertimesync commission <pairing-code> # writes the device's fabric table + local storage +mattertimesync sync [--node <id>] [--time <hh:mm>] [--json] # set clock, time zone, DST offsets +mattertimesync decommission [--node <id>] # device(s) drop this controller's fabric +``` + +No command writes local state alone; the registry and sync results change only as part of a +fabric-writing command. + +`--node <id>` targets one device; `sync` and `decommission` target all commissioned devices by +default. `sync --time 16:35` (or `4:35pm`) sets that wall-clock time, today in the configured zone, +instead of the current time; the operator is then the time source and the NTP gate is not enforced. Logs go to stderr; stdout carries only command output, so `--json` is always clean JSON. +Exit codes: 0 success, 1 runtime failure (timer retries), 2 usage/config error. + +## Configuration + +`/etc/mattertimesync/config.json` by default; override with `--config <path>`. Same file format as +the TypeScript implementation (see `examples/config.example.json`): + +```json +{ + "storagePath": "/var/lib/mattertimesync", + "timezone": "America/Chicago", + "logLevel": "info", + "fabricLabel": "My Home" +} +``` + +| 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`. | +| `fabricLabel` | `Matter Time Sync` | Label other ecosystems show for this controller (max 32 chars). | + +Unknown fields are rejected loudly. Matter requires fabric labels to be unique per device and Apple +Home labels its own entry with the home's name, so use a variant like "Lakeside Time Sync" rather +than the home name itself; `sync` pushes label changes to each device. + +## Build and deploy + +```bash +cargo build --release # target/release/mattertimesync, one self-contained binary +cargo test +``` + +The binary must match the target's architecture; the simplest path is building on the target itself +(a Raspberry Pi 4 handles it fine): + +```bash +# on the Pi, one-time toolchain install (user-local, ~/.rustup and ~/.cargo) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +git clone <repository> && cd mattertimesync +~/.cargo/bin/cargo build --release +``` + +The target machine needs nothing but the binary (and a tzdb, present on any Linux). On the target: + +```bash +sudo install -D -m 0755 mattertimesync /opt/mattertimesync/mattertimesync +sudo useradd --system --home /var/lib/mattertimesync --shell /usr/sbin/nologin mattertimesync +sudo mkdir -p /etc/mattertimesync /var/lib/mattertimesync +sudo chown mattertimesync:mattertimesync /var/lib/mattertimesync +sudo chmod 700 /var/lib/mattertimesync +sudo cp examples/mattertimesync.service examples/mattertimesync.timer /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +Commission (as the service user, within the pairing window opened in the primary ecosystem): + +```bash +sudo -u mattertimesync /opt/mattertimesync/mattertimesync \ + --config /etc/mattertimesync/config.json commission 12345678901 +``` + +Then `sudo systemctl enable --now mattertimesync.timer` (2 minutes after boot, hourly thereafter). + +## Migrating from the TypeScript implementation + +The config file and `service-state.json` carry over unchanged, but the **Matter fabric identity does +not**: matter.js and rs-matter persist fabrics in different formats, and the device is paired to a +key, not a storage path. Migration is one re-pairing: + +1. Run this implementation with its own fresh `storagePath` and commission the device as an + additional admin (Matter allows several; the fabric table has at least 5 slots). Both + implementations can sync side by side while validating. +2. Once satisfied, `decommission` with the **old** implementation so the device drops its fabric, + point `mattertimesync.service` at this binary, and delete the old storage directory. + +## Troubleshooting + +- **Sync refuses to run**: the host clock is not NTP-synchronized (`timedatectl status`). The tool + will not push time it does not trust. +- **Commissionable device not found**: the pairing window expired, or mDNS/multicast is blocked + between the host and the device's network. +- **Thread device discovered but unreachable**: the host has no route to the Thread network's + `fd.../64` prefix. On the border routers' own network segment the route is learned automatically + from their Router Advertisements; across VLANs, add one static IPv6 route on the gateway (the + prefix via a border router's stable address). Verify with `ping` of the device's `fd...` address. +- **Clock off by one hour**: DST configuration missing or rejected; this tool always derives offsets + from the IANA database, so check the device accepted SetDSTOffset (sync logs). |
