# 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 ; 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 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 ` pulls dependencies from the registry (adjust the unit's `ExecStart` accordingly). ## Configuration The CLI reads `/etc/mattertimesync/config.json` by default; override with `--config `. 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 ] mattertimesync nodes mattertimesync inspect [--node ] [--json] mattertimesync sync [--node ] # not yet implemented (pending real-device inspection) mattertimesync status mattertimesync fabrics [--node ] [--remove ] mattertimesync decommission --node ``` `--node ` 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 ` 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 ` 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.