diff options
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" +} @@ -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"] +} |
