From 0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 26 Jul 2026 05:38:33 -0500 Subject: Implement mattertimesync: one-shot Matter time synchronization CLI A standalone CLI Matter controller that joins Matter devices' existing setups as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on matter.js 0.17.6 using the CommissioningController API. One-shot runs from a systemd timer, no daemon: being a secondary controller is fabric membership, not a running process, so each invocation loads the persisted keys, discovers the device via operational DNS-SD, opens a fresh CASE session, does its work, and exits. - TypeScript scaffold: strict tsc, oxlint, prettier, vitest (66 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting, capability planning against the captured device fixture) - Validated JSON configuration with bigint-safe values; unknown fields rejected loudly - Persistent controller fabric with the same identity across restarts. The fabric doubles as the device registry: every node commissioned onto it is kept in sync, and membership changes only through commission and decommission, so no separate device list can drift - On-network multi-admin commissioning from a manual or QR pairing code; any number of devices, one per-device pairing code each. A device already on the fabric rejects re-commissioning via fabric conflict - sync, validated against real hardware (IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home): refuses to run unless the host is NTP-synchronized, then per node reads utcTime, reports the correction with direction ("device clock was 1m 23s behind", handling unset clocks in exact bigint microseconds), writes SetUTCTime, SetTimeZone (standard offset plus IANA name), and SetDSTOffset (transition list bounded by the device's capacity), and verifies by reading the clock back. Capability-driven throughout: UTC-only devices degrade gracefully, cluster-less devices are skipped with a warning. Devices whose firmware encodes Unix-epoch time on the wire are detected by the exact 946684800s shift and reported corrected. Note matter.js's TlvEpochUs API convention: values are Unix-epoch microseconds at the API boundary; the library converts to Matter epoch on the wire - Commands: nodes, inspect (human/JSON; also reads the fabric table fabric-unfiltered, matching our own entry by fabric ID plus root public key and flagging likely-stale orphans; strictly read-only, since this tool never uses its admin rights against other fabrics' entries), status, and decommission (each device drops our fabric while staying paired to its primary ecosystem; targets all devices unless --node narrows it) - Logs (service and matter.js library, plain format) go to stderr; stdout carries only command output, so --json (available on nodes, inspect, sync, status, fabrics) is always clean parseable JSON with 64-bit values as decimal strings. Node's node:sqlite ExperimentalWarning is filtered before matter.js loads - systemd oneshot service (dedicated non-root user, hardening) plus hourly timer with randomized delay - Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB): all runtime deps are pure JavaScript, so the target needs only Node 20+, with no npm, registry access, or build toolchain. The Bun sqlite driver stays external behind its runtime guard and node:sqlite is aliased to a lazy shim so the bundle runs under plain Node. npm pack remains an alternative install path --- .gitignore | 10 + .prettierignore | 3 + .prettierrc.json | 8 + LICENSE | 21 + README.md | 320 ++++ config.example.json | 5 + data/.gitkeep | 0 mattertimesync.service | 15 + mattertimesync.timer | 10 + package-lock.json | 2721 +++++++++++++++++++++++++++++++++++ package.json | 39 + src/cli.ts | 9 + src/commissioning.ts | 101 ++ src/config.ts | 99 ++ src/controller.ts | 102 ++ src/device.ts | 72 + src/fabrics.ts | 111 ++ src/inspection.ts | 267 ++++ src/json.ts | 8 + src/logging.ts | 69 + src/main.ts | 381 +++++ src/sqlite-lazy.ts | 17 + src/state.ts | 90 ++ src/sync.ts | 181 +++ src/timesync.ts | 268 ++++ src/timezone.ts | 190 +++ src/warnings.ts | 19 + test/commissioning.test.ts | 33 + test/config.test.ts | 73 + test/fabrics.test.ts | 46 + test/fixtures/alpstuga-inspect.json | 504 +++++++ test/state.test.ts | 103 ++ test/sync.test.ts | 133 ++ test/timesync.test.ts | 79 + test/timezone.test.ts | 139 ++ tsconfig.json | 21 + 36 files changed, 6267 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.example.json create mode 100644 data/.gitkeep create mode 100644 mattertimesync.service create mode 100644 mattertimesync.timer create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/commissioning.ts create mode 100644 src/config.ts create mode 100644 src/controller.ts create mode 100644 src/device.ts create mode 100644 src/fabrics.ts create mode 100644 src/inspection.ts create mode 100644 src/json.ts create mode 100644 src/logging.ts create mode 100644 src/main.ts create mode 100644 src/sqlite-lazy.ts create mode 100644 src/state.ts create mode 100644 src/sync.ts create mode 100644 src/timesync.ts create mode 100644 src/timezone.ts create mode 100644 src/warnings.ts create mode 100644 test/commissioning.test.ts create mode 100644 test/config.test.ts create mode 100644 test/fabrics.test.ts create mode 100644 test/fixtures/alpstuga-inspect.json create mode 100644 test/state.test.ts create mode 100644 test/sync.test.ts create mode 100644 test/timesync.test.ts create mode 100644 test/timezone.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37c2350 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ + +# Persistent Matter fabric state and credentials must never be committed. +data/* +!data/.gitkeep + +*.log +*.tgz +mattertimesync.mjs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3e0aed8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +package-lock.json +dist/ +data/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e242695 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 110, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "arrowParens": "avoid" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d43b2af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Luke Hoersten + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0387ad0 --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +# mattertimesync + +A small CLI Matter controller that sets the clocks of Matter devices via the standard Time +Synchronization cluster. + +Some Matter devices with clock displays (air-quality monitors, thermostats, sensor hubs) lose their +clock after a power outage, and not every ecosystem restores it reliably. This tool joins each device's +existing Matter setup as an _additional_ controller (Matter multi-admin) and pushes the correct time to +every device commissioned onto its fabric. Run it periodically from a systemd timer; the primary +ecosystem (e.g. Apple Home) keeps working unchanged. + +There is no daemon: each invocation connects, syncs, and exits. + +The main repository is ; 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 +cd mattertimesync +npm install +npm run build +``` + +Run tests and lint: + +```bash +npm test +npm run lint +``` + +## Packaging for deployment + +All runtime dependencies are pure JavaScript (no native modules), so the whole tool bundles into one +self-contained file. The target machine needs only `node` (20 or newer): no npm, no registry access, no +git checkout, no build. + +```bash +npm run bundle # produces mattertimesync.mjs (~4.5 MB) +scp mattertimesync.mjs pi: +ssh pi sudo install -D -m 0755 mattertimesync.mjs /opt/mattertimesync/mattertimesync.mjs +``` + +Run it as `node /opt/mattertimesync/mattertimesync.mjs ...` (the systemd unit does exactly this). +Upgrades replace the one file; Matter state under `/var/lib/mattertimesync` is untouched. + +Alternative, if you prefer a `mattertimesync` command on PATH and don't mind npm on the target: +`npm pack` produces a small tarball whose `npm install -g ` pulls dependencies from the +registry (adjust the unit's `ExecStart` accordingly). + +## Configuration + +The CLI reads `/etc/mattertimesync/config.json` by default; override with `--config `. +See `config.example.json`: + +```json +{ + "storagePath": "/var/lib/mattertimesync", + "timezone": "America/Chicago", + "logLevel": "info" +} +``` + +| Field | Default | Meaning | +| ------------- | ----------------- | --------------------------------------------------------------- | +| `storagePath` | (required) | Directory for persistent Matter fabric state. Contains secrets. | +| `timezone` | `America/Chicago` | IANA time-zone name. Never a fixed UTC offset; DST is derived. | +| `logLevel` | `info` | `debug`, `info`, `warn`, or `error`. | + +Scheduling lives in the systemd timer, and device membership lives in controller storage; neither is in +the configuration. The set of devices kept in sync is exactly the set commissioned onto this +controller's fabric, changed only by `commission` and `decommission`. + +The storage directory holds the controller's private keys and operational certificates. Keep it mode +`0700`, owned by the service user, and out of Git. Deleting it destroys this controller's fabric +identity; the stale fabric would then need to be removed from the device before commissioning again. + +## Commissioning + +1. In the device's primary ecosystem, open its pairing mode (Apple Home: device settings, _Turn On + Pairing Mode_). The ecosystem shows a temporary setup code. +2. Within the pairing window, run (as the service user, so the timer job can reuse the same storage): + +```bash +sudo -u mattertimesync \ + node /opt/mattertimesync/mattertimesync.mjs \ + --config /etc/mattertimesync/config.json \ + commission 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 ] [--json] +``` + +Commands with side effects: + +```bash +mattertimesync commission # writes the device's fabric table + local storage +mattertimesync sync [--node ] [--json] # sets the device's clock, time zone, DST offsets +mattertimesync decommission [--node ] # device(s) drop this controller's fabric +``` + +`--node ` 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 ` 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 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 { + 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; + + // 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 { + 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 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 { + 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 { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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 = { + 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 { + 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; +} + +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; + featureMap: unknown; + supportedCommands: Record; + 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 { + 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(); + 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, + })); + + 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 { + 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 = ``; + } + 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, + 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 ""; + 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 = { debug: 0, info: 1, warn: 2, error: 3 }; +const LEVEL_LABEL: Record = { + 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 ] [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 ] [--json] Connect to a device and dump its live endpoints, clusters, + time-sync capabilities, and fabric table + +Commands with side effects: + commission Join a device as an additional Matter admin + (writes the device's fabric table and local storage) + sync [--node ] [--json] Set each device's clock: UTC time, time zone, DST offsets + decommission [--node ] 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 Configuration file (default: ${DEFAULT_CONFIG_PATH}) + --json Machine-readable output (64-bit values as decimal strings) + --help Show this help +`; + +async function main(): Promise { + 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 { + const pairingCode = codeArgument?.trim(); + if (pairingCode === undefined || pairingCode.length === 0) { + process.stderr.write( + "Usage: commission (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 { + 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 { + 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 { + // 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 { + 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 { + 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; +} + +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; + const nodes: Record = {}; + 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; + 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): 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 { + 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 { + 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"] +} -- cgit v1.2.3