src.nth.io/

summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md287
1 files changed, 287 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c00969a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,287 @@
+# 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).
+- 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
+
+Implemented: project scaffold, configuration, persistent controller fabric, on-network multi-admin
+commissioning, and full endpoint/cluster inspection.
+
+The Time Synchronization write commands (the `sync` command) are intentionally not implemented yet:
+they will be written against a real device's inspected capabilities, not against assumptions. The next
+milestone is a successful commissioning plus a complete cluster dump (`inspect --json`) from real
+hardware.
+
+## 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 --code 12345678901
+```
+
+Omit `--code` to be prompted interactively. The code is used once, never logged, and never stored.
+
+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
+
+```bash
+mattertimesync commission [--code <pairing-code>]
+mattertimesync nodes
+mattertimesync inspect [--node <id>] [--json]
+mattertimesync sync [--node <id>] # not yet implemented (pending real-device inspection)
+mattertimesync status
+mattertimesync fabrics [--node <id>] [--remove <index>]
+mattertimesync decommission --node <id>
+```
+
+`--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.
+
+- `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, and the Time Synchronization cluster's feature map, supported commands,
+ and attribute values. `--json` emits the same data machine-readable (node IDs and timestamps as
+ strings, safe for 64-bit values).
+- `sync` will connect to each targeted device, verify the host clock is NTP-synchronized, set UTC time,
+ and configure the time zone and DST transitions when the device supports them, then exit. 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`.
+- `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.
+- `fabrics` reads the selected device's Operational Credentials fabric table and prints every
+ commissioned controller (label, vendor, fabric and node IDs, slot usage), marks this controller's own
+ entry, and flags likely stale entries: ones carrying our label but belonging to an old identity whose
+ storage was deleted. `--remove <index>` deletes a stale entry from the device (our own entry is
+ refused; use `decommission` for that).
+- `decommission` gracefully removes this controller's fabric from the selected device (the primary
+ ecosystem is untouched) and drops its state entry. 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 and clean them, run
+`mattertimesync fabrics`: stale entries carry our label but not our current identity, and
+`fabrics --remove <index>` deletes them using the live controller's admin rights. Alternatively, 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.
+
+**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. This tool converts with
+bigint arithmetic against the Matter epoch only.