src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore10
-rw-r--r--.prettierignore3
-rw-r--r--.prettierrc.json8
-rw-r--r--LICENSE21
-rw-r--r--README.md320
-rw-r--r--config.example.json5
-rw-r--r--data/.gitkeep0
-rw-r--r--mattertimesync.service15
-rw-r--r--mattertimesync.timer10
-rw-r--r--package-lock.json2721
-rw-r--r--package.json39
-rw-r--r--src/cli.ts9
-rw-r--r--src/commissioning.ts101
-rw-r--r--src/config.ts99
-rw-r--r--src/controller.ts102
-rw-r--r--src/device.ts72
-rw-r--r--src/fabrics.ts111
-rw-r--r--src/inspection.ts267
-rw-r--r--src/json.ts8
-rw-r--r--src/logging.ts69
-rw-r--r--src/main.ts381
-rw-r--r--src/sqlite-lazy.ts17
-rw-r--r--src/state.ts90
-rw-r--r--src/sync.ts181
-rw-r--r--src/timesync.ts268
-rw-r--r--src/timezone.ts190
-rw-r--r--src/warnings.ts19
-rw-r--r--test/commissioning.test.ts33
-rw-r--r--test/config.test.ts73
-rw-r--r--test/fabrics.test.ts46
-rw-r--r--test/fixtures/alpstuga-inspect.json504
-rw-r--r--test/state.test.ts103
-rw-r--r--test/sync.test.ts133
-rw-r--r--test/timesync.test.ts79
-rw-r--r--test/timezone.test.ts139
-rw-r--r--tsconfig.json21
36 files changed, 6267 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..37c2350
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+dist/
+
+# Persistent Matter fabric state and credentials must never be committed.
+data/*
+!data/.gitkeep
+
+*.log
+*.tgz
+mattertimesync.mjs
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..3e0aed8
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+package-lock.json
+dist/
+data/
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..e242695
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "printWidth": 110,
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "arrowParens": "avoid"
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d43b2af
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Luke Hoersten
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
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.
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..2700ecb
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,5 @@
+{
+ "storagePath": "/var/lib/mattertimesync",
+ "timezone": "America/Chicago",
+ "logLevel": "info"
+}
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/data/.gitkeep
diff --git a/mattertimesync.service b/mattertimesync.service
new file mode 100644
index 0000000..fa69907
--- /dev/null
+++ b/mattertimesync.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Matter device clock synchronization
+Wants=network-online.target
+After=network-online.target time-sync.target
+
+[Service]
+Type=oneshot
+User=mattertimesync
+Group=mattertimesync
+ExecStart=/usr/bin/node /opt/mattertimesync/mattertimesync.mjs --config /etc/mattertimesync/config.json sync
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/mattertimesync
diff --git a/mattertimesync.timer b/mattertimesync.timer
new file mode 100644
index 0000000..4d7b64a
--- /dev/null
+++ b/mattertimesync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Periodic Matter device clock synchronization
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=1h
+RandomizedDelaySec=5min
+
+[Install]
+WantedBy=timers.target
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f638612
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2721 @@
+{
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@matter/main": "^0.17.6",
+ "@project-chip/matter.js": "^0.17.6"
+ },
+ "bin": {
+ "mattertimesync": "dist/cli.js"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "esbuild": "^0.28.1",
+ "oxlint": "^1.0.0",
+ "prettier": "^3.6.0",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@matter/general": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.17.6.tgz",
+ "integrity": "sha512-PiSly47lwR+31sMWZTxT9eVgibfC2PB3FflTwxhVDzektrNNYT4wh9+5abTNhQmszs/VnXixbyo64HSz+LQS8g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@noble/curves": "^2.2.0"
+ }
+ },
+ "node_modules/@matter/main": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.17.6.tgz",
+ "integrity": "sha512-RBXVLxJGEd3IUZ63QrHPUpb7o1amErvs2MoK3bJpnUXYNqXAuN2IHLTYZ04j4yrdMHJlyGSDxdIYr5rN6zXqSA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ },
+ "optionalDependencies": {
+ "@matter/nodejs": "0.17.6"
+ }
+ },
+ "node_modules/@matter/model": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.17.6.tgz",
+ "integrity": "sha512-ECf6r0zTq6FS6IA4ov8qtSu36iyO1oxEJT06RFYX/SVDOyfhgzRjQDSIsVtW4UwTMht8kSVnDJE9EAO0qY37YA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6"
+ }
+ },
+ "node_modules/@matter/node": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.17.6.tgz",
+ "integrity": "sha512-qtkh3/hz+73Wh7kY+Jz9HogrnpqjIMreYaY//VEMb9Jl/tY1A+3RFa7PDIY51LK2m+gWy3boXu/3kdYEvn00ig==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@matter/nodejs": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.17.6.tgz",
+ "integrity": "sha512-tvn2wLMgjd1YVKr/kAkKq50WU+8mUeoDVCPIyO6aXJMsy0GTOc8/vC3Lu3ZIUD1IwMiOgn67xnqBVozpp+ZjTw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.13.0"
+ }
+ },
+ "node_modules/@matter/protocol": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.17.6.tgz",
+ "integrity": "sha512-p3oc8v2RKKbV4RkkjKemDpzheNHFMQJnbxpij2yKpxsXYIXpYnwtexnvLxDpZCJPsd9Jv3igSxxURBEUexHmBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@matter/types": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.17.6.tgz",
+ "integrity": "sha512-zYCWx+om6hQR6s6CHniWJMaOsqzTNDKcxUoU0QI8ZgF39SCtIeIb+qOzNigoAtKKZzhVdo9B5IsbEbYY4PhO3g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
+ "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz",
+ "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz",
+ "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz",
+ "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz",
+ "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz",
+ "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz",
+ "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz",
+ "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz",
+ "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz",
+ "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz",
+ "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz",
+ "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz",
+ "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz",
+ "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz",
+ "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz",
+ "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz",
+ "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz",
+ "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz",
+ "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz",
+ "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@project-chip/matter.js": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@project-chip/matter.js/-/matter.js-0.17.6.tgz",
+ "integrity": "sha512-E/FvTvY3G8cPTbpMJDgYdUY8oUHkWN47dXJ6aRjmiDrLFToRb40rrW53p0g6wOOvMnWE9nhVLbJxbE9kOpJIyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz",
+ "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.75.0",
+ "@oxlint/binding-android-arm64": "1.75.0",
+ "@oxlint/binding-darwin-arm64": "1.75.0",
+ "@oxlint/binding-darwin-x64": "1.75.0",
+ "@oxlint/binding-freebsd-x64": "1.75.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.75.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.75.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.75.0",
+ "@oxlint/binding-linux-arm64-musl": "1.75.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.75.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-musl": "1.75.0",
+ "@oxlint/binding-openharmony-arm64": "1.75.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.75.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.75.0",
+ "@oxlint/binding-win32-x64-msvc": "1.75.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..037d930
--- /dev/null
+++ b/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "description": "CLI Matter controller that synchronizes the clocks of Matter devices via the standard Time Synchronization cluster",
+ "type": "module",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "bin": {
+ "mattertimesync": "dist/cli.js"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "bundle": "npm run clean && npm run build && esbuild dist/cli.js --bundle --platform=node --target=node20 --format=esm --external:bun:sqlite --external:*/BunSqlite.js --alias:node:sqlite=./dist/sqlite-lazy.js --banner:js=\"import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);\" --outfile=mattertimesync.mjs",
+ "prepack": "npm run clean && npm run build",
+ "clean": "rm -rf dist",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "oxlint src test",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\" \"*.md\"",
+ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\" \"*.md\""
+ },
+ "dependencies": {
+ "@matter/main": "^0.17.6",
+ "@project-chip/matter.js": "^0.17.6"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "esbuild": "^0.28.1",
+ "oxlint": "^1.0.0",
+ "prettier": "^3.6.0",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..c3579d7
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+/**
+ * Entry point. The warning filter must be installed before matter.js loads
+ * (its import graph reaches node:sqlite, whose ExperimentalWarning fires at
+ * module load). Static imports are hoisted and evaluated before any code
+ * runs, so the program body is loaded dynamically after the filter.
+ */
+import "./warnings.js";
+await import("./main.js");
diff --git a/src/commissioning.ts b/src/commissioning.ts
new file mode 100644
index 0000000..b4edf9b
--- /dev/null
+++ b/src/commissioning.ts
@@ -0,0 +1,101 @@
+import { ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types";
+import { GeneralCommissioning } from "@matter/main/clusters/general-commissioning";
+import type { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
+import type { Config } from "./config.js";
+import { Log } from "./logging.js";
+import { serviceStatePath, updateNodeState } from "./state.js";
+
+const log = new Log("commissioning");
+
+export interface CommissioningResult {
+ nodeId: bigint;
+}
+
+interface ParsedPairingCode {
+ passcode: number;
+ shortDiscriminator?: number;
+ longDiscriminator?: number;
+}
+
+/**
+ * Parses an Apple-generated Matter multi-admin pairing code. Accepts the
+ * 11/21-digit manual code (with or without hyphens) or an "MT:" QR payload.
+ * The code is used only in memory and never logged or stored.
+ */
+export function parsePairingCode(code: string): ParsedPairingCode {
+ const trimmed = code.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Pairing code is empty");
+ }
+
+ if (trimmed.toUpperCase().startsWith("MT:")) {
+ const [payload] = QrPairingCodeCodec.decode(trimmed);
+ if (payload === undefined) {
+ throw new Error("QR pairing code contains no payload");
+ }
+ return { passcode: payload.passcode, longDiscriminator: payload.discriminator };
+ }
+
+ const digits = trimmed.replace(/[-\s]/g, "");
+ if (!/^\d{11}$|^\d{21}$/.test(digits)) {
+ throw new Error("Manual pairing code must be 11 or 21 digits");
+ }
+ const decoded = ManualPairingCodeCodec.decode(digits);
+ return {
+ passcode: decoded.passcode,
+ shortDiscriminator: decoded.shortDiscriminator,
+ longDiscriminator: decoded.discriminator,
+ };
+}
+
+/**
+ * Commissions a device as an additional Matter administrator using on-network
+ * (IP) discovery only. The device stays joined to its existing primary fabric
+ * (e.g. Apple Home); no Thread credentials are involved.
+ *
+ * Any number of devices may be commissioned, one pairing code each; a device
+ * that is already on this fabric rejects the attempt itself (fabric
+ * conflict), so duplicates cannot occur.
+ */
+export async function commissionDevice(
+ controller: CommissioningController,
+ config: Config,
+ pairingCode: string,
+): Promise<CommissioningResult> {
+ const existing = controller.getCommissionedNodes();
+ if (existing.length > 0) {
+ log.info(
+ `${existing.length} node${existing.length === 1 ? "" : "s"} already commissioned; ` +
+ `adding another device to the same fabric.`,
+ );
+ }
+
+ const parsed = parsePairingCode(pairingCode);
+ const identifierData =
+ parsed.longDiscriminator !== undefined
+ ? { longDiscriminator: parsed.longDiscriminator }
+ : parsed.shortDiscriminator !== undefined
+ ? { shortDiscriminator: parsed.shortDiscriminator }
+ : {};
+
+ log.info("Discovering commissionable device on the IP network (_matterc._udp)...");
+
+ const options: NodeCommissioningOptions = {
+ commissioning: {
+ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
+ regulatoryCountryCode: "XX",
+ },
+ discovery: {
+ identifierData,
+ discoveryCapabilities: { onIpNetwork: true },
+ },
+ passcode: parsed.passcode,
+ };
+
+ const nodeId = await controller.commissionNode(options, { connectNodeAfterCommissioning: false });
+ const nodeIdBigint = BigInt(nodeId);
+
+ updateNodeState(serviceStatePath(config.storagePath), nodeIdBigint, {});
+ log.info(`Commissioning complete: node ${nodeIdBigint}`);
+ return { nodeId: nodeIdBigint };
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..14dbd4f
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,99 @@
+import { readFileSync } from "node:fs";
+import { isLogLevel, type LogLevel } from "./logging.js";
+import { isValidTimeZone } from "./timezone.js";
+
+export const DEFAULT_CONFIG_PATH = "/etc/mattertimesync/config.json";
+
+export interface Config {
+ /** Directory holding persistent Matter fabric state and service state. Contains secrets. */
+ storagePath: string;
+ /** IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset. */
+ timezone: string;
+ logLevel: LogLevel;
+}
+
+export class ConfigError extends Error {}
+
+const DEFAULTS = {
+ timezone: "America/Chicago",
+ logLevel: "info" as LogLevel,
+};
+
+export function loadConfig(path: string): Config {
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch (cause) {
+ throw new ConfigError(
+ `Cannot read configuration file ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (cause) {
+ throw new ConfigError(
+ `Configuration file ${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+
+ return validateConfig(parsed, path);
+}
+
+export function validateConfig(parsed: unknown, source = "configuration"): Config {
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new ConfigError(`${source}: top-level value must be a JSON object`);
+ }
+ const record = parsed as Record<string, unknown>;
+
+ // Device membership lives in controller storage, not the configuration, so
+ // there is deliberately no nodeId field; commands take --node instead.
+ const known = new Set(["storagePath", "timezone", "logLevel"]);
+ for (const key of Object.keys(record)) {
+ if (!known.has(key)) {
+ throw new ConfigError(`${source}: unknown field "${key}"`);
+ }
+ }
+
+ const storagePath = record["storagePath"];
+ if (typeof storagePath !== "string" || storagePath.length === 0) {
+ throw new ConfigError(`${source}: "storagePath" is required and must be a non-empty string`);
+ }
+
+ const timezone = record["timezone"] === undefined ? DEFAULTS.timezone : record["timezone"];
+ if (typeof timezone !== "string" || !isValidTimeZone(timezone)) {
+ throw new ConfigError(
+ `${source}: "timezone" must be a valid IANA time-zone name (got ${JSON.stringify(timezone)})`,
+ );
+ }
+
+ const logLevel = record["logLevel"] === undefined ? DEFAULTS.logLevel : record["logLevel"];
+ if (!isLogLevel(logLevel)) {
+ throw new ConfigError(`${source}: "logLevel" must be one of debug, info, warn, error`);
+ }
+
+ return { storagePath, timezone, logLevel };
+}
+
+/**
+ * Parses a node ID given on the command line (--node). Node IDs are 64-bit
+ * values; they are carried as decimal strings so they never pass through a
+ * lossy JavaScript number.
+ */
+export function parseNodeId(value: unknown, source = "--node"): bigint {
+ if (typeof value === "string" && /^\d+$/.test(value)) {
+ return toNodeId(BigInt(value), source);
+ }
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
+ return toNodeId(BigInt(value), source);
+ }
+ throw new ConfigError(`${source}: node ID must be a decimal string; got ${JSON.stringify(value)}`);
+}
+
+function toNodeId(value: bigint, source: string): bigint {
+ if (value < 0n || value >= 1n << 64n) {
+ throw new ConfigError(`${source}: node ID must fit in an unsigned 64-bit integer`);
+ }
+ return value;
+}
diff --git a/src/controller.ts b/src/controller.ts
new file mode 100644
index 0000000..ee65d82
--- /dev/null
+++ b/src/controller.ts
@@ -0,0 +1,102 @@
+import { mkdirSync } from "node:fs";
+import { Environment, LogFormat, Logger, StorageService } from "@matter/main";
+import { CommissioningController } from "@project-chip/matter.js";
+import type { Config } from "./config.js";
+import { Log, type LogLevel } from "./logging.js";
+
+// matter.js library logs go to stderr, like the service's own logs, so
+// stdout carries only command output (in particular clean --json). Plain
+// format: the default ANSI escapes would land in the systemd journal.
+Logger.format = LogFormat.PLAIN;
+Logger.destinations["default"]!.write = text => process.stderr.write(text + "\n");
+
+const log = new Log("controller");
+
+/**
+ * Fabric label shown to other Matter administrators (e.g. in Apple Home's
+ * connected-services list). Maximum 32 characters.
+ */
+const FABRIC_LABEL = "mattertimesync";
+
+/** Storage namespace under the storage path; also the controller's unique environment id. */
+const CONTROLLER_ID = "mattertimesync";
+
+/**
+ * matter.js library log level derived from the service log level. At service
+ * level "info" the library is kept at "warn" so protocol internals (discovery,
+ * session management) only appear when debugging.
+ */
+function matterLogLevel(serviceLevel: LogLevel): string {
+ return serviceLevel === "debug" ? "debug" : serviceLevel === "info" ? "warn" : serviceLevel;
+}
+
+/**
+ * Creates and starts the Matter controller with persistent storage rooted at
+ * the configured storage path. The fabric identity (root certificate, keys,
+ * node allocation state) persists across restarts; a fabric is only generated
+ * on the very first start.
+ */
+export async function startController(config: Config): Promise<CommissioningController> {
+ mkdirSync(config.storagePath, { recursive: true, mode: 0o700 });
+
+ const environment = Environment.default;
+ environment.vars.set("storage.path", config.storagePath);
+ environment.vars.set("log.level", matterLogLevel(config.logLevel));
+
+ const storage = environment.get(StorageService);
+ log.debug(`Matter storage location: ${storage.location}`);
+
+ const controller = new CommissioningController({
+ environment: { environment, id: CONTROLLER_ID },
+ autoConnect: false,
+ adminFabricLabel: FABRIC_LABEL,
+ });
+
+ await controller.start();
+ const commissioned = controller.getCommissionedNodes();
+ log.info(
+ `Controller started (fabric ${controller.fabric.fabricId}, ${commissioned.length} commissioned node${
+ commissioned.length === 1 ? "" : "s"
+ })`,
+ );
+ return controller;
+}
+
+/** All commissioned node IDs in controller storage. */
+export function commissionedNodeIds(controller: CommissioningController): bigint[] {
+ return controller.getCommissionedNodes().map(id => BigInt(id));
+}
+
+/**
+ * Resolves the set of nodes a command operates on: the explicitly requested
+ * one, or every commissioned node.
+ */
+export function resolveTargetNodes(controller: CommissioningController, requested?: bigint): bigint[] {
+ const commissioned = commissionedNodeIds(controller);
+ if (commissioned.length === 0) {
+ throw new Error('No device is commissioned yet. Run "commission" first.');
+ }
+ if (requested === undefined) return commissioned;
+ if (!commissioned.includes(requested)) {
+ throw new Error(
+ `Node ${requested} is not commissioned on this controller ` +
+ `(known nodes: ${commissioned.join(", ")}). Run "nodes" to list them.`,
+ );
+ }
+ return [requested];
+}
+
+/**
+ * Resolves exactly one node for commands that cannot meaningfully operate on
+ * several (inspect, fabrics, decommission). --node is required once more than
+ * one device is commissioned.
+ */
+export function resolveSingleNode(controller: CommissioningController, requested?: bigint): bigint {
+ const targets = resolveTargetNodes(controller, requested);
+ if (targets.length > 1) {
+ throw new Error(
+ `Multiple nodes are commissioned (${targets.join(", ")}); pass --node <id> to choose one.`,
+ );
+ }
+ return targets[0]!;
+}
diff --git a/src/device.ts b/src/device.ts
new file mode 100644
index 0000000..1c0d0ea
--- /dev/null
+++ b/src/device.ts
@@ -0,0 +1,72 @@
+import { NodeId } from "@matter/main";
+import type { CommissioningController } from "@project-chip/matter.js";
+import { NodeStates, type PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("device");
+
+export function nodeStateName(state: NodeStates): string {
+ switch (state) {
+ case NodeStates.Connected:
+ return "connected";
+ case NodeStates.Disconnected:
+ return "disconnected";
+ case NodeStates.Reconnecting:
+ return "reconnecting";
+ case NodeStates.WaitingForDeviceDiscovery:
+ return "waiting for device discovery";
+ }
+}
+
+/**
+ * Connects to the commissioned node using operational discovery (DNS-SD by
+ * node identity, never a stored IP address) and waits until the node is fully
+ * initialized from the device.
+ *
+ * The returned PairedNode keeps reconnecting in the background; observe
+ * `events.stateChanged` for connectivity transitions.
+ */
+export async function connectDevice(
+ controller: CommissioningController,
+ nodeId: bigint,
+ timeoutMs = 60_000,
+): Promise<PairedNode> {
+ const node = await controller.getNode(NodeId(nodeId));
+
+ node.events.stateChanged.on(state => {
+ log.debug(`Node ${nodeId} state: ${nodeStateName(state)}`);
+ });
+
+ if (!node.initialized) {
+ log.info(`Connecting to Matter node ${nodeId}...`);
+ node.connect();
+ await waitForInitialization(node, nodeId, timeoutMs);
+ } else if (!node.isConnected) {
+ node.connect();
+ await waitForInitialization(node, nodeId, timeoutMs);
+ }
+
+ log.info(`Connected to Matter node ${nodeId}`);
+ return node;
+}
+
+async function waitForInitialization(node: PairedNode, nodeId: bigint, timeoutMs: number): Promise<void> {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise<never>((_, reject) => {
+ timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for node ${nodeId}. ` +
+ `The device may be offline or unreachable over IPv6.`,
+ ),
+ ),
+ timeoutMs,
+ );
+ });
+ try {
+ await Promise.race([node.events.initializedFromRemote, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/src/fabrics.ts b/src/fabrics.ts
new file mode 100644
index 0000000..48687af
--- /dev/null
+++ b/src/fabrics.ts
@@ -0,0 +1,111 @@
+import { OperationalCredentials } from "@matter/main/clusters/operational-credentials";
+import type { CommissioningController } from "@project-chip/matter.js";
+import type { PairedNode } from "@project-chip/matter.js/device";
+
+/**
+ * Read-only view of the device's Operational Credentials fabric table.
+ *
+ * Every commissioning writes a fabric entry into the device's limited fabric
+ * table. Entries never expire; identities whose local storage was deleted
+ * leave orphans behind. These helpers make the table visible and flag likely
+ * orphans. Removal is deliberately not offered: this controller does not use
+ * its admin rights against other fabrics' entries. Clean stale entries up
+ * from the device's primary ecosystem (e.g. Apple Home) or by factory reset;
+ * our own live entry is removed with "decommission".
+ */
+
+export interface FabricEntry {
+ fabricIndex: number;
+ fabricId: bigint;
+ nodeId: bigint;
+ vendorId: number;
+ label: string;
+ /** True when the entry belongs to this controller's current identity. */
+ isOurs: boolean;
+}
+
+export interface FabricTable {
+ supportedFabrics: number;
+ commissionedFabrics: number;
+ entries: FabricEntry[];
+}
+
+const KNOWN_VENDORS: Record<number, string> = {
+ 0x1349: "Apple",
+ 0x6006: "Google",
+ 0x10e1: "SmartThings",
+ 0x117c: "IKEA",
+};
+
+function vendorName(vendorId: number): string {
+ const known = KNOWN_VENDORS[vendorId];
+ if (known !== undefined) return known;
+ if (vendorId >= 0xfff1 && vendorId <= 0xfff4) return "test vendor";
+ return "unknown vendor";
+}
+
+export async function readFabricTable(
+ controller: CommissioningController,
+ node: PairedNode,
+): Promise<FabricTable> {
+ const client = node.getRootClusterClient(OperationalCredentials.Complete);
+ if (client === undefined) {
+ throw new Error("Operational Credentials cluster not found on the device's root endpoint");
+ }
+
+ // Read from the device with fabric filtering off, so entries from all
+ // fabrics are returned, not just our own.
+ const fabrics = await client.attributes.fabrics.get(true, false);
+ const supportedFabrics = (await client.attributes.supportedFabrics.get(true)) ?? 0;
+ const commissionedFabrics = (await client.attributes.commissionedFabrics.get(true)) ?? 0;
+ if (fabrics === undefined) {
+ throw new Error("Device returned no fabric list");
+ }
+
+ const ourFabric = controller.fabric;
+ const entries = fabrics.map(fabric => ({
+ fabricIndex: Number(fabric.fabricIndex),
+ fabricId: BigInt(fabric.fabricId),
+ nodeId: BigInt(fabric.nodeId),
+ vendorId: Number(fabric.vendorId),
+ label: fabric.label,
+ isOurs: ourFabric.matchesFabricIdAndRootPublicKey(fabric.fabricId, fabric.rootPublicKey),
+ }));
+
+ return { supportedFabrics, commissionedFabrics, entries };
+}
+
+export function formatFabricTable(nodeId: bigint, table: FabricTable): string {
+ const lines: string[] = [];
+ lines.push(
+ `Fabric table on node ${nodeId} (${table.commissionedFabrics} of ${table.supportedFabrics} slots used):`,
+ );
+ for (const entry of table.entries) {
+ const vendor = `0x${entry.vendorId.toString(16).padStart(4, "0")} (${vendorName(entry.vendorId)})`;
+ const marker = entry.isOurs ? " [this controller]" : "";
+ lines.push(
+ ` index ${entry.fabricIndex}: label ${JSON.stringify(entry.label)} vendor ${vendor} ` +
+ `fabricId ${entry.fabricId} nodeId ${entry.nodeId}${marker}`,
+ );
+ }
+ const strays = findLikelyStrays(table);
+ if (strays.length > 0) {
+ lines.push("");
+ lines.push(
+ `Likely stale entries (our label, but not our current identity): ` +
+ `${strays.map(entry => `index ${entry.fabricIndex}`).join(", ")}`,
+ );
+ lines.push(`Remove them from the device's primary ecosystem (e.g. Apple Home: device settings,`);
+ lines.push(`Connected Services) or by factory-resetting and re-pairing the device.`);
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Entries carrying our fabric label but not our current identity: orphans
+ * from a commissioning whose local storage was deleted or replaced.
+ */
+export function findLikelyStrays(table: FabricTable): FabricEntry[] {
+ const ourLabel = table.entries.find(entry => entry.isOurs)?.label;
+ return table.entries.filter(entry => !entry.isOurs && ourLabel !== undefined && entry.label === ourLabel);
+}
diff --git a/src/inspection.ts b/src/inspection.ts
new file mode 100644
index 0000000..3c0ed57
--- /dev/null
+++ b/src/inspection.ts
@@ -0,0 +1,267 @@
+import { ClusterId } from "@matter/main";
+import { SupportedAttributeClient } from "@project-chip/matter.js/cluster";
+import type { Endpoint, PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("inspection");
+
+const TIME_SYNC_CLUSTER_ID = ClusterId(0x38);
+/** Standard cluster IDs are <= 0x7fff; anything above is manufacturer-specific. */
+const LAST_STANDARD_CLUSTER_ID = 0x7fff;
+
+export interface ClusterSummary {
+ id: string;
+ name: string;
+ revision: number;
+ isUnknown: boolean;
+ vendorSpecific: boolean;
+ supportedFeatures: Record<string, boolean>;
+}
+
+export interface EndpointSummary {
+ endpointId: number;
+ name: string;
+ deviceTypes: { name: string; code: string; revision: number }[];
+ serverClusters: ClusterSummary[];
+ /** Client-side cluster IDs from the endpoint's descriptor. */
+ clientClusters: string[];
+}
+
+export interface TimeSyncAttribute {
+ id: string;
+ name: string;
+ value: unknown;
+}
+
+export interface TimeSyncSummary {
+ endpointId: number;
+ clusterRevision: number;
+ supportedFeatures: Record<string, boolean>;
+ featureMap: unknown;
+ supportedCommands: Record<string, boolean>;
+ attributes: TimeSyncAttribute[];
+}
+
+export interface InspectionReport {
+ nodeId: string;
+ basicInformation: {
+ vendorName: string;
+ vendorId: number;
+ productName: string;
+ productId: number;
+ hardwareVersion: string;
+ softwareVersion: string;
+ serialNumber?: string;
+ uniqueId?: string;
+ } | null;
+ endpoints: EndpointSummary[];
+ timeSynchronization: TimeSyncSummary | null;
+ vendorSpecificClusters: { endpointId: number; clusterId: string; name: string }[];
+}
+
+/** Commands whose availability drives the capability-based sync stages. */
+const TIME_SYNC_COMMANDS = [
+ "setUtcTime",
+ "setTrustedTimeSource",
+ "setTimeZone",
+ "setDstOffset",
+ "setDefaultNtp",
+] as const;
+
+export async function inspectNode(node: PairedNode): Promise<InspectionReport> {
+ const endpoints = collectEndpoints(node);
+
+ const endpointSummaries = endpoints.map(endpoint => summarizeEndpoint(endpoint));
+
+ const info = node.basicInformation;
+ const basicInformation = info
+ ? {
+ vendorName: info.vendorName,
+ vendorId: Number(info.vendorId),
+ productName: info.productName,
+ productId: info.productId,
+ hardwareVersion: info.hardwareVersionString,
+ softwareVersion: info.softwareVersionString,
+ serialNumber: info.serialNumber,
+ uniqueId: info.uniqueId,
+ }
+ : null;
+
+ const timeSynchronization = await summarizeTimeSync(endpoints);
+ if (timeSynchronization === null) {
+ log.warn("Time Synchronization cluster not found on any endpoint");
+ } else {
+ log.info(`Time Synchronization cluster found on endpoint ${timeSynchronization.endpointId}`);
+ }
+
+ const vendorSpecificClusters = endpointSummaries.flatMap(endpoint =>
+ endpoint.serverClusters
+ .filter(cluster => cluster.vendorSpecific)
+ .map(cluster => ({ endpointId: endpoint.endpointId, clusterId: cluster.id, name: cluster.name })),
+ );
+
+ return {
+ nodeId: BigInt(node.nodeId).toString(),
+ basicInformation,
+ endpoints: endpointSummaries,
+ timeSynchronization,
+ vendorSpecificClusters,
+ };
+}
+
+function collectEndpoints(node: PairedNode): Endpoint[] {
+ const seen = new Map<number, Endpoint>();
+ const root = node.getRootEndpoint();
+ if (root?.number !== undefined) seen.set(root.number, root);
+ for (const [number, endpoint] of node.parts) {
+ if (!seen.has(number)) seen.set(number, endpoint);
+ }
+ return [...seen.entries()].sort(([a], [b]) => a - b).map(([, endpoint]) => endpoint);
+}
+
+function summarizeEndpoint(endpoint: Endpoint): EndpointSummary {
+ const serverClusters = endpoint.getAllClusterClients().map(client => ({
+ id: formatClusterId(client.id),
+ name: client.name,
+ revision: client.revision,
+ isUnknown: client.isUnknown,
+ vendorSpecific: client.id > LAST_STANDARD_CLUSTER_ID,
+ supportedFeatures: Object.fromEntries(
+ Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"),
+ ) as Record<string, boolean>,
+ }));
+
+ let clientClusters: string[] = [];
+ try {
+ clientClusters = (endpoint.state.descriptor.clientList ?? []).map(id => formatClusterId(id));
+ } catch {
+ // Descriptor state unavailable; leave the client list empty.
+ }
+
+ return {
+ endpointId: endpoint.number ?? -1,
+ name: endpoint.name,
+ deviceTypes: endpoint.getDeviceTypes().map(type => ({
+ name: type.name,
+ code: `0x${type.code.toString(16).padStart(4, "0")}`,
+ revision: type.revision,
+ })),
+ serverClusters,
+ clientClusters,
+ };
+}
+
+async function summarizeTimeSync(endpoints: Endpoint[]): Promise<TimeSyncSummary | null> {
+ for (const endpoint of endpoints) {
+ const client = endpoint.getClusterClientById(TIME_SYNC_CLUSTER_ID);
+ if (client === undefined) continue;
+
+ const attributes: TimeSyncAttribute[] = [];
+ for (const [name, attribute] of Object.entries(client.attributes)) {
+ if (!(attribute instanceof SupportedAttributeClient)) continue;
+ let value: unknown;
+ try {
+ value = attribute.getLocal();
+ if (value === undefined) value = await attribute.get();
+ } catch (cause) {
+ value = `<read failed: ${cause instanceof Error ? cause.message : String(cause)}>`;
+ }
+ attributes.push({ id: `0x${attribute.id.toString(16)}`, name, value });
+ }
+
+ const supportedCommands = Object.fromEntries(
+ TIME_SYNC_COMMANDS.map(command => [command, client.isCommandSupportedByName(command)]),
+ );
+
+ const featureMap = attributes.find(attribute => attribute.name === "featureMap")?.value ?? null;
+
+ return {
+ endpointId: endpoint.number ?? -1,
+ clusterRevision: client.revision,
+ supportedFeatures: Object.fromEntries(
+ Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"),
+ ) as Record<string, boolean>,
+ featureMap,
+ supportedCommands,
+ attributes,
+ };
+ }
+ return null;
+}
+
+function formatClusterId(id: number): string {
+ return `0x${Number(id).toString(16).padStart(4, "0")}`;
+}
+
+export function formatInspection(report: InspectionReport): string {
+ const lines: string[] = [];
+ lines.push(`Node ${report.nodeId}`);
+
+ if (report.basicInformation) {
+ const info = report.basicInformation;
+ lines.push(` Vendor: ${info.vendorName} (0x${info.vendorId.toString(16).padStart(4, "0")})`);
+ lines.push(` Product: ${info.productName} (0x${info.productId.toString(16).padStart(4, "0")})`);
+ lines.push(` Hardware: ${info.hardwareVersion} Software: ${info.softwareVersion}`);
+ if (info.serialNumber) lines.push(` Serial: ${info.serialNumber}`);
+ }
+
+ for (const endpoint of report.endpoints) {
+ lines.push("");
+ const types = endpoint.deviceTypes.map(t => `${t.name} (${t.code}, rev ${t.revision})`).join(", ");
+ lines.push(`Endpoint ${endpoint.endpointId}: ${types}`);
+ if (endpoint.serverClusters.length > 0) {
+ lines.push(" Server clusters:");
+ for (const cluster of endpoint.serverClusters) {
+ const features = Object.entries(cluster.supportedFeatures)
+ .filter(([, enabled]) => enabled)
+ .map(([name]) => name);
+ const notes = [
+ `rev ${cluster.revision}`,
+ ...(features.length > 0 ? [`features: ${features.join(", ")}`] : []),
+ ...(cluster.vendorSpecific ? ["vendor-specific"] : []),
+ ...(cluster.isUnknown ? ["unknown to matter.js"] : []),
+ ];
+ lines.push(` ${cluster.id} ${cluster.name} (${notes.join("; ")})`);
+ }
+ }
+ if (endpoint.clientClusters.length > 0) {
+ lines.push(` Client clusters: ${endpoint.clientClusters.join(", ")}`);
+ }
+ }
+
+ lines.push("");
+ if (report.timeSynchronization) {
+ const ts = report.timeSynchronization;
+ lines.push(`Time Synchronization cluster (endpoint ${ts.endpointId}, revision ${ts.clusterRevision})`);
+ const features = Object.entries(ts.supportedFeatures)
+ .filter(([, enabled]) => enabled)
+ .map(([name]) => name);
+ lines.push(` Features: ${features.length > 0 ? features.join(", ") : "(none)"}`);
+ lines.push(" Commands:");
+ for (const [command, supported] of Object.entries(ts.supportedCommands)) {
+ lines.push(` ${command}: ${supported ? "supported" : "not supported"}`);
+ }
+ lines.push(" Attributes:");
+ for (const attribute of ts.attributes) {
+ lines.push(` ${attribute.name} (${attribute.id}): ${renderValue(attribute.value)}`);
+ }
+ } else {
+ lines.push("Time Synchronization cluster: NOT FOUND");
+ lines.push(" This device cannot be synchronized through the standard Matter mechanism.");
+ }
+
+ if (report.vendorSpecificClusters.length > 0) {
+ lines.push("");
+ lines.push("Vendor-specific clusters:");
+ for (const cluster of report.vendorSpecificClusters) {
+ lines.push(` endpoint ${cluster.endpointId}: ${cluster.clusterId} ${cluster.name}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+function renderValue(value: unknown): string {
+ if (value === undefined) return "<not read>";
+ return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner));
+}
diff --git a/src/json.ts b/src/json.ts
new file mode 100644
index 0000000..53ab378
--- /dev/null
+++ b/src/json.ts
@@ -0,0 +1,8 @@
+/**
+ * JSON serialization for command output. 64-bit values (node IDs, Matter
+ * microsecond timestamps) are bigints internally and render as decimal
+ * strings, never as lossy JavaScript numbers.
+ */
+export function toJsonString(value: unknown): string {
+ return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner), 2);
+}
diff --git a/src/logging.ts b/src/logging.ts
new file mode 100644
index 0000000..e7b26f2
--- /dev/null
+++ b/src/logging.ts
@@ -0,0 +1,69 @@
+/**
+ * Structured, level-filtered logging for the service's own messages.
+ *
+ * matter.js library logging is configured separately (see controller.ts); this
+ * logger covers service-level events in the format:
+ *
+ * 2026-07-26T20:00:00.100Z INFO controller Connected to Matter node 1
+ */
+
+export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
+export type LogLevel = (typeof LOG_LEVELS)[number];
+
+export function isLogLevel(value: unknown): value is LogLevel {
+ return typeof value === "string" && (LOG_LEVELS as readonly string[]).includes(value);
+}
+
+const LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
+const LEVEL_LABEL: Record<LogLevel, string> = {
+ debug: "DEBUG",
+ info: "INFO ",
+ warn: "WARN ",
+ error: "ERROR",
+};
+
+let globalLevel: LogLevel = "info";
+
+export function setLogLevel(level: LogLevel): void {
+ globalLevel = level;
+}
+
+export function getLogLevel(): LogLevel {
+ return globalLevel;
+}
+
+function write(level: LogLevel, module: string, message: string): void {
+ if (LEVEL_RANK[level] < LEVEL_RANK[globalLevel]) return;
+ const line = `${new Date().toISOString()} ${LEVEL_LABEL[level]} ${module} ${message}`;
+ // All log levels go to stderr: stdout is reserved for command output so
+ // that --json (and any piped human output) is never interleaved with logs.
+ process.stderr.write(line + "\n");
+}
+
+/** A logger bound to a module name (e.g. "controller", "sync"). */
+export class Log {
+ constructor(private readonly module: string) {}
+
+ debug(message: string): void {
+ write("debug", this.module, message);
+ }
+
+ info(message: string): void {
+ write("info", this.module, message);
+ }
+
+ warn(message: string): void {
+ write("warn", this.module, message);
+ }
+
+ error(message: string, cause?: unknown): void {
+ write("error", this.module, cause === undefined ? message : `${message}: ${describeError(cause)}`);
+ }
+}
+
+export function describeError(cause: unknown): string {
+ if (cause instanceof Error) {
+ return cause.message;
+ }
+ return String(cause);
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..4e28261
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,381 @@
+import { existsSync } from "node:fs";
+import { parseArgs } from "node:util";
+import { ConfigError, DEFAULT_CONFIG_PATH, loadConfig, parseNodeId, type Config } from "./config.js";
+import { commissionedNodeIds, resolveSingleNode, resolveTargetNodes, startController } from "./controller.js";
+import { commissionDevice } from "./commissioning.js";
+import { connectDevice } from "./device.js";
+import { findLikelyStrays, formatFabricTable, readFabricTable } from "./fabrics.js";
+import { formatInspection, inspectNode } from "./inspection.js";
+import { toJsonString } from "./json.js";
+import { Log, describeError, setLogLevel } from "./logging.js";
+import { currentMatterUtcMicroseconds, isHostClockSynchronized } from "./sync.js";
+import { formatSyncResult, syncNode, type SyncNodeResult } from "./timesync.js";
+import { nodeState, readServiceState, removeNodeState, serviceStatePath, updateNodeState } from "./state.js";
+import { formatUtcOffset, nextOffsetTransition, utcOffsetSeconds } from "./timezone.js";
+
+const log = new Log("cli");
+
+const USAGE = `Usage: mattertimesync [--config <path>] <command> [options]
+
+Read-only commands (never modify a device):
+ status [--json] Show controller and per-device state (contacts no device)
+ nodes [--json] List commissioned devices from local cached state (contacts no device)
+ inspect [--node <id>] [--json] Connect to a device and dump its live endpoints, clusters,
+ time-sync capabilities, and fabric table
+
+Commands with side effects:
+ commission <pairing-code> Join a device as an additional Matter admin
+ (writes the device's fabric table and local storage)
+ sync [--node <id>] [--json] Set each device's clock: UTC time, time zone, DST offsets
+ decommission [--node <id>] Drop this controller's fabric from each device, or just
+ one with --node (primary ecosystems are untouched)
+
+The controller fabric is the device registry: every commissioned node is kept
+in sync. --node may be omitted while only one device is commissioned.
+Run "sync" periodically (e.g. from a systemd timer) to keep clocks correct.
+
+Logs go to stderr; command output (including --json) goes to stdout.
+
+Options:
+ --config <path> Configuration file (default: ${DEFAULT_CONFIG_PATH})
+ --json Machine-readable output (64-bit values as decimal strings)
+ --help Show this help
+`;
+
+async function main(): Promise<number> {
+ const { values, positionals } = parseArgs({
+ options: {
+ config: { type: "string" },
+ json: { type: "boolean", default: false },
+ node: { type: "string" },
+ help: { type: "boolean", default: false },
+ },
+ allowPositionals: true,
+ });
+
+ if (values.help || positionals.length === 0) {
+ process.stdout.write(USAGE);
+ return values.help ? 0 : 2;
+ }
+
+ const command = positionals[0]!;
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
+
+ let config: Config;
+ let requestedNode: bigint | undefined;
+ try {
+ config = loadConfig(configPath);
+ requestedNode = values.node === undefined ? undefined : parseNodeId(values.node);
+ } catch (cause) {
+ if (cause instanceof ConfigError) {
+ process.stderr.write(`Configuration error: ${cause.message}\n`);
+ return 1;
+ }
+ throw cause;
+ }
+ setLogLevel(config.logLevel);
+
+ switch (command) {
+ case "commission":
+ return await runCommission(config, positionals[1]);
+ case "nodes":
+ return await runNodes(config, values.json ?? false);
+ case "inspect":
+ return await runInspect(config, requestedNode, values.json ?? false);
+ case "sync":
+ return await runSync(config, requestedNode, values.json ?? false);
+ case "status":
+ return await runStatus(config, values.json ?? false);
+ case "decommission":
+ return await runDecommission(config, requestedNode);
+ default:
+ process.stderr.write(`Unknown command "${command}"\n\n${USAGE}`);
+ return 2;
+ }
+}
+
+async function runCommission(config: Config, codeArgument: string | undefined): Promise<number> {
+ const pairingCode = codeArgument?.trim();
+ if (pairingCode === undefined || pairingCode.length === 0) {
+ process.stderr.write(
+ "Usage: commission <pairing-code> (the code from the primary ecosystem's pairing mode).\n" +
+ "The code is used once and never logged or stored.\n",
+ );
+ return 2;
+ }
+ const controller = await startController(config);
+ try {
+ const result = await commissionDevice(controller, config, pairingCode);
+
+ const node = await connectDevice(controller, result.nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), result.nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ process.stdout.write(formatInspection(report) + "\n\n");
+
+ process.stdout.write(
+ [
+ "Commissioning summary",
+ ` Node ID: ${result.nodeId}`,
+ ` Fabric ID: ${controller.fabric.fabricId}`,
+ ` Total devices: ${commissionedNodeIds(controller).length}`,
+ ` Time Sync: ${
+ report.timeSynchronization
+ ? `endpoint ${report.timeSynchronization.endpointId}`
+ : "cluster not found"
+ }`,
+ "",
+ "The device remains paired with its primary ecosystem; this controller",
+ 'was added as an additional Matter administrator. Run "sync" to',
+ "synchronize its clock now.",
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runNodes(config: Config, json: boolean): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const details = controller.getCommissionedNodesDetails();
+ const state = readServiceState(serviceStatePath(config.storagePath));
+
+ if (json) {
+ const entries = details.map(detail => {
+ const nodeId = BigInt(detail.nodeId);
+ const info = detail.deviceData.basicInformation ?? {};
+ return {
+ nodeId,
+ vendorName: info["vendorName"] ?? null,
+ productName: info["productName"] ?? null,
+ ...nodeState(state, nodeId),
+ };
+ });
+ process.stdout.write(toJsonString(entries) + "\n");
+ return 0;
+ }
+
+ if (details.length === 0) {
+ process.stdout.write('No devices commissioned. Run "commission" to add one.\n');
+ return 0;
+ }
+
+ process.stdout.write(`${details.length} commissioned device${details.length === 1 ? "" : "s"}:\n`);
+ for (const detail of details) {
+ const nodeId = BigInt(detail.nodeId);
+ const info = detail.deviceData.basicInformation ?? {};
+ const name = [info["vendorName"], info["productName"]].filter(Boolean).join(" ");
+ const perNode = nodeState(state, nodeId);
+ process.stdout.write(
+ [
+ ` node ${nodeId}: ${name || "(no cached device info)"}`,
+ ` last connection: ${perNode.lastSuccessfulConnection ?? "never"}`,
+ ` last sync: ${perNode.lastSuccessfulSync ?? "never"}`,
+ ...(perNode.lastError !== null ? [` last error: ${perNode.lastError}`] : []),
+ ].join("\n") + "\n",
+ );
+ }
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runInspect(config: Config, requestedNode: bigint | undefined, json: boolean): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ const fabricTable = await readFabricTable(controller, node);
+ if (json) {
+ process.stdout.write(
+ toJsonString({
+ ...report,
+ fabricTable: {
+ ...fabricTable,
+ likelyStaleIndexes: findLikelyStrays(fabricTable).map(entry => entry.fabricIndex),
+ },
+ }) + "\n",
+ );
+ } else {
+ process.stdout.write(formatInspection(report) + "\n\n" + formatFabricTable(nodeId, fabricTable) + "\n");
+ }
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runSync(config: Config, requestedNode: bigint | undefined, json: boolean): Promise<number> {
+ // Fail safe: never push time from a clock that is not NTP-disciplined.
+ const ntpSynchronized = await isHostClockSynchronized();
+ if (!ntpSynchronized) {
+ log.warn("Host clock is not NTP-synchronized; refusing to set device time. Will retry next run.");
+ if (json) process.stdout.write(toJsonString({ hostNtpSynchronized: false, nodes: [] }) + "\n");
+ return 1;
+ }
+
+ const controller = await startController(config);
+ const statePath = serviceStatePath(config.storagePath);
+ const results: SyncNodeResult[] = [];
+ try {
+ for (const nodeId of resolveTargetNodes(controller, requestedNode)) {
+ updateNodeState(statePath, nodeId, { lastAttemptedSync: new Date().toISOString() });
+ let result: SyncNodeResult;
+ try {
+ const node = await connectDevice(controller, nodeId);
+ updateNodeState(statePath, nodeId, { lastSuccessfulConnection: new Date().toISOString() });
+ result = await syncNode(node, config.timezone);
+ } catch (cause) {
+ result = {
+ nodeId: nodeId.toString(),
+ success: false,
+ skipped: false,
+ error: describeError(cause),
+ clockBefore: null,
+ utcTimeWritten: null,
+ utcTimeWrittenUnixMicroseconds: null,
+ timeZoneWritten: null,
+ dstOffsetsWritten: null,
+ verification: null,
+ };
+ }
+ results.push(result);
+ if (result.success) {
+ updateNodeState(statePath, nodeId, {
+ lastSuccessfulSync: new Date().toISOString(),
+ lastError: null,
+ });
+ } else {
+ updateNodeState(statePath, nodeId, { lastError: result.error });
+ log.error(`Node ${nodeId}: sync failed`, result.error);
+ }
+ }
+ } finally {
+ await controller.close();
+ }
+
+ if (json) {
+ process.stdout.write(toJsonString({ hostNtpSynchronized: true, nodes: results }) + "\n");
+ } else {
+ process.stdout.write(results.map(result => formatSyncResult(result)).join("\n") + "\n");
+ }
+ // Devices without the cluster are skipped with a warning, not treated as
+ // failures; a permanently incompatible device must not fail every timer run.
+ return results.some(result => !result.success && !result.skipped) ? 1 : 0;
+}
+
+async function runStatus(config: Config, json: boolean): Promise<number> {
+ const statePath = serviceStatePath(config.storagePath);
+ const state = readServiceState(statePath);
+ const storageExists = existsSync(config.storagePath);
+ const ntpSynchronized = await isHostClockSynchronized();
+ const offsetSeconds = utcOffsetSeconds(config.timezone);
+ const transition = nextOffsetTransition(config.timezone);
+
+ if (json) {
+ process.stdout.write(
+ toJsonString({
+ timezone: config.timezone,
+ storagePath: config.storagePath,
+ storageInitialized: storageExists,
+ hostNtpSynchronized: ntpSynchronized,
+ currentUtcOffsetSeconds: offsetSeconds,
+ currentUtcOffset: formatUtcOffset(offsetSeconds),
+ nextDstTransition:
+ transition === null
+ ? null
+ : {
+ at: transition.at.toISOString(),
+ offsetBeforeSeconds: transition.offsetBeforeSeconds,
+ offsetAfterSeconds: transition.offsetAfterSeconds,
+ },
+ matterTimeNowMicroseconds: currentMatterUtcMicroseconds(),
+ nodes: state.nodes,
+ }) + "\n",
+ );
+ return 0;
+ }
+
+ const nodeIds = Object.keys(state.nodes);
+ const lines = [
+ `Configuration: loaded (${config.timezone})`,
+ `Controller storage: ${storageExists ? `present at ${config.storagePath}` : "NOT INITIALIZED"}`,
+ `Commissioned nodes: ${nodeIds.length > 0 ? nodeIds.join(", ") : "none recorded"}`,
+ `Host NTP synced: ${ntpSynchronized ? "yes" : "no (or not determinable on this host)"}`,
+ `Current UTC offset: ${formatUtcOffset(offsetSeconds)}`,
+ `Next DST transition: ${
+ transition
+ ? `${transition.at.toISOString()} (${formatUtcOffset(transition.offsetBeforeSeconds)} -> ${formatUtcOffset(
+ transition.offsetAfterSeconds,
+ )})`
+ : "none within 400 days"
+ }`,
+ `Matter time now: ${currentMatterUtcMicroseconds()} us since 2000-01-01T00:00:00Z`,
+ ];
+ for (const [nodeId, perNode] of Object.entries(state.nodes)) {
+ lines.push(`Node ${nodeId}:`);
+ lines.push(` Last connection: ${perNode.lastSuccessfulConnection ?? "never"}`);
+ lines.push(` Last successful sync: ${perNode.lastSuccessfulSync ?? "never"}`);
+ lines.push(` Last attempted sync: ${perNode.lastAttemptedSync ?? "never"}`);
+ lines.push(` Most recent error: ${perNode.lastError ?? "none"}`);
+ }
+ process.stdout.write(lines.join("\n") + "\n");
+ return 0;
+}
+
+async function runDecommission(config: Config, requestedNode: bigint | undefined): Promise<number> {
+ const controller = await startController(config);
+ const statePath = serviceStatePath(config.storagePath);
+ const failed: bigint[] = [];
+ try {
+ // Like sync, no --node means every commissioned device.
+ for (const nodeId of resolveTargetNodes(controller, requestedNode)) {
+ try {
+ const node = await connectDevice(controller, nodeId);
+ log.info(`Decommissioning: removing this controller's fabric from node ${nodeId}`);
+ await node.decommission();
+ removeNodeState(statePath, nodeId);
+ process.stdout.write(
+ `Device ${nodeId} removed this controller's fabric; its primary ecosystem is untouched.\n`,
+ );
+ } catch (cause) {
+ failed.push(nodeId);
+ log.error(`Node ${nodeId}: decommission failed`, cause);
+ process.stdout.write(`Device ${nodeId} could NOT be decommissioned: ${describeError(cause)}\n`);
+ }
+ }
+
+ const remaining = commissionedNodeIds(controller);
+ process.stdout.write(
+ (remaining.length > 0
+ ? `${remaining.length} device${remaining.length === 1 ? "" : "s"} remain commissioned: ${remaining.join(", ")}.`
+ : `No devices remain commissioned. Local controller identity remains in ` +
+ `${config.storagePath}; deleting that directory is now safe, or keep it to reuse ` +
+ `the same identity for future commissioning.`) + "\n",
+ );
+ return failed.length > 0 ? 1 : 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+main()
+ .then(code => {
+ process.exitCode = code;
+ })
+ .catch(cause => {
+ log.error("Fatal error", cause);
+ if (!(cause instanceof Error)) {
+ process.stderr.write(describeError(cause) + "\n");
+ }
+ process.exitCode = 1;
+ });
diff --git a/src/sqlite-lazy.ts b/src/sqlite-lazy.ts
new file mode 100644
index 0000000..587f662
--- /dev/null
+++ b/src/sqlite-lazy.ts
@@ -0,0 +1,17 @@
+/**
+ * Lazy stand-in for node:sqlite, used only by the esbuild bundle (via
+ * --alias:node:sqlite=./dist/sqlite-lazy.js).
+ *
+ * In an ESM bundle, external imports are hoisted and evaluated before any
+ * bundled code runs, so a direct node:sqlite import would fire Node's
+ * ExperimentalWarning before the filter in warnings.ts is installed. This
+ * module loads node:sqlite with require() at module-initialization time
+ * instead, which the bundle defers until after the filter is active.
+ */
+import { createRequire } from "node:module";
+
+const nodeRequire = createRequire(import.meta.url);
+const sqlite = nodeRequire("node:sqlite") as typeof import("node:sqlite");
+
+export const DatabaseSync = sqlite.DatabaseSync;
+export const StatementSync = sqlite.StatementSync;
diff --git a/src/state.ts b/src/state.ts
new file mode 100644
index 0000000..c5621fe
--- /dev/null
+++ b/src/state.ts
@@ -0,0 +1,90 @@
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+/**
+ * Small service-state file kept next to the Matter fabric storage, with one
+ * entry per commissioned node (keyed by node ID as a decimal string). Written
+ * atomically (temp file + rename) so a power loss mid-write never corrupts it.
+ */
+export interface NodeState {
+ lastSuccessfulConnection: string | null;
+ lastSuccessfulSync: string | null;
+ lastAttemptedSync: string | null;
+ lastError: string | null;
+}
+
+export interface ServiceState {
+ nodes: Record<string, NodeState>;
+}
+
+export const EMPTY_NODE_STATE: NodeState = {
+ lastSuccessfulConnection: null,
+ lastSuccessfulSync: null,
+ lastAttemptedSync: null,
+ lastError: null,
+};
+
+export function serviceStatePath(storagePath: string): string {
+ return join(storagePath, "service-state.json");
+}
+
+export function readServiceState(path: string): ServiceState {
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch {
+ return { nodes: {} };
+ }
+ try {
+ const parsed = JSON.parse(raw) as Partial<ServiceState>;
+ const nodes: Record<string, NodeState> = {};
+ if (typeof parsed.nodes === "object" && parsed.nodes !== null) {
+ for (const [nodeId, value] of Object.entries(parsed.nodes)) {
+ if (!/^\d+$/.test(nodeId) || typeof value !== "object" || value === null) continue;
+ const entry = value as Partial<NodeState>;
+ nodes[nodeId] = {
+ lastSuccessfulConnection: stringOrNull(entry.lastSuccessfulConnection),
+ lastSuccessfulSync: stringOrNull(entry.lastSuccessfulSync),
+ lastAttemptedSync: stringOrNull(entry.lastAttemptedSync),
+ lastError: stringOrNull(entry.lastError),
+ };
+ }
+ }
+ return { nodes };
+ } catch {
+ // A corrupt state file is not fatal; it only holds status metadata.
+ return { nodes: {} };
+ }
+}
+
+export function writeServiceState(path: string, state: ServiceState): void {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
+ const temp = `${path}.tmp`;
+ writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
+ renameSync(temp, path);
+}
+
+export function nodeState(state: ServiceState, nodeId: bigint): NodeState {
+ return state.nodes[nodeId.toString()] ?? { ...EMPTY_NODE_STATE };
+}
+
+/** Merges a patch into one node's entry and persists the result. */
+export function updateNodeState(path: string, nodeId: bigint, patch: Partial<NodeState>): ServiceState {
+ const state = readServiceState(path);
+ const key = nodeId.toString();
+ state.nodes[key] = { ...(state.nodes[key] ?? EMPTY_NODE_STATE), ...patch };
+ writeServiceState(path, state);
+ return state;
+}
+
+/** Drops one node's entry (after decommissioning) and persists the result. */
+export function removeNodeState(path: string, nodeId: bigint): ServiceState {
+ const state = readServiceState(path);
+ delete state.nodes[nodeId.toString()];
+ writeServiceState(path, state);
+ return state;
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" ? value : null;
+}
diff --git a/src/sync.ts b/src/sync.ts
new file mode 100644
index 0000000..64ff832
--- /dev/null
+++ b/src/sync.ts
@@ -0,0 +1,181 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+/**
+ * Matter epoch conversion and clock comparison.
+ *
+ * Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter
+ * epoch"), not the Unix epoch. All arithmetic stays in bigint; the full
+ * microsecond timestamp must never pass through a JavaScript number.
+ */
+
+export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n;
+
+/**
+ * The exact distance between the Unix and Matter epochs in microseconds;
+ * equivalently, Matter epoch zero (2000-01-01T00:00:00Z) expressed as
+ * Unix-epoch microseconds.
+ *
+ * Epoch handling has two layers. On the wire, all epoch-us fields are
+ * Matter-epoch. At the matter.js API boundary, however, TlvEpochUs values
+ * are Unix-epoch microseconds in both directions: the library performs the
+ * wire conversion itself (and rejects pre-converted values). All values
+ * passed to or read from matter.js are therefore Unix-epoch microseconds.
+ */
+export const MATTER_EPOCH_AS_UNIX_MICROS = MATTER_EPOCH_UNIX_SECONDS * 1_000_000n;
+
+/**
+ * A device is treated as epoch-confused when its clock delta lands within
+ * this window of the exact Unix/Matter epoch distance: firmware that encodes
+ * Unix-epoch values on the wire reads as ~30 years ahead after decoding. A
+ * week comfortably covers any real drift (a wrong clock is stale by hours,
+ * not decades) while remaining astronomically far from every honest delta.
+ */
+const EPOCH_SHIFT_DETECTION_WINDOW_MICROS = 7n * 86_400n * 1_000_000n;
+
+/** Converts a Unix-epoch timestamp in milliseconds to Matter-epoch microseconds. */
+export function unixMillisToMatterMicros(unixMilliseconds: bigint): bigint {
+ return unixMilliseconds * 1_000n - MATTER_EPOCH_UNIX_SECONDS * 1_000_000n;
+}
+
+/** Converts Matter-epoch microseconds back to Unix-epoch milliseconds (truncating). */
+export function matterMicrosToUnixMillis(matterMicroseconds: bigint): bigint {
+ return (matterMicroseconds + MATTER_EPOCH_UNIX_SECONDS * 1_000_000n) / 1_000n;
+}
+
+/** Current time as Matter-epoch microseconds. */
+export function currentMatterUtcMicroseconds(now: () => number = Date.now): bigint {
+ return unixMillisToMatterMicros(BigInt(now()));
+}
+
+/** Renders Matter-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */
+export function formatMatterMicros(matterMicroseconds: bigint): string {
+ return new Date(Number(matterMicrosToUnixMillis(matterMicroseconds))).toISOString();
+}
+
+/** Current time as Unix-epoch microseconds (matter.js's TlvEpochUs convention). */
+export function currentUnixUtcMicroseconds(now: () => number = Date.now): bigint {
+ return BigInt(now()) * 1_000n;
+}
+
+/** Renders Unix-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */
+export function formatUnixMicros(unixMicroseconds: bigint): string {
+ return new Date(Number(unixMicroseconds / 1_000n)).toISOString();
+}
+
+/**
+ * Comparison of the device's reported clock against the time we are about to
+ * set, for the sync report ("was X, set to Y, delta Z").
+ */
+export interface ClockComparison {
+ /**
+ * Device time before sync as ISO-8601 (epoch-corrected for off-spec
+ * devices, i.e. the wall clock the device actually believes), or null if
+ * its clock was unset.
+ */
+ deviceTime: string | null;
+ /** The time being written, as ISO-8601. */
+ hostTime: string;
+ /** deviceTime - hostTime in microseconds; null when the device clock was unset. */
+ deltaMicroseconds: bigint | null;
+ /** True when the device reports Unix-epoch microseconds instead of Matter-epoch. */
+ epochShifted: boolean;
+ /**
+ * The delta with any detected epoch shift removed: the device's real clock
+ * error. Equals deltaMicroseconds for spec-compliant devices.
+ */
+ effectiveDeltaMicroseconds: bigint | null;
+ /** Human-readable summary, e.g. "device clock was 3.2s behind". */
+ description: string;
+}
+
+/**
+ * Compares the device's utcTime attribute (null when the device lost its
+ * clock, e.g. after a power outage) against the host time being written.
+ * Both arguments are Unix-epoch microseconds, matter.js's API convention.
+ *
+ * Detects epoch-confused devices: firmware that encodes Unix-epoch values
+ * on the wire decodes to a delta of exactly the Unix/Matter epoch distance,
+ * which is folded out so such a device's real clock error stays visible
+ * instead of reading as 30 years ahead.
+ */
+export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison {
+ const hostTime = formatUnixMicros(hostMicros);
+ if (deviceMicros === null) {
+ return {
+ deviceTime: null,
+ hostTime,
+ deltaMicroseconds: null,
+ epochShifted: false,
+ effectiveDeltaMicroseconds: null,
+ description: "device clock was unset",
+ };
+ }
+ const delta = deviceMicros - hostMicros;
+ const shiftError = delta - MATTER_EPOCH_AS_UNIX_MICROS;
+ const epochShifted = (shiftError < 0n ? -shiftError : shiftError) <= EPOCH_SHIFT_DETECTION_WINDOW_MICROS;
+ const effective = epochShifted ? shiftError : delta;
+ const description = epochShifted
+ ? `device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was ${describeDelta(effective)}`
+ : `device clock was ${describeDelta(delta)}`;
+ return {
+ deviceTime: formatUnixMicros(epochShifted ? deviceMicros - MATTER_EPOCH_AS_UNIX_MICROS : deviceMicros),
+ hostTime,
+ deltaMicroseconds: delta,
+ epochShifted,
+ effectiveDeltaMicroseconds: effective,
+ description,
+ };
+}
+
+/** "within 1s of host time (412ms behind)" / "1m 23s ahead" for a signed delta. */
+function describeDelta(delta: bigint): string {
+ const magnitude = delta < 0n ? -delta : delta;
+ const direction = delta < 0n ? "behind" : "ahead";
+ return magnitude < 1_000_000n
+ ? `within 1s of host time (${formatDurationMicros(magnitude)} ${direction})`
+ : `${formatDurationMicros(magnitude)} ${direction}`;
+}
+
+/** Formats a non-negative duration in microseconds as a compact human string. */
+export function formatDurationMicros(microseconds: bigint): string {
+ if (microseconds < 0n) throw new Error("duration must be non-negative");
+ if (microseconds < 1_000n) return `${microseconds}us`;
+ if (microseconds < 1_000_000n) return `${microseconds / 1_000n}ms`;
+
+ const totalSeconds = microseconds / 1_000_000n;
+ if (totalSeconds < 60n) {
+ const tenths = (microseconds % 1_000_000n) / 100_000n;
+ return tenths === 0n ? `${totalSeconds}s` : `${totalSeconds}.${tenths}s`;
+ }
+ const days = totalSeconds / 86_400n;
+ const hours = (totalSeconds % 86_400n) / 3_600n;
+ const minutes = (totalSeconds % 3_600n) / 60n;
+ const seconds = totalSeconds % 60n;
+ const parts: string[] = [];
+ if (days > 0n) parts.push(`${days}d`);
+ if (hours > 0n) parts.push(`${hours}h`);
+ if (minutes > 0n) parts.push(`${minutes}m`);
+ if (seconds > 0n && days === 0n) parts.push(`${seconds}s`);
+ return parts.join(" ");
+}
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Whether the host clock is NTP-synchronized, via systemd-timesyncd. The
+ * device must never be set from an unsynchronized clock.
+ *
+ * Returns false on hosts without timedatectl (e.g. during development on
+ * macOS) so callers fail safe; pass `assumeSynchronized` explicitly in tests.
+ */
+export async function isHostClockSynchronized(): Promise<boolean> {
+ try {
+ const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], {
+ timeout: 5000,
+ });
+ return stdout.trim() === "yes";
+ } catch {
+ return false;
+ }
+}
diff --git a/src/timesync.ts b/src/timesync.ts
new file mode 100644
index 0000000..b3a7aea
--- /dev/null
+++ b/src/timesync.ts
@@ -0,0 +1,268 @@
+import { TimeSynchronization } from "@matter/main/clusters/time-synchronization";
+import type { PairedNode } from "@project-chip/matter.js/device";
+import type { TimeSyncSummary } from "./inspection.js";
+import { Log } from "./logging.js";
+import {
+ compareDeviceClock,
+ currentUnixUtcMicroseconds,
+ formatDurationMicros,
+ formatUnixMicros,
+ type ClockComparison,
+} from "./sync.js";
+import {
+ buildDstOffsetList,
+ buildTimeZoneList,
+ formatUtcOffset,
+ type MatterDstOffsetEntry,
+ type MatterTimeZoneEntry,
+} from "./timezone.js";
+
+const log = new Log("sync");
+
+/**
+ * One clock synchronization against a live device, driven entirely by the
+ * capabilities the device itself reports: SetUTCTime always (it is the
+ * mandatory command), SetTimeZone/SetDSTOffset only when the TimeZone
+ * feature and the commands are present, and the DST list bounded by the
+ * device's DSTOffsetListMaxSize. Devices without optional features degrade
+ * gracefully; a missing Time Synchronization cluster is skipped with a
+ * warning.
+ */
+
+/** What the device's Time Synchronization cluster supports. */
+export interface TimeSyncCapabilities {
+ timeZoneFeature: boolean;
+ supportsSetUtcTime: boolean;
+ supportsSetTimeZone: boolean;
+ supportsSetDstOffset: boolean;
+ timeZoneListMaxSize: number;
+ dstOffsetListMaxSize: number;
+}
+
+/** Derives capabilities from an inspection summary (live or captured fixture). */
+export function capabilitiesFromTimeSync(summary: TimeSyncSummary): TimeSyncCapabilities {
+ const attributeValue = (name: string): unknown =>
+ summary.attributes.find(attribute => attribute.name === name)?.value;
+ const sizeAttribute = (name: string): number => {
+ const value = attributeValue(name);
+ // The spec minimum for both list sizes is 1; assume it when unreported.
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : 1;
+ };
+ return {
+ timeZoneFeature: summary.supportedFeatures["timeZone"] === true,
+ supportsSetUtcTime: summary.supportedCommands["setUtcTime"] === true,
+ supportsSetTimeZone: summary.supportedCommands["setTimeZone"] === true,
+ supportsSetDstOffset: summary.supportedCommands["setDstOffset"] === true,
+ timeZoneListMaxSize: sizeAttribute("timeZoneListMaxSize"),
+ dstOffsetListMaxSize: sizeAttribute("dstOffsetListMaxSize"),
+ };
+}
+
+/** The writes one sync run will perform, decided before touching the device. */
+export interface TimeSyncPlan {
+ setUtcTime: boolean;
+ timeZone: MatterTimeZoneEntry[] | null;
+ dstOffsets: MatterDstOffsetEntry[] | null;
+}
+
+export function planTimeSync(
+ capabilities: TimeSyncCapabilities,
+ timezone: string,
+ from: Date = new Date(),
+): TimeSyncPlan {
+ const timeZoneSupported = capabilities.timeZoneFeature && capabilities.supportsSetTimeZone;
+ return {
+ setUtcTime: capabilities.supportsSetUtcTime,
+ timeZone: timeZoneSupported ? buildTimeZoneList(timezone, from) : null,
+ dstOffsets:
+ timeZoneSupported && capabilities.supportsSetDstOffset
+ ? buildDstOffsetList(timezone, capabilities.dstOffsetListMaxSize, from)
+ : null,
+ };
+}
+
+/** Read-back verification after the writes. */
+export interface SyncVerification {
+ /** Device utcTime after sync as ISO-8601 (epoch-corrected), or null if unreadable. */
+ utcTimeAfter: string | null;
+ /** Effective clock error after sync in microseconds (epoch shift removed). */
+ deltaAfterMicroseconds: bigint | null;
+ /** True when the device reports Unix-epoch instead of Matter-epoch time. */
+ epochShifted: boolean;
+ verified: boolean;
+}
+
+const VERIFY_TOLERANCE_MICROS = 5_000_000n;
+
+export interface SyncNodeResult {
+ nodeId: string;
+ success: boolean;
+ /** Set (with success=false) when the device has no Time Synchronization cluster. */
+ skipped: boolean;
+ error: string | null;
+ clockBefore: ClockComparison | null;
+ /** The UTC instant written, ISO-8601 and Unix-epoch microseconds (matter.js convention). */
+ utcTimeWritten: string | null;
+ utcTimeWrittenUnixMicroseconds: bigint | null;
+ timeZoneWritten: MatterTimeZoneEntry[] | null;
+ dstOffsetsWritten: MatterDstOffsetEntry[] | null;
+ verification: SyncVerification | null;
+}
+
+function emptyResult(nodeId: bigint): SyncNodeResult {
+ return {
+ nodeId: nodeId.toString(),
+ success: false,
+ skipped: false,
+ error: null,
+ clockBefore: null,
+ utcTimeWritten: null,
+ utcTimeWrittenUnixMicroseconds: null,
+ timeZoneWritten: null,
+ dstOffsetsWritten: null,
+ verification: null,
+ };
+}
+
+/** Coerces a utcTime attribute value (number | bigint | null | undefined) to bigint | null. */
+function utcTimeToBigint(value: number | bigint | null | undefined): bigint | null {
+ if (value === null || value === undefined) return null;
+ return BigInt(value);
+}
+
+/**
+ * Synchronizes one connected node's clock. Throws only on programming
+ * errors; device and interaction failures are reported in the result so the
+ * caller can continue with other nodes.
+ */
+export async function syncNode(node: PairedNode, timezone: string): Promise<SyncNodeResult> {
+ const nodeId = BigInt(node.nodeId);
+ const result = emptyResult(nodeId);
+
+ const client = node.getRootClusterClient(TimeSynchronization.Complete);
+ if (client === undefined) {
+ result.skipped = true;
+ result.error = "no Time Synchronization cluster on the root endpoint";
+ log.warn(`Node ${nodeId}: ${result.error}; skipping`);
+ return result;
+ }
+
+ try {
+ const supportedCommands = {
+ setUtcTime: client.isCommandSupportedByName("setUtcTime"),
+ setTimeZone: client.isCommandSupportedByName("setTimeZone"),
+ setDstOffset: client.isCommandSupportedByName("setDstOffset"),
+ };
+ const capabilities: TimeSyncCapabilities = {
+ timeZoneFeature: client.supportedFeatures["timeZone"] === true,
+ supportsSetUtcTime: supportedCommands.setUtcTime,
+ supportsSetTimeZone: supportedCommands.setTimeZone,
+ supportsSetDstOffset: supportedCommands.setDstOffset,
+ timeZoneListMaxSize: (await client.attributes.timeZoneListMaxSize.get()) ?? 1,
+ dstOffsetListMaxSize: (await client.attributes.dstOffsetListMaxSize.get()) ?? 1,
+ };
+ if (!capabilities.supportsSetUtcTime) {
+ throw new Error("device does not accept SetUTCTime; its clock cannot be synchronized");
+ }
+ const plan = planTimeSync(capabilities, timezone);
+
+ // Read the device clock live (not from cache) for the before/after report.
+ const deviceBefore = utcTimeToBigint(await client.attributes.utcTime.get(true));
+ result.clockBefore = compareDeviceClock(deviceBefore, currentUnixUtcMicroseconds());
+ log.info(`Node ${nodeId}: ${result.clockBefore.description}`);
+
+ // Write UTC time last-moment-fresh, as Unix-epoch microseconds:
+ // matter.js's TlvEpochUs converts to the Matter epoch on the wire.
+ // Granularity and time source matter for acceptance: a device that
+ // already has good time may reject (TimeNotAccepted) a claim no better
+ // than what it holds, so state the strongest truthful claim: the host
+ // clock is NTP-disciplined (NonMatterSntp) and the timestamp is
+ // microsecond-precise at send time.
+ const utcTime = currentUnixUtcMicroseconds();
+ await client.commands.setUtcTime({
+ utcTime,
+ granularity: TimeSynchronization.Granularity.MicrosecondsGranularity,
+ timeSource: TimeSynchronization.TimeSource.NonMatterSntp,
+ });
+ result.utcTimeWritten = formatUnixMicros(utcTime);
+ result.utcTimeWrittenUnixMicroseconds = utcTime;
+ log.info(`Node ${nodeId}: SetUTCTime ${result.utcTimeWritten}`);
+
+ if (plan.timeZone !== null) {
+ const response = await client.commands.setTimeZone({ timeZone: plan.timeZone });
+ result.timeZoneWritten = plan.timeZone;
+ log.info(
+ `Node ${nodeId}: SetTimeZone ${plan.timeZone[0]!.name} ` +
+ `(standard offset ${formatUtcOffset(plan.timeZone[0]!.offset)})`,
+ );
+ // dstOffsetRequired=false means the device knows this zone's DST
+ // rules itself; pushing our list anyway would be redundant.
+ const dstRequired = response?.dstOffsetRequired !== false;
+ if (plan.dstOffsets !== null && dstRequired) {
+ await client.commands.setDstOffset({ dstOffset: plan.dstOffsets });
+ result.dstOffsetsWritten = plan.dstOffsets;
+ log.info(`Node ${nodeId}: SetDSTOffset with ${plan.dstOffsets.length} entries`);
+ }
+ }
+
+ // Read back and verify against the host clock at read time.
+ const deviceAfter = utcTimeToBigint(await client.attributes.utcTime.get(true));
+ const after = compareDeviceClock(deviceAfter, currentUnixUtcMicroseconds());
+ const effective = after.effectiveDeltaMicroseconds;
+ const verified =
+ effective !== null && (effective < 0n ? -effective : effective) <= VERIFY_TOLERANCE_MICROS;
+ result.verification = {
+ utcTimeAfter: after.deviceTime,
+ deltaAfterMicroseconds: effective,
+ epochShifted: after.epochShifted,
+ verified,
+ };
+ if (!verified) {
+ throw new Error(
+ `read-back verification failed: device utcTime is ${after.deviceTime ?? "unset"} ` +
+ `(${after.description})`,
+ );
+ }
+
+ result.success = true;
+ return result;
+ } catch (cause) {
+ result.error = cause instanceof Error ? cause.message : String(cause);
+ return result;
+ }
+}
+
+export function formatSyncResult(result: SyncNodeResult): string {
+ const lines: string[] = [`Node ${result.nodeId}:`];
+ if (result.skipped) {
+ lines.push(` Skipped: ${result.error}`);
+ return lines.join("\n");
+ }
+ if (result.clockBefore !== null) {
+ lines.push(` Device clock: ${result.clockBefore.deviceTime ?? "unset"}`);
+ lines.push(` Assessment: ${result.clockBefore.description}`);
+ }
+ if (result.utcTimeWritten !== null) {
+ lines.push(` Time written: ${result.utcTimeWritten}`);
+ }
+ if (result.timeZoneWritten !== null) {
+ const zone = result.timeZoneWritten[0]!;
+ lines.push(` Time zone: ${zone.name} (standard offset ${formatUtcOffset(zone.offset)})`);
+ }
+ if (result.dstOffsetsWritten !== null && result.dstOffsetsWritten.length > 0) {
+ const last = result.dstOffsetsWritten[result.dstOffsetsWritten.length - 1]!;
+ const horizon =
+ last.validUntil === null ? "indefinitely" : `through ${formatUnixMicros(last.validUntil)}`;
+ lines.push(` DST offsets: ${result.dstOffsetsWritten.length} entries, valid ${horizon}`);
+ }
+ if (result.verification !== null && result.verification.deltaAfterMicroseconds !== null) {
+ const delta = result.verification.deltaAfterMicroseconds;
+ const magnitude = delta < 0n ? -delta : delta;
+ lines.push(
+ ` Verification: device clock within ${formatDurationMicros(magnitude)} of host after sync` +
+ `${result.verification.epochShifted ? " (device reports Unix-epoch time; corrected)" : ""}`,
+ );
+ }
+ lines.push(result.success ? " Result: OK" : ` Result: FAILED (${result.error})`);
+ return lines.join("\n");
+}
diff --git a/src/timezone.ts b/src/timezone.ts
new file mode 100644
index 0000000..062a811
--- /dev/null
+++ b/src/timezone.ts
@@ -0,0 +1,190 @@
+/**
+ * IANA time-zone helpers: validation and offset introspection (config
+ * validation, status output) plus Matter TimeZone/DSTOffset structure
+ * generation for the sync command.
+ */
+
+import { MATTER_EPOCH_AS_UNIX_MICROS } from "./sync.js";
+
+/** Returns true when the host's ICU data recognizes the IANA time-zone name. */
+export function isValidTimeZone(timezone: string): boolean {
+ if (typeof timezone !== "string" || timezone.length === 0) return false;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Current UTC offset of `timezone` at `date`, in seconds.
+ *
+ * Derived from ICU rules, never from a hard-coded offset, so daylight-saving
+ * transitions are honored automatically.
+ */
+export function utcOffsetSeconds(timezone: string, date: Date = new Date()): number {
+ const formatter = new Intl.DateTimeFormat("en-US", {
+ timeZone: timezone,
+ timeZoneName: "longOffset",
+ });
+ const offsetPart = formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value;
+ if (offsetPart === undefined) {
+ throw new Error(`Unable to determine UTC offset for time zone ${timezone}`);
+ }
+ // Formats look like "GMT-05:00", "GMT+05:30", or plain "GMT" for UTC itself.
+ const match = /^GMT(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(offsetPart);
+ if (!match) {
+ throw new Error(`Unrecognized UTC offset format "${offsetPart}" for time zone ${timezone}`);
+ }
+ if (match[1] === undefined) return 0;
+ const sign = match[1] === "-" ? -1 : 1;
+ const hours = Number(match[2]);
+ const minutes = Number(match[3]);
+ const seconds = match[4] === undefined ? 0 : Number(match[4]);
+ return sign * (hours * 3600 + minutes * 60 + seconds);
+}
+
+/** Formats an offset in seconds as "UTC-05:00" style text for logs and status output. */
+export function formatUtcOffset(offsetSeconds: number): string {
+ const sign = offsetSeconds < 0 ? "-" : "+";
+ const absolute = Math.abs(offsetSeconds);
+ const hours = String(Math.floor(absolute / 3600)).padStart(2, "0");
+ const minutes = String(Math.floor((absolute % 3600) / 60)).padStart(2, "0");
+ return `UTC${sign}${hours}:${minutes}`;
+}
+
+/**
+ * Finds the next instant at which the zone's UTC offset changes, scanning up
+ * to `horizonDays` ahead. Returns null when no transition occurs in the
+ * window (e.g. fixed-offset zones).
+ *
+ * Uses day-granularity scan plus binary search, so it is exact to the second
+ * without iterating minute-by-minute.
+ */
+export function nextOffsetTransition(
+ timezone: string,
+ from: Date = new Date(),
+ horizonDays = 400,
+): { at: Date; offsetBeforeSeconds: number; offsetAfterSeconds: number } | null {
+ const startOffset = utcOffsetSeconds(timezone, from);
+ const dayMs = 24 * 60 * 60 * 1000;
+ // Probe on whole-second boundaries so the binary search converges on the
+ // exact transition instant (transitions occur at whole seconds).
+ const startMs = Math.ceil(from.getTime() / 1000) * 1000;
+
+ let previous = startMs;
+ let changedAtOrBefore: number | null = null;
+ for (let day = 1; day <= horizonDays; day++) {
+ const probe = startMs + day * dayMs;
+ if (utcOffsetSeconds(timezone, new Date(probe)) !== startOffset) {
+ changedAtOrBefore = probe;
+ break;
+ }
+ previous = probe;
+ }
+ if (changedAtOrBefore === null) return null;
+
+ // Binary search the exact transition instant between the last unchanged
+ // probe and the first changed probe.
+ let low = previous;
+ let high = changedAtOrBefore;
+ while (high - low > 1000) {
+ const middle = low + Math.floor((high - low) / 2 / 1000) * 1000;
+ if (utcOffsetSeconds(timezone, new Date(middle)) === startOffset) {
+ low = middle;
+ } else {
+ high = middle;
+ }
+ }
+ return {
+ at: new Date(high),
+ offsetBeforeSeconds: startOffset,
+ offsetAfterSeconds: utcOffsetSeconds(timezone, new Date(high)),
+ };
+}
+
+/**
+ * Matter TimeZoneStruct and DSTOffsetStruct as passed to matter.js. All
+ * timestamps here are Unix-epoch microseconds: matter.js's TlvEpochUs
+ * converts to Matter-epoch on the wire and rejects pre-converted values.
+ * "Valid since the beginning of time" is therefore Matter epoch zero
+ * expressed in Unix microseconds, not 0.
+ */
+export interface MatterTimeZoneEntry {
+ /** Standard (non-DST) UTC offset in seconds. */
+ offset: number;
+ /** Unix-epoch microseconds at which the entry takes effect. */
+ validAt: bigint;
+ name?: string;
+}
+
+/** One DST period; offset is added on top of the TimeZone offset. */
+export interface MatterDstOffsetEntry {
+ offset: number;
+ validStarting: bigint;
+ /** Unix-epoch microseconds; null = valid until further notice (last entry only). */
+ validUntil: bigint | null;
+}
+
+/**
+ * The zone's standard (non-DST) UTC offset in seconds: the smaller of the
+ * mid-January and mid-July offsets of the year. DST increases the offset in
+ * every zone as reported by ICU (including Europe/Dublin, which ICU models
+ * as +00:00 standard / +01:00 summer despite IANA's negative-SAVE encoding).
+ */
+export function standardOffsetSeconds(timezone: string, at: Date = new Date()): number {
+ const year = at.getUTCFullYear();
+ const january = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 0, 15)));
+ const july = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 6, 15)));
+ return Math.min(january, july);
+}
+
+/**
+ * The TimeZone attribute list for SetTimeZone: a single entry carrying the
+ * zone's standard offset and IANA name, valid from the beginning of time.
+ */
+export function buildTimeZoneList(timezone: string, at: Date = new Date()): MatterTimeZoneEntry[] {
+ return [
+ {
+ offset: standardOffsetSeconds(timezone, at),
+ validAt: MATTER_EPOCH_AS_UNIX_MICROS,
+ name: timezone.slice(0, 64),
+ },
+ ];
+}
+
+/**
+ * The DSTOffset list for SetDstOffset: the DST state in effect at `from`
+ * followed by upcoming transitions, at most `maxEntries` entries (the
+ * device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete
+ * validUntil bounds where a next transition is known, so a device left
+ * unrefreshed falls back to standard time rather than trusting stale DST;
+ * the periodic sync refreshes the list long before it expires. Zones
+ * without transitions yield a single open-ended zero entry.
+ */
+export function buildDstOffsetList(
+ timezone: string,
+ maxEntries: number,
+ from: Date = new Date(),
+): MatterDstOffsetEntry[] {
+ const standard = standardOffsetSeconds(timezone, from);
+ const entries: MatterDstOffsetEntry[] = [];
+ let cursor = from;
+ let currentDst = utcOffsetSeconds(timezone, cursor) - standard;
+ let validStarting = MATTER_EPOCH_AS_UNIX_MICROS;
+ const limit = Math.max(1, maxEntries);
+ while (entries.length < limit) {
+ const transition = nextOffsetTransition(timezone, cursor);
+ if (transition === null) {
+ entries.push({ offset: currentDst, validStarting, validUntil: null });
+ break;
+ }
+ const untilMicros = BigInt(transition.at.getTime()) * 1_000n;
+ entries.push({ offset: currentDst, validStarting, validUntil: untilMicros });
+ validStarting = untilMicros;
+ currentDst = transition.offsetAfterSeconds - standard;
+ cursor = new Date(transition.at.getTime() + 1000);
+ }
+ return entries;
+}
diff --git a/src/warnings.ts b/src/warnings.ts
new file mode 100644
index 0000000..8bf2ca5
--- /dev/null
+++ b/src/warnings.ts
@@ -0,0 +1,19 @@
+/**
+ * matter.js's storage backend imports node:sqlite, which Node still marks
+ * experimental and announces with a process-level ExperimentalWarning on
+ * every run. That one-line warning is pure noise to an operator, so exactly
+ * that warning is dropped here; every other warning passes through.
+ *
+ * This module must be imported before anything that (transitively) imports
+ * matter.js, because the warning fires when node:sqlite is first loaded.
+ */
+
+const originalEmitWarning = process.emitWarning.bind(process);
+
+process.emitWarning = ((warning: string | Error, ...rest: unknown[]): void => {
+ const text = typeof warning === "string" ? warning : warning.message;
+ if (text.includes("SQLite is an experimental feature")) return;
+ (originalEmitWarning as (warning: string | Error, ...rest: unknown[]) => void)(warning, ...rest);
+}) as typeof process.emitWarning;
+
+export {};
diff --git a/test/commissioning.test.ts b/test/commissioning.test.ts
new file mode 100644
index 0000000..676a5e1
--- /dev/null
+++ b/test/commissioning.test.ts
@@ -0,0 +1,33 @@
+import { ManualPairingCodeCodec } from "@matter/main/types";
+import { describe, expect, it } from "vitest";
+import { parsePairingCode } from "../src/commissioning.js";
+
+describe("parsePairingCode", () => {
+ const validCode = ManualPairingCodeCodec.encode({ discriminator: 3840, passcode: 20202021 });
+
+ it("parses a valid 11-digit manual code", () => {
+ const parsed = parsePairingCode(validCode);
+ expect(parsed.passcode).toBe(20202021);
+ expect(parsed.shortDiscriminator).toBe(3840 >> 8);
+ });
+
+ it("accepts hyphens and whitespace", () => {
+ const withHyphens = `${validCode.slice(0, 4)}-${validCode.slice(4, 7)}-${validCode.slice(7)}`;
+ expect(parsePairingCode(` ${withHyphens} `).passcode).toBe(20202021);
+ });
+
+ it("rejects empty input", () => {
+ expect(() => parsePairingCode("")).toThrow(/empty/);
+ expect(() => parsePairingCode(" ")).toThrow(/empty/);
+ });
+
+ it("rejects codes of the wrong length", () => {
+ expect(() => parsePairingCode("12345")).toThrow(/11 or 21 digits/);
+ expect(() => parsePairingCode("123456789012")).toThrow(/11 or 21 digits/);
+ });
+
+ it("rejects codes with an invalid checksum", () => {
+ const corrupted = validCode.slice(0, -1) + ((Number(validCode.at(-1)) + 1) % 10).toString();
+ expect(() => parsePairingCode(corrupted)).toThrow();
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..1caecd8
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { ConfigError, parseNodeId, validateConfig } from "../src/config.js";
+
+const minimal = { storagePath: "/var/lib/mattertimesync" };
+
+describe("validateConfig", () => {
+ it("accepts a minimal configuration and applies defaults", () => {
+ const config = validateConfig(minimal);
+ expect(config.storagePath).toBe(minimal.storagePath);
+ expect(config.timezone).toBe("America/Chicago");
+ expect(config.logLevel).toBe("info");
+ });
+
+ it("accepts a full configuration", () => {
+ const config = validateConfig({
+ ...minimal,
+ timezone: "Europe/Berlin",
+ logLevel: "debug",
+ });
+ expect(config.timezone).toBe("Europe/Berlin");
+ expect(config.logLevel).toBe("debug");
+ });
+
+ it("rejects non-object documents", () => {
+ expect(() => validateConfig([])).toThrow(ConfigError);
+ expect(() => validateConfig("x")).toThrow(ConfigError);
+ expect(() => validateConfig(null)).toThrow(ConfigError);
+ });
+
+ it("requires storagePath", () => {
+ expect(() => validateConfig({})).toThrow(/storagePath/);
+ expect(() => validateConfig({ storagePath: "" })).toThrow(/storagePath/);
+ });
+
+ it("rejects unknown fields", () => {
+ expect(() => validateConfig({ ...minimal, unexpected: 1 })).toThrow(/unknown field "unexpected"/);
+ // Fields from abandoned designs are rejected, not ignored.
+ expect(() => validateConfig({ ...minimal, syncIntervalHours: 24 })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, syncOnReconnect: true })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, nodeId: "1" })).toThrow(/unknown field/);
+ });
+
+ it("rejects invalid time zones", () => {
+ expect(() => validateConfig({ ...minimal, timezone: "Central Time" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: "" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: 5 })).toThrow(/timezone/);
+ });
+
+ it("rejects invalid log levels", () => {
+ expect(() => validateConfig({ ...minimal, logLevel: "verbose" })).toThrow(/logLevel/);
+ });
+});
+
+describe("parseNodeId", () => {
+ it("parses decimal strings without precision loss", () => {
+ expect(parseNodeId("1")).toBe(1n);
+ expect(parseNodeId("9007199254740993")).toBe(9007199254740993n);
+ expect(parseNodeId("18446744073709551615")).toBe(18446744073709551615n);
+ });
+
+ it("accepts small safe integers", () => {
+ expect(parseNodeId(42)).toBe(42n);
+ });
+
+ it("rejects unsafe or invalid values", () => {
+ expect(() => parseNodeId("18446744073709551616")).toThrow(ConfigError); // 2^64
+ expect(() => parseNodeId("-1")).toThrow(ConfigError);
+ expect(() => parseNodeId(1.5)).toThrow(ConfigError);
+ expect(() => parseNodeId("0x10")).toThrow(ConfigError);
+ expect(() => parseNodeId(Number.MAX_SAFE_INTEGER + 2)).toThrow(ConfigError);
+ expect(() => parseNodeId(null)).toThrow(ConfigError);
+ });
+});
diff --git a/test/fabrics.test.ts b/test/fabrics.test.ts
new file mode 100644
index 0000000..b3cd18e
--- /dev/null
+++ b/test/fabrics.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { findLikelyStrays, formatFabricTable, type FabricTable } from "../src/fabrics.js";
+
+const table: FabricTable = {
+ supportedFabrics: 5,
+ commissionedFabrics: 4,
+ entries: [
+ { fabricIndex: 1, fabricId: 100n, nodeId: 10n, vendorId: 0x1349, label: "Apple Home", isOurs: false },
+ { fabricIndex: 2, fabricId: 200n, nodeId: 20n, vendorId: 0xfff1, label: "mattertimesync", isOurs: true },
+ { fabricIndex: 3, fabricId: 300n, nodeId: 30n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ { fabricIndex: 4, fabricId: 400n, nodeId: 40n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ ],
+};
+
+describe("findLikelyStrays", () => {
+ it("flags entries with our label that are not our identity", () => {
+ expect(findLikelyStrays(table).map(entry => entry.fabricIndex)).toEqual([3, 4]);
+ });
+
+ it("never flags other ecosystems' entries", () => {
+ const strays = findLikelyStrays(table);
+ expect(strays.some(entry => entry.label === "Apple Home")).toBe(false);
+ });
+
+ it("flags nothing when our identity is not in the table", () => {
+ const withoutOurs: FabricTable = {
+ ...table,
+ entries: table.entries.map(entry => ({ ...entry, isOurs: false })),
+ };
+ expect(findLikelyStrays(withoutOurs)).toEqual([]);
+ });
+});
+
+describe("formatFabricTable", () => {
+ const output = formatFabricTable(1n, table);
+
+ it("shows slot usage and marks our entry", () => {
+ expect(output).toContain("4 of 5 slots used");
+ expect(output).toContain("[this controller]");
+ });
+
+ it("names known vendors and lists stale indices", () => {
+ expect(output).toContain("(Apple)");
+ expect(output).toContain("index 3, index 4");
+ });
+});
diff --git a/test/fixtures/alpstuga-inspect.json b/test/fixtures/alpstuga-inspect.json
new file mode 100644
index 0000000..1edfbfc
--- /dev/null
+++ b/test/fixtures/alpstuga-inspect.json
@@ -0,0 +1,504 @@
+{
+ "nodeId": "1",
+ "basicInformation": {
+ "vendorName": "IKEA of Sweden",
+ "vendorId": 4476,
+ "productName": "ALPSTUGA air quality monitor",
+ "productId": 12289,
+ "hardwareVersion": "P2.0",
+ "softwareVersion": "1.0.26",
+ "uniqueId": "3e4b6ef1d93c64187e026d0c1c1ba319"
+ },
+ "endpoints": [
+ {
+ "endpointId": 0,
+ "name": "MA-otarequestor",
+ "deviceTypes": [
+ {
+ "name": "MA-otarequestor",
+ "code": "0x0012",
+ "revision": 1
+ },
+ {
+ "name": "MA-rootnode",
+ "code": "0x0016",
+ "revision": 4
+ }
+ ],
+ "serverClusters": [
+ {
+ "id": "0x001d",
+ "name": "Descriptor",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "tagList": false
+ }
+ },
+ {
+ "id": "0x001f",
+ "name": "AccessControl",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "extension": false,
+ "managedDevice": false,
+ "auxiliary": false
+ }
+ },
+ {
+ "id": "0x0028",
+ "name": "BasicInformation",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x002a",
+ "name": "OtaSoftwareUpdateRequestor",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0030",
+ "name": "GeneralCommissioning",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "termsAndConditions": false,
+ "networkRecovery": false
+ }
+ },
+ {
+ "id": "0x0031",
+ "name": "NetworkCommissioning",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "wiFiNetworkInterface": false,
+ "threadNetworkInterface": true,
+ "ethernetNetworkInterface": false
+ }
+ },
+ {
+ "id": "0x0033",
+ "name": "GeneralDiagnostics",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "dataModelTest": false
+ }
+ },
+ {
+ "id": "0x0035",
+ "name": "ThreadNetworkDiagnostics",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "packetCounts": false,
+ "errorCounts": false,
+ "mleCounts": false,
+ "macCounts": false
+ }
+ },
+ {
+ "id": "0x0038",
+ "name": "TimeSynchronization",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0x003c",
+ "name": "AdministratorCommissioning",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "basic": true
+ }
+ },
+ {
+ "id": "0x003e",
+ "name": "OperationalCredentials",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x003f",
+ "name": "GroupKeyManagement",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "cacheAndSync": false,
+ "groupcast": false
+ }
+ }
+ ],
+ "clientClusters": [
+ "0x0029"
+ ]
+ },
+ {
+ "endpointId": 1,
+ "name": "MA-airqualitysensor",
+ "deviceTypes": [
+ {
+ "name": "MA-airqualitysensor",
+ "code": "0x002c",
+ "revision": 1
+ }
+ ],
+ "serverClusters": [
+ {
+ "id": "0x0003",
+ "name": "Identify",
+ "revision": 4,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0006",
+ "name": "OnOff",
+ "revision": 6,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "lighting": false,
+ "deadFrontBehavior": true,
+ "offOnly": false
+ }
+ },
+ {
+ "id": "0x001d",
+ "name": "Descriptor",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "tagList": false
+ }
+ },
+ {
+ "id": "0x005b",
+ "name": "AirQuality",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "fair": false,
+ "moderate": false,
+ "veryPoor": false,
+ "extremelyPoor": false
+ }
+ },
+ {
+ "id": "0x0402",
+ "name": "TemperatureMeasurement",
+ "revision": 4,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0405",
+ "name": "RelativeHumidityMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x040d",
+ "name": "CarbonDioxideConcentrationMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "numericMeasurement": true,
+ "levelIndication": true,
+ "mediumLevel": false,
+ "criticalLevel": false,
+ "peakMeasurement": false,
+ "averageMeasurement": false
+ }
+ },
+ {
+ "id": "0x042a",
+ "name": "Pm25ConcentrationMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "numericMeasurement": true,
+ "levelIndication": true,
+ "mediumLevel": false,
+ "criticalLevel": false,
+ "peakMeasurement": false,
+ "averageMeasurement": false
+ }
+ }
+ ],
+ "clientClusters": []
+ }
+ ],
+ "timeSynchronization": {
+ "endpointId": 0,
+ "clusterRevision": 2,
+ "supportedFeatures": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ },
+ "featureMap": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ },
+ "supportedCommands": {
+ "setUtcTime": true,
+ "setTrustedTimeSource": false,
+ "setTimeZone": true,
+ "setDstOffset": true,
+ "setDefaultNtp": false
+ },
+ "attributes": [
+ {
+ "id": "0x0",
+ "name": "0",
+ "value": "1785111495000000"
+ },
+ {
+ "id": "0x1",
+ "name": "1",
+ "value": 4
+ },
+ {
+ "id": "0x5",
+ "name": "5",
+ "value": [
+ {
+ "offset": -21600,
+ "validAt": "946684800000000",
+ "name": "America/Chicago"
+ }
+ ]
+ },
+ {
+ "id": "0x6",
+ "name": "6",
+ "value": [
+ {
+ "offset": 3600,
+ "validStarting": "946684800000000",
+ "validUntil": "1793516400000000"
+ },
+ {
+ "offset": 0,
+ "validStarting": "1793516400000000",
+ "validUntil": "1805011200000000"
+ }
+ ]
+ },
+ {
+ "id": "0x7",
+ "name": "7",
+ "value": "1785093495000000"
+ },
+ {
+ "id": "0x8",
+ "name": "8",
+ "value": 2
+ },
+ {
+ "id": "0xa",
+ "name": "10",
+ "value": 2
+ },
+ {
+ "id": "0xb",
+ "name": "11",
+ "value": 2
+ },
+ {
+ "id": "0xfff8",
+ "name": "65528",
+ "value": [
+ 3
+ ]
+ },
+ {
+ "id": "0xfff9",
+ "name": "65529",
+ "value": [
+ 0,
+ 2,
+ 4
+ ]
+ },
+ {
+ "id": "0xfffb",
+ "name": "65531",
+ "value": [
+ 0,
+ 1,
+ 5,
+ 6,
+ 7,
+ 8,
+ 10,
+ 11,
+ 65528,
+ 65529,
+ 65531,
+ 65532,
+ 65533
+ ]
+ },
+ {
+ "id": "0xfffc",
+ "name": "65532",
+ "value": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0xfffd",
+ "name": "65533",
+ "value": 2
+ },
+ {
+ "id": "0xfffd",
+ "name": "clusterRevision",
+ "value": 2
+ },
+ {
+ "id": "0xfffc",
+ "name": "featureMap",
+ "value": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0x0",
+ "name": "utcTime",
+ "value": "1785111495000000"
+ },
+ {
+ "id": "0x1",
+ "name": "granularity",
+ "value": 4
+ },
+ {
+ "id": "0x5",
+ "name": "timeZone",
+ "value": [
+ {
+ "offset": -21600,
+ "validAt": "946684800000000",
+ "name": "America/Chicago"
+ }
+ ]
+ },
+ {
+ "id": "0x6",
+ "name": "dstOffset",
+ "value": [
+ {
+ "offset": 3600,
+ "validStarting": "946684800000000",
+ "validUntil": "1793516400000000"
+ },
+ {
+ "offset": 0,
+ "validStarting": "1793516400000000",
+ "validUntil": "1805011200000000"
+ }
+ ]
+ },
+ {
+ "id": "0x7",
+ "name": "localTime",
+ "value": "1785093495000000"
+ },
+ {
+ "id": "0x8",
+ "name": "timeZoneDatabase",
+ "value": 2
+ },
+ {
+ "id": "0xa",
+ "name": "timeZoneListMaxSize",
+ "value": 2
+ },
+ {
+ "id": "0xb",
+ "name": "dstOffsetListMaxSize",
+ "value": 2
+ },
+ {
+ "id": "0xfffb",
+ "name": "attributeList",
+ "value": [
+ 0,
+ 1,
+ 5,
+ 6,
+ 7,
+ 8,
+ 10,
+ 11,
+ 65528,
+ 65529,
+ 65531,
+ 65532,
+ 65533
+ ]
+ },
+ {
+ "id": "0xfff9",
+ "name": "acceptedCommandList",
+ "value": [
+ 0,
+ 2,
+ 4
+ ]
+ },
+ {
+ "id": "0xfff8",
+ "name": "generatedCommandList",
+ "value": [
+ 3
+ ]
+ }
+ ]
+ },
+ "vendorSpecificClusters": []
+}
diff --git a/test/state.test.ts b/test/state.test.ts
new file mode 100644
index 0000000..a80fcfd
--- /dev/null
+++ b/test/state.test.ts
@@ -0,0 +1,103 @@
+import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ EMPTY_NODE_STATE,
+ nodeState,
+ readServiceState,
+ removeNodeState,
+ serviceStatePath,
+ updateNodeState,
+ writeServiceState,
+} from "../src/state.js";
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "mattertimesync-state-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("service state", () => {
+ it("returns empty state when the file does not exist", () => {
+ expect(readServiceState(join(dir, "missing.json"))).toEqual({ nodes: {} });
+ });
+
+ it("round-trips per-node state through disk", () => {
+ const path = serviceStatePath(dir);
+ const state = {
+ nodes: {
+ "12345678901234567890": {
+ lastSuccessfulConnection: "2026-07-26T20:00:00Z",
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ lastError: null,
+ },
+ "2": { ...EMPTY_NODE_STATE, lastError: "unreachable" },
+ },
+ };
+ writeServiceState(path, state);
+ expect(readServiceState(path)).toEqual(state);
+ });
+
+ it("leaves no temporary file behind after writing", () => {
+ const path = serviceStatePath(dir);
+ writeServiceState(path, { nodes: {} });
+ expect(readdirSync(dir)).toEqual(["service-state.json"]);
+ });
+
+ it("returns empty state for a corrupt file instead of throwing", () => {
+ const path = join(dir, "corrupt.json");
+ writeFileSync(path, "{ this is not json");
+ expect(readServiceState(path)).toEqual({ nodes: {} });
+ });
+
+ it("drops malformed node keys and wrongly typed fields", () => {
+ const path = join(dir, "typed.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ nodes: {
+ "not-a-node-id": { lastError: "x" },
+ "7": { lastError: ["array"], lastSuccessfulSync: "2026-07-26T20:00:02Z" },
+ },
+ }),
+ );
+ expect(readServiceState(path)).toEqual({
+ nodes: { "7": { ...EMPTY_NODE_STATE, lastSuccessfulSync: "2026-07-26T20:00:02Z" } },
+ });
+ });
+
+ it("merges patches into one node without touching others", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, { lastSuccessfulSync: "2026-07-26T20:00:02Z" });
+ updateNodeState(path, 1n, { lastAttemptedSync: "2026-07-26T20:00:01Z" });
+
+ const state = readServiceState(path);
+ expect(nodeState(state, 1n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastError: "boom",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ });
+ expect(nodeState(state, 2n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ });
+ });
+
+ it("removes a node's entry", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, {});
+ removeNodeState(path, 1n);
+
+ const state = readServiceState(path);
+ expect(Object.keys(state.nodes)).toEqual(["2"]);
+ expect(nodeState(state, 1n)).toEqual(EMPTY_NODE_STATE);
+ });
+});
diff --git a/test/sync.test.ts b/test/sync.test.ts
new file mode 100644
index 0000000..d48f286
--- /dev/null
+++ b/test/sync.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import {
+ MATTER_EPOCH_UNIX_SECONDS,
+ compareDeviceClock,
+ currentMatterUtcMicroseconds,
+ formatDurationMicros,
+ formatMatterMicros,
+ matterMicrosToUnixMillis,
+ unixMillisToMatterMicros,
+} from "../src/sync.js";
+
+describe("Matter epoch conversion", () => {
+ it("maps 2000-01-01T00:00:00Z to 0 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:00Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(0n);
+ });
+
+ it("maps 2000-01-01T00:00:01Z to 1,000,000 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:01Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(1_000_000n);
+ });
+
+ it("keeps the Unix epoch conversion exact", () => {
+ expect(unixMillisToMatterMicros(0n)).toBe(-MATTER_EPOCH_UNIX_SECONDS * 1_000_000n);
+ expect(unixMillisToMatterMicros(0n)).toBe(-946_684_800_000_000n);
+ });
+
+ it("keeps current dates exact through bigint round-trips", () => {
+ const unixMs = BigInt(Date.parse("2026-07-26T20:00:00.123Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros).toBe((unixMs - 946_684_800_000n) * 1_000n);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("stays exact beyond JavaScript number precision", () => {
+ // Microsecond timestamps pass Number.MAX_SAFE_INTEGER around year 2285;
+ // conversion must not degrade there, proving no number arithmetic occurs.
+ const unixMs = BigInt(Date.parse("2300-01-01T00:00:00.001Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("computes the current time via the injected clock", () => {
+ const nowMs = Date.parse("2026-07-26T20:00:00Z");
+ const micros = currentMatterUtcMicroseconds(() => nowMs);
+ expect(micros).toBe(unixMillisToMatterMicros(BigInt(nowMs)));
+ });
+
+ it("formats Matter microseconds as an ISO instant", () => {
+ expect(formatMatterMicros(0n)).toBe("2000-01-01T00:00:00.000Z");
+ expect(formatMatterMicros(1_000_000n)).toBe("2000-01-01T00:00:01.000Z");
+ });
+});
+
+describe("formatDurationMicros", () => {
+ it("scales units with magnitude", () => {
+ expect(formatDurationMicros(0n)).toBe("0us");
+ expect(formatDurationMicros(999n)).toBe("999us");
+ expect(formatDurationMicros(412_000n)).toBe("412ms");
+ expect(formatDurationMicros(3_200_000n)).toBe("3.2s");
+ expect(formatDurationMicros(59_000_000n)).toBe("59s");
+ expect(formatDurationMicros(125_000_000n)).toBe("2m 5s");
+ expect(formatDurationMicros(3_840_000_000n)).toBe("1h 4m");
+ // 90,061 seconds = 1d 1h 1m 1s; seconds are dropped once days appear.
+ expect(formatDurationMicros(90_061_000_000n)).toBe("1d 1h 1m");
+ });
+
+ it("rejects negative durations", () => {
+ expect(() => formatDurationMicros(-1n)).toThrow();
+ });
+});
+
+describe("compareDeviceClock", () => {
+ // compareDeviceClock operates on Unix-epoch microseconds (matter.js convention).
+ const host = BigInt(Date.parse("2026-07-26T20:00:00.000Z")) * 1_000n;
+
+ it("reports an unset device clock", () => {
+ const comparison = compareDeviceClock(null, host);
+ expect(comparison.deviceTime).toBeNull();
+ expect(comparison.deltaMicroseconds).toBeNull();
+ expect(comparison.hostTime).toBe("2026-07-26T20:00:00.000Z");
+ expect(comparison.description).toBe("device clock was unset");
+ });
+
+ it("reports a device clock behind the host", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.deviceTime).toBe("2026-07-26T19:58:37.000Z");
+ expect(comparison.deltaMicroseconds).toBe(-83_000_000n);
+ expect(comparison.description).toBe("device clock was 1m 23s behind");
+ });
+
+ it("reports a device clock ahead of the host", () => {
+ const comparison = compareDeviceClock(host + 5_500_000n, host);
+ expect(comparison.deltaMicroseconds).toBe(5_500_000n);
+ expect(comparison.description).toBe("device clock was 5.5s ahead");
+ });
+
+ it("treats sub-second deltas as in sync but still reports them", () => {
+ const comparison = compareDeviceClock(host - 412_000n, host);
+ expect(comparison.description).toBe("device clock was within 1s of host time (412ms behind)");
+ });
+
+ it("detects a device that encodes Unix-epoch time on the wire (wrong epoch bug)", () => {
+ // Such firmware decodes to exactly 946,684,800s ahead at the matter.js
+ // API boundary. The raw delta stays exact; the effective delta folds
+ // the shift out.
+ const wrong = host + 946_684_800n * 1_000_000n + 1_400_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.epochShifted).toBe(true);
+ expect(comparison.deltaMicroseconds).toBe(946_684_800_000_000n + 1_400_000n);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(1_400_000n);
+ expect(comparison.deviceTime).toBe("2026-07-26T20:00:01.400Z");
+ expect(comparison.description).toBe(
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was 1.4s ahead",
+ );
+ });
+
+ it("does not fold out large errors that are not the epoch shift", () => {
+ // 30 days past the shift window: a genuinely broken clock, not epoch bug.
+ const wrong = host + 946_684_800n * 1_000_000n + 30n * 86_400n * 1_000_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(comparison.deltaMicroseconds);
+ expect(comparison.description).toContain("ahead");
+ });
+
+ it("keeps spec-compliant comparisons unshifted", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(-83_000_000n);
+ });
+});
diff --git a/test/timesync.test.ts b/test/timesync.test.ts
new file mode 100644
index 0000000..f51369f
--- /dev/null
+++ b/test/timesync.test.ts
@@ -0,0 +1,79 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import type { TimeSyncSummary } from "../src/inspection.js";
+import { compareDeviceClock } from "../src/sync.js";
+import { capabilitiesFromTimeSync, planTimeSync } from "../src/timesync.js";
+
+/**
+ * Tests driven by the captured `inspect --json` output of the real device
+ * (IKEA ALPSTUGA air quality monitor, firmware 1.0.26), per the plan: the
+ * sync behavior is derived from inspected capabilities, not assumptions.
+ */
+
+const fixture = JSON.parse(readFileSync(join(__dirname, "fixtures", "alpstuga-inspect.json"), "utf8")) as {
+ basicInformation: { vendorName: string; productName: string };
+ timeSynchronization: TimeSyncSummary;
+};
+
+describe("alpstuga fixture", () => {
+ it("is the expected device", () => {
+ expect(fixture.basicInformation.vendorName).toBe("IKEA of Sweden");
+ expect(fixture.timeSynchronization).not.toBeNull();
+ });
+
+ it("derives capabilities from the inspection", () => {
+ const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization);
+ expect(capabilities).toEqual({
+ timeZoneFeature: true,
+ supportsSetUtcTime: true,
+ supportsSetTimeZone: true,
+ supportsSetDstOffset: true,
+ timeZoneListMaxSize: 2,
+ dstOffsetListMaxSize: 2,
+ });
+ });
+
+ it("plans UTC + time zone + a 2-entry DST list for this device", () => {
+ const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization);
+ const plan = planTimeSync(capabilities, "America/Chicago", new Date("2026-07-26T00:00:00Z"));
+ expect(plan.setUtcTime).toBe(true);
+ expect(plan.timeZone).toEqual([
+ { offset: -21600, validAt: 946_684_800_000_000n, name: "America/Chicago" },
+ ]);
+ expect(plan.dstOffsets).toHaveLength(2);
+ expect(plan.dstOffsets![0]!.offset).toBe(3600);
+ expect(plan.dstOffsets![1]!.offset).toBe(0);
+ });
+
+ it("reads the fixture's utcTime as healthy Unix-epoch microseconds", () => {
+ // matter.js's TlvEpochUs decodes wire values to Unix-epoch microseconds,
+ // so the fixture's utcTime (read at commissioning, 2026-07-27T00:18:15Z
+ // wall clock) compares cleanly against that instant with no epoch shift.
+ const attribute = fixture.timeSynchronization.attributes.find(a => a.name === "utcTime");
+ const deviceMicros = BigInt(attribute!.value as string);
+ const captureInstant = BigInt(Date.parse("2026-07-27T00:18:20Z")) * 1_000n;
+ const comparison = compareDeviceClock(deviceMicros, captureInstant);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.deviceTime).toBe("2026-07-27T00:18:15.000Z");
+ const effective = comparison.effectiveDeltaMicroseconds!;
+ const magnitude = effective < 0n ? -effective : effective;
+ expect(magnitude < 60n * 1_000_000n).toBe(true);
+ });
+
+ it("degrades to UTC-only when the TimeZone feature is absent", () => {
+ const utcOnly: TimeSyncSummary = {
+ ...fixture.timeSynchronization,
+ supportedFeatures: { ...fixture.timeSynchronization.supportedFeatures, timeZone: false },
+ supportedCommands: {
+ ...fixture.timeSynchronization.supportedCommands,
+ setTimeZone: false,
+ setDstOffset: false,
+ },
+ };
+ const plan = planTimeSync(capabilitiesFromTimeSync(utcOnly), "America/Chicago");
+ expect(plan.setUtcTime).toBe(true);
+ expect(plan.timeZone).toBeNull();
+ expect(plan.dstOffsets).toBeNull();
+ });
+});
diff --git a/test/timezone.test.ts b/test/timezone.test.ts
new file mode 100644
index 0000000..d4ba6d6
--- /dev/null
+++ b/test/timezone.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildDstOffsetList,
+ buildTimeZoneList,
+ formatUtcOffset,
+ isValidTimeZone,
+ nextOffsetTransition,
+ standardOffsetSeconds,
+ utcOffsetSeconds,
+} from "../src/timezone.js";
+
+// All builder timestamps are Unix-epoch microseconds (matter.js TlvEpochUs
+// convention); "since the beginning of time" is Matter epoch zero in Unix us.
+const MATTER_ZERO = 946_684_800_000_000n;
+const FALL_BACK_2026 = BigInt(Date.parse("2026-11-01T07:00:00Z")) * 1_000n;
+const SPRING_FORWARD_2027 = BigInt(Date.parse("2027-03-14T08:00:00Z")) * 1_000n;
+
+describe("isValidTimeZone", () => {
+ it("accepts IANA names", () => {
+ expect(isValidTimeZone("America/Chicago")).toBe(true);
+ expect(isValidTimeZone("Europe/Berlin")).toBe(true);
+ expect(isValidTimeZone("UTC")).toBe(true);
+ });
+
+ it("rejects invalid names", () => {
+ expect(isValidTimeZone("Central Time")).toBe(false);
+ expect(isValidTimeZone("")).toBe(false);
+ expect(isValidTimeZone("America/Springfield")).toBe(false);
+ });
+});
+
+describe("utcOffsetSeconds", () => {
+ it("returns CST offset in January", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600);
+ });
+
+ it("returns CDT offset in July", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600);
+ });
+
+ it("returns 0 for UTC", () => {
+ expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0);
+ });
+
+ it("handles half-hour offsets", () => {
+ expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600);
+ });
+});
+
+describe("formatUtcOffset", () => {
+ it("formats negative, positive, and zero offsets", () => {
+ expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00");
+ expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30");
+ expect(formatUtcOffset(0)).toBe("UTC+00:00");
+ });
+});
+
+describe("nextOffsetTransition", () => {
+ it("finds the exact spring-forward instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-5 * 3600);
+ });
+
+ it("finds the exact fall-back instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-6 * 3600);
+ });
+
+ it("returns null for fixed-offset zones", () => {
+ expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull();
+ });
+});
+
+describe("standardOffsetSeconds", () => {
+ it("returns the non-DST offset regardless of season", () => {
+ expect(standardOffsetSeconds("America/Chicago", new Date("2026-07-15T00:00:00Z"))).toBe(-6 * 3600);
+ expect(standardOffsetSeconds("America/Chicago", new Date("2026-01-15T00:00:00Z"))).toBe(-6 * 3600);
+ });
+
+ it("handles the southern hemisphere", () => {
+ // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer.
+ expect(standardOffsetSeconds("Australia/Sydney", new Date("2026-01-15T00:00:00Z"))).toBe(10 * 3600);
+ });
+
+ it("handles fixed-offset zones", () => {
+ expect(standardOffsetSeconds("UTC", new Date("2026-07-15T00:00:00Z"))).toBe(0);
+ expect(standardOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T00:00:00Z"))).toBe(5.5 * 3600);
+ });
+});
+
+describe("buildTimeZoneList", () => {
+ it("builds a single always-valid entry with the standard offset and IANA name", () => {
+ const list = buildTimeZoneList("America/Chicago", new Date("2026-07-26T00:00:00Z"));
+ expect(list).toEqual([{ offset: -6 * 3600, validAt: MATTER_ZERO, name: "America/Chicago" }]);
+ });
+});
+
+describe("buildDstOffsetList", () => {
+ const from = new Date("2026-07-26T00:00:00Z");
+
+ it("builds the active DST period plus the following one (max 2, as on ALPSTUGA)", () => {
+ expect(buildDstOffsetList("America/Chicago", 2, from)).toEqual([
+ { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 },
+ { offset: 0, validStarting: FALL_BACK_2026, validUntil: SPRING_FORWARD_2027 },
+ ]);
+ });
+
+ it("caps the list at the device's maximum", () => {
+ expect(buildDstOffsetList("America/Chicago", 1, from)).toEqual([
+ { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 },
+ ]);
+ });
+
+ it("emits a single open-ended zero entry for zones without DST", () => {
+ expect(buildDstOffsetList("UTC", 2, from)).toEqual([
+ { offset: 0, validStarting: MATTER_ZERO, validUntil: null },
+ ]);
+ expect(buildDstOffsetList("Asia/Kolkata", 5, from)).toEqual([
+ { offset: 0, validStarting: MATTER_ZERO, validUntil: null },
+ ]);
+ });
+
+ it("starts from standard time when DST is not in effect", () => {
+ const winter = new Date("2026-01-15T00:00:00Z");
+ const springForward2026 = BigInt(Date.parse("2026-03-08T08:00:00Z")) * 1_000n;
+ const list = buildDstOffsetList("America/Chicago", 2, winter);
+ expect(list[0]).toEqual({ offset: 0, validStarting: MATTER_ZERO, validUntil: springForward2026 });
+ expect(list[1]!.offset).toBe(3600);
+ expect(list[1]!.validStarting).toBe(springForward2026);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c68f719
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "types": ["node"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "declaration": false
+ },
+ "include": ["src/**/*.ts"]
+}