src.nth.io/

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