diff --git a/README.md b/README.md index e35f6b2..20a1273 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ or speculative writes. Current status: live proof completed on firmware 3.0.2. The Mac provisioned the K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured the scan-correlated point-cloud and pose streams, and decoded both successfully. +The repository also contains a local React control console and a Foxglove live +bridge for the verified point-cloud and trajectory topics. The repository now contains one narrowly gated state-changing command: `ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning @@ -53,6 +55,46 @@ uv run k1link doctor uv run pytest ``` +## Live console and Foxglove + +Build the browser control surface once, then launch the whole local stand from +the repository root: + +```bash +cd apps/k1-viewer +npm install +npm run build +cd ../.. +uv run k1link serve +``` + +Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket +bind to loopback only. The console supports: + +- real CoreBluetooth K1 discovery; +- one operator-triggered reviewed BLE Wi-Fi provisioning write; +- live read-only MQTT capture with raw-first evidence storage; +- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture; +- `/k1/points`, `/k1/pose`, `/k1/trajectory` and `/k1/metrics` for Foxglove; +- measured Mac-side MQTT-receive-to-Foxglove-publish latency and bounded + latest-wins preview dropping. + +For live mode, start the session in the console and use the verified physical +double-click on K1 to start or stop scanning. The connector deliberately does +not publish modeling commands yet. For the recorded proof, replay this ignored +local file at `1x` with loop enabled: + +```text +sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv +``` + +Foxglove remains the full 3D workspace rather than being reimplemented in the +React console. In its 3D panel, select `/k1/points`, color by `intensity` (or +`z`/``) and choose Turbo, Rainbow or a custom gradient. Add +`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the +[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current +limitations. + `doctor` is intentionally non-invasive. It checks the local Python environment and reports external tools; it does not request Bluetooth permission, scan the LAN, touch the K1, alter Homebrew, or change capture permissions. @@ -93,6 +135,7 @@ present. - [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md) - [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md) - [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md) +- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Session manifest schema](schemas/session-manifest.schema.json) - [Reference input provenance](docs/reference/README.md) diff --git a/apps/k1-viewer/.env.example b/apps/k1-viewer/.env.example new file mode 100644 index 0000000..16f2d1d --- /dev/null +++ b/apps/k1-viewer/.env.example @@ -0,0 +1 @@ +VITE_API_TARGET=http://127.0.0.1:8000 diff --git a/apps/k1-viewer/.gitignore b/apps/k1-viewer/.gitignore new file mode 100644 index 0000000..bd22d1f --- /dev/null +++ b/apps/k1-viewer/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.local diff --git a/apps/k1-viewer/README.md b/apps/k1-viewer/README.md new file mode 100644 index 0000000..c7780a6 --- /dev/null +++ b/apps/k1-viewer/README.md @@ -0,0 +1,51 @@ +# K1 Live Console + +React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles +connection setup, source selection, transport status, latency metrics and handoff to +the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder +telemetry. + +## Run locally + +```bash +cd apps/k1-viewer +npm install +npm run dev +``` + +The development server listens on `http://127.0.0.1:5173` and proxies `/api` to +`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a +different backend address. Production artifacts are built with `npm run build`. + +The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE` +checkout through `file:` dependencies. Their source is not copied into this app. + +## Backend contract + +| Method | Route | Body / purpose | +| --- | --- | --- | +| `GET` | `/api/health` | Local service health | +| `GET` | `/api/state` | Authoritative console state | +| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` | +| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` | +| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` | +| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` | +| `POST` | `/api/session/stop` | Stop the active source | +| `WS` | `/api/events` | State snapshots for live UI updates | + +`/api/state` and state-changing responses may return the snapshot directly or as +`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`, +`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`, +`source_mode` and `metrics`. + +The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is +cleared after the backend acknowledges a successful connection. It is never written to +local storage or included in a URL. + +## Explicit unavailable states + +- Missing metrics render as an em dash; no synthetic values are used. +- BLE scanning remains a real API action and reports HTTP/network errors visibly. +- The Foxglove button is disabled until the backend supplies + `foxglove_viewer_url`. +- Replay cannot start without a backend-local capture path. diff --git a/apps/k1-viewer/index.html b/apps/k1-viewer/index.html new file mode 100644 index 0000000..32cbe75 --- /dev/null +++ b/apps/k1-viewer/index.html @@ -0,0 +1,17 @@ + + + + + + + + K1 Live Console ยท NODE.DC + + +
+ + + diff --git a/apps/k1-viewer/package-lock.json b/apps/k1-viewer/package-lock.json new file mode 100644 index 0000000..623fbf6 --- /dev/null +++ b/apps/k1-viewer/package-lock.json @@ -0,0 +1,1891 @@ +{ + "name": "@nodedc/k1-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nodedc/k1-viewer", + "version": "0.1.0", + "dependencies": { + "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "^5.8.3", + "vite": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": { + "name": "@nodedc/tokens", + "version": "0.6.0" + }, + "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": { + "name": "@nodedc/ui-core", + "version": "0.6.0", + "dependencies": { + "@nodedc/tokens": "0.6.0" + } + }, + "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": { + "name": "@nodedc/ui-react", + "version": "0.6.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@nodedc/ui-core": "0.6.0", + "lucide-react": "^0.468.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodedc/tokens": { + "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "link": true + }, + "node_modules/@nodedc/ui-core": { + "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "link": true + }, + "node_modules/@nodedc/ui-react": { + "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "link": true + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/apps/k1-viewer/package.json b/apps/k1-viewer/package.json new file mode 100644 index 0000000..e7777d8 --- /dev/null +++ b/apps/k1-viewer/package.json @@ -0,0 +1,30 @@ +{ + "name": "@nodedc/k1-viewer", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "^5.8.3", + "vite": "^7.0.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/k1-viewer/src/App.tsx b/apps/k1-viewer/src/App.tsx new file mode 100644 index 0000000..17a090b --- /dev/null +++ b/apps/k1-viewer/src/App.tsx @@ -0,0 +1,699 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + AppHeader, + ApplicationShell, + Button, + Checker, + GlassSurface, + HeaderAvatar, + HeaderNavigation, + HeaderProfile, + HeaderProfileButton, + Icon, + SegmentedControl, + StatusBadge, + TextField, + type StatusTone, +} from "@nodedc/ui-react"; + +import type { BleDevice, ConsoleState, K1Metrics } from "./api"; +import { useK1Console, type BackendStatus } from "./useK1Console"; + +type ConsoleSection = "monitor" | "connect" | "session"; +type SessionIntent = "live" | "replay"; + +const sectionItems = [ + { value: "monitor", label: "Monitor" }, + { value: "connect", label: "Connect" }, + { value: "session", label: "Session" }, +] as const; + +const sessionItems = [ + { value: "live", label: "Live scanner" }, + { value: "replay", label: "Replay capture" }, +] satisfies Array<{ value: SessionIntent; label: string }>; + +const phaseLabels: Record = { + idle: "Idle", + scanning: "BLE scan", + device_selected: "Device selected", + provisioning: "Provisioning Wi-Fi", + connecting: "Connecting", + connected: "K1 connected", + starting_live: "Starting live", + live: "Live stream", + replay: "Replay", + stopping: "Stopping", + error: "Error", +}; + +function phaseLabel(phase: string | null | undefined): string { + if (!phase) return "No state"; + return phaseLabels[phase] ?? phase.replaceAll("_", " "); +} + +function phaseTone(phase: string | null | undefined): StatusTone { + if (!phase) return "neutral"; + if (phase === "error") return "danger"; + if (["connected", "live", "replay"].includes(phase)) return "success"; + if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) { + return "accent"; + } + return "neutral"; +} + +function backendLabel(status: BackendStatus): string { + return { + checking: "Checking API", + online: "API online", + degraded: "API degraded", + offline: "API offline", + }[status]; +} + +function backendTone(status: BackendStatus): StatusTone { + if (status === "online") return "success"; + if (status === "degraded" || status === "checking") return "warning"; + return "danger"; +} + +function finiteMetric(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function pipelineLatency(metrics: K1Metrics | undefined): number | null { + if (!metrics) return null; + const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms); + if (direct !== null) return direct; + + const segments = [ + finiteMetric(metrics.mqtt_to_decode_ms), + finiteMetric(metrics.decode_ms), + finiteMetric(metrics.publish_ms), + ].filter((value): value is number => value !== null); + + return segments.length ? segments.reduce((total, value) => total + value, 0) : null; +} + +function formatNumber(value: number | null, digits = 1): string { + if (value === null) return "โ€”"; + return value.toLocaleString("en-US", { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }); +} + +function MetricCard({ + eyebrow, + value, + unit, + detail, + featured = false, +}: { + eyebrow: string; + value: string; + unit?: string; + detail: string; + featured?: boolean; +}) { + return ( + + {eyebrow} +
+ {value} + {unit ? {unit} : null} +
+

{detail}

+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function WizardStep({ + number, + title, + status, + tone = "neutral", + children, +}: { + number: string; + title: string; + status: string; + tone?: StatusTone; + children: ReactNode; +}) { + return ( +
+ +
+
+

{title}

+ {status} +
+ {children} +
+
+ ); +} + +function DeviceRow({ + device, + selected, + onSelect, +}: { + device: BleDevice; + selected: boolean; + onSelect: () => void; +}) { + return ( +
+
+
+
+ {finiteMetric(device.rssi) === null ? "RSSI โ€”" : `${device.rssi} dBm`} + +
+
+ ); +} + +function LatencyTrace({ values }: { values: number[] }) { + const ceiling = Math.max(16, ...values); + + return ( +
+ {values.length ? ( + values.map((value, index) => ( + + )) + ) : ( +

No latency samples yet. The chart remains empty until the backend reports metrics.

+ )} +
+ ); +} + +function FoxglovePanel({ state }: { state: ConsoleState | null }) { + const viewerUrl = state?.foxglove_viewer_url?.trim(); + + return ( + + + } + /> + ); +} diff --git a/apps/k1-viewer/src/api.ts b/apps/k1-viewer/src/api.ts new file mode 100644 index 0000000..54fa3e1 --- /dev/null +++ b/apps/k1-viewer/src/api.ts @@ -0,0 +1,210 @@ +export interface BleDevice { + device_id: string; + name?: string | null; + rssi?: number | null; + address?: string | null; + connectable?: boolean | null; +} + +export type SourceMode = "idle" | "live" | "replay"; + +export interface K1Metrics { + mqtt_to_decode_ms?: number | null; + decode_ms?: number | null; + publish_ms?: number | null; + pipeline_ms?: number | null; + end_to_end_ms?: number | null; + frame_rate?: number | null; + frame_rate_hz?: number | null; + point_count?: number | null; + dropped_preview_frames?: number | null; + [key: string]: number | null | undefined; +} + +export interface ConsoleState { + phase?: string | null; + message?: string | null; + devices?: BleDevice[]; + selected_device_id?: string | null; + k1_ip?: string | null; + foxglove_ws_url?: string | null; + foxglove_viewer_url?: string | null; + source_mode?: SourceMode | null; + metrics?: K1Metrics; +} + +export interface HealthResponse { + ok?: boolean; + status?: string; + service?: string; + version?: string; +} + +export interface ScanRequest { + duration_seconds?: number; +} + +export interface ConnectRequest { + device_id: string; + ssid: string; + password: string; +} + +export interface LiveRequest { + host?: string; + duration_seconds?: number; +} + +export interface ReplayRequest { + path: string; + speed?: number; + loop?: boolean; +} + +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status = 0) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function unwrapState(payload: unknown): ConsoleState { + const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload; + + if (!isRecord(value)) { + throw new ApiError("Backend returned an invalid state snapshot."); + } + + return value as ConsoleState; +} + +async function requestJson(path: string, init?: RequestInit): Promise { + let response: Response; + + try { + response = await fetch(path, { + ...init, + headers: { + Accept: "application/json", + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + } catch (error) { + throw new ApiError( + error instanceof Error + ? `Cannot reach the local K1 API: ${error.message}` + : "Cannot reach the local K1 API.", + ); + } + + const bodyText = await response.text(); + let body: unknown; + + if (bodyText) { + try { + body = JSON.parse(bodyText) as unknown; + } catch { + body = bodyText; + } + } + + if (!response.ok) { + const detail = + isRecord(body) && typeof body.detail === "string" + ? body.detail + : typeof body === "string" && body.trim() + ? body.trim() + : response.statusText; + throw new ApiError( + detail || `K1 API request failed with HTTP ${response.status}.`, + response.status, + ); + } + + return body; +} + +async function postState(path: string, body?: object): Promise { + const payload = await requestJson(path, { + method: "POST", + body: body ? JSON.stringify(body) : undefined, + }); + + if (payload === undefined) { + return api.getState(); + } + + return unwrapState(payload); +} + +export const api = { + async getHealth(): Promise { + const payload = await requestJson("/api/health"); + if (!isRecord(payload)) { + throw new ApiError("Backend returned an invalid health response."); + } + return payload as HealthResponse; + }, + + async getState(): Promise { + return unwrapState(await requestJson("/api/state")); + }, + + scanBle(body: ScanRequest = {}): Promise { + return postState("/api/ble/scan", body); + }, + + connect(body: ConnectRequest): Promise { + return postState("/api/connect", body); + }, + + startLive(body: LiveRequest = {}): Promise { + return postState("/api/session/live", body); + }, + + startReplay(body: ReplayRequest): Promise { + return postState("/api/session/replay", body); + }, + + stopSession(): Promise { + return postState("/api/session/stop"); + }, +}; + +export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; + +function eventSocketUrl(): string { + const url = new URL("/api/events", window.location.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +export function openEventSocket( + onState: (state: ConsoleState) => void, + onStatus: (status: EventSocketStatus) => void, +): () => void { + onStatus("connecting"); + const socket = new WebSocket(eventSocketUrl()); + + socket.addEventListener("open", () => onStatus("open")); + socket.addEventListener("message", (event) => { + try { + const payload = JSON.parse(String(event.data)) as unknown; + onState(unwrapState(payload)); + } catch { + // The REST poll remains authoritative if an unrelated event is received. + } + }); + socket.addEventListener("error", () => onStatus("error")); + socket.addEventListener("close", () => onStatus("closed")); + + return () => socket.close(1000, "K1 console unmounted"); +} diff --git a/apps/k1-viewer/src/main.tsx b/apps/k1-viewer/src/main.tsx new file mode 100644 index 0000000..3608508 --- /dev/null +++ b/apps/k1-viewer/src/main.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { applyNodedcTheme } from "@nodedc/ui-core"; +import "@nodedc/tokens/tokens.css"; +import "@nodedc/tokens/themes.css"; +import "@nodedc/ui-core/styles.css"; + +import App from "./App"; +import "./styles.css"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("K1 console root element is missing."); +} + +rootElement.classList.add("nodedc-ui-root"); +rootElement.dataset.nodedcUi = ""; +applyNodedcTheme(rootElement, { theme: "dark" }); + +createRoot(rootElement).render( + + + , +); diff --git a/apps/k1-viewer/src/styles.css b/apps/k1-viewer/src/styles.css new file mode 100644 index 0000000..42addb2 --- /dev/null +++ b/apps/k1-viewer/src/styles.css @@ -0,0 +1,884 @@ +:root { + background: #050506; +} + +html { + min-width: 320px; + min-height: 100%; + background: #050506; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #050506; +} + +button, +input { + font: inherit; +} + +#root { + min-height: 100vh; +} + +.k1-console { + --k1-panel: #151517; + --k1-panel-soft: #0d0d0f; + --k1-hairline: rgba(255, 255, 255, 0.08); + --k1-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11); + --k1-success-soft: rgb(var(--nodedc-success-rgb) / 0.1); +} + +.brand-lockup { + display: inline-flex; + height: 100%; + align-items: center; + color: var(--nodedc-text-primary); + font-size: 1.32rem; + font-weight: 850; + letter-spacing: -0.075em; + line-height: 1; +} + +.brand-lockup span { + color: rgb(var(--nodedc-accent-rgb)); +} + +.product-label { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.64rem; + font-weight: 800; + letter-spacing: 0.13em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.api-dot { + width: 0.48rem; + height: 0.48rem; + flex: 0 0 0.48rem; + border-radius: 999px; + background: var(--nodedc-text-muted); +} + +.api-dot[data-status="online"] { + background: rgb(var(--nodedc-success-rgb)); + box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09); +} + +.api-dot[data-status="checking"], +.api-dot[data-status="degraded"] { + background: rgb(var(--nodedc-warning-rgb)); +} + +.api-dot[data-status="offline"] { + background: rgb(var(--nodedc-danger-rgb)); +} + +.console-stage { + height: 100%; + overflow: auto; + overscroll-behavior: contain; + scrollbar-color: var(--nodedc-scrollbar-thumb) transparent; +} + +.console-canvas { + position: relative; + width: min(100%, 112rem); + min-height: 100%; + margin: 0 auto; + padding: 0 0 2.75rem; +} + +.console-canvas::before { + position: absolute; + z-index: 0; + top: -8rem; + right: -8rem; + width: 34rem; + height: 34rem; + border-radius: 50%; + background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 66%); + content: ""; + pointer-events: none; +} + +.console-canvas > * { + position: relative; + z-index: 1; +} + +.error-banner { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.9rem; + margin-bottom: 1.25rem; + border-radius: 1.1rem; + background: rgb(var(--nodedc-danger-rgb) / 0.1); + color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 82%, white); + padding: 0.85rem 1rem; +} + +.error-banner > svg { + align-self: start; + margin-top: 0.12rem; +} + +.error-banner strong, +.error-banner p { + margin: 0; +} + +.error-banner strong { + color: var(--nodedc-text-primary); + font-size: 0.8rem; +} + +.error-banner p { + margin-top: 0.2rem; + color: var(--nodedc-text-secondary); + font-size: 0.74rem; + line-height: 1.45; +} + +.error-banner__actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.console-hero { + display: flex; + min-height: 15.5rem; + align-items: flex-end; + justify-content: space-between; + gap: 3rem; + overflow: hidden; + scroll-margin-top: 1rem; + border-radius: var(--nodedc-radius-card); + background: + linear-gradient(112deg, rgba(255, 255, 255, 0.048), transparent 44%), + radial-gradient(circle at 79% 16%, rgb(var(--nodedc-accent-rgb) / 0.12), transparent 34%), + #0b0b0d; + padding: clamp(1.6rem, 3vw, 3rem); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.console-hero__copy { + max-width: 46rem; +} + +.section-eyebrow, +.metric-card__eyebrow { + display: block; + color: var(--nodedc-text-muted); + font-size: 0.64rem; + font-weight: 820; + letter-spacing: 0.12em; + line-height: 1.2; +} + +.console-hero h1 { + max-width: 42rem; + margin: 0.65rem 0 0.9rem; + font-size: clamp(2rem, 4vw, 4.2rem); + font-weight: 710; + letter-spacing: -0.055em; + line-height: 0.98; +} + +.console-hero__copy p { + max-width: 39rem; + margin: 0; + color: var(--nodedc-text-secondary); + font-size: 0.86rem; + line-height: 1.55; +} + +.console-hero__status { + display: grid; + max-width: 21rem; + justify-items: end; + gap: 0.75rem; + color: var(--nodedc-text-muted); + font-size: 0.74rem; + line-height: 1.45; + text-align: right; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.metric-card { + min-height: 9.3rem; +} + +.metric-card[data-featured="true"] { + background: linear-gradient(145deg, var(--k1-accent-soft), transparent 72%), var(--k1-panel); +} + +.metric-card__reading { + display: flex; + align-items: baseline; + gap: 0.45rem; + margin-top: 1.15rem; +} + +.metric-card__reading strong { + font-size: clamp(1.75rem, 2.6vw, 2.7rem); + font-weight: 690; + letter-spacing: -0.055em; + line-height: 1; +} + +.metric-card__reading span { + color: var(--nodedc-text-muted); + font-size: 0.76rem; + font-weight: 700; +} + +.metric-card p { + margin: 0.9rem 0 0; + color: var(--nodedc-text-muted); + font-size: 0.7rem; + line-height: 1.35; +} + +.console-layout { + display: grid; + grid-template-columns: minmax(21rem, 28.5rem) minmax(0, 1fr); + align-items: start; + gap: 1.25rem; + margin-top: 1.25rem; +} + +.connection-panel, +.session-panel { + scroll-margin-top: 1rem; +} + +.connection-panel { + background: var(--k1-panel); +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.panel-heading h2 { + margin: 0.42rem 0 0; + font-size: 1.3rem; + font-weight: 730; + letter-spacing: -0.035em; + line-height: 1.1; +} + +.panel-heading--compact { + align-items: center; +} + +.wizard-list { + display: grid; + margin-top: 1.5rem; +} + +.wizard-step { + display: grid; + grid-template-columns: 2.25rem minmax(0, 1fr); + gap: 0.8rem; +} + +.wizard-step__rail { + position: relative; + display: flex; + justify-content: center; +} + +.wizard-step__rail::after { + position: absolute; + top: 2rem; + bottom: 0; + left: 50%; + width: 1px; + background: var(--k1-hairline); + content: ""; +} + +.wizard-step:last-child .wizard-step__rail::after { + display: none; +} + +.wizard-step__rail span { + position: relative; + z-index: 1; + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 999px; + background: #27272a; + color: var(--nodedc-text-secondary); + font-size: 0.62rem; + font-weight: 800; +} + +.wizard-step__content { + min-width: 0; + padding: 0.1rem 0 1.6rem; +} + +.wizard-step:last-child .wizard-step__content { + padding-bottom: 0; +} + +.wizard-step__content > header { + display: flex; + min-height: 2rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.8rem; +} + +.wizard-step__content h3 { + margin: 0; + font-size: 0.86rem; + font-weight: 720; +} + +.step-copy, +.safety-note { + margin: 0 0 0.75rem; + color: var(--nodedc-text-muted); + font-size: 0.69rem; + line-height: 1.45; +} + +.safety-note { + margin: 0.7rem 0 0; +} + +.device-list { + display: grid; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.device-row { + display: grid; + gap: 0.65rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.035); + padding: 0.75rem; +} + +.device-row[data-selected="true"] { + background: var(--k1-accent-soft); + box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18); +} + +.device-row__identity, +.device-row__action { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.7rem; +} + +.device-row__identity { + justify-content: flex-start; +} + +.device-row__identity > div { + display: grid; + min-width: 0; + gap: 0.2rem; +} + +.device-row__identity strong { + overflow: hidden; + font-size: 0.74rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-row code, +.detail-row code { + overflow: hidden; + color: var(--nodedc-text-muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-row__signal { + width: 0.6rem; + height: 0.6rem; + flex: 0 0 0.6rem; + border-radius: 50%; + background: rgb(var(--nodedc-success-rgb)); + box-shadow: 0 0 0 0.25rem var(--k1-success-soft); +} + +.device-row__action > span { + color: var(--nodedc-text-muted); + font-size: 0.66rem; +} + +.empty-device-list { + border-radius: 1rem; + background: rgba(255, 255, 255, 0.025); + color: var(--nodedc-text-muted); + padding: 0.95rem; + font-size: 0.69rem; + line-height: 1.45; +} + +.field-stack { + display: grid; + gap: 0.9rem; +} + +.connection-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + border-radius: 0.9rem; + background: rgba(255, 255, 255, 0.03); + padding: 0.65rem 0.8rem; + font-size: 0.69rem; +} + +.connection-summary span { + color: var(--nodedc-text-muted); +} + +.connection-summary strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monitor-column { + display: grid; + min-width: 0; + gap: 1.25rem; +} + +.foxglove-panel { + position: relative; + display: grid; + min-height: 20rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + overflow: hidden; + background: #0b0b0e; +} + +.foxglove-panel__grid { + position: absolute; + inset: 0; + opacity: 0.36; + background-image: + linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px); + background-position: center; + background-size: 3rem 3rem; + mask-image: radial-gradient(circle at 58% 48%, #000 0%, transparent 66%); + transform: perspective(32rem) rotateX(56deg) scale(1.42) translateY(18%); +} + +.foxglove-panel::after { + position: absolute; + top: 16%; + right: 14%; + width: 13rem; + height: 13rem; + border-radius: 50%; + background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 68%); + content: ""; + pointer-events: none; +} + +.foxglove-panel__content, +.foxglove-panel__action { + position: relative; + z-index: 1; +} + +.foxglove-panel__content { + max-width: 37rem; +} + +.foxglove-panel h2 { + margin: 0.5rem 0 0.7rem; + font-size: clamp(1.6rem, 3vw, 3rem); + font-weight: 690; + letter-spacing: -0.05em; + line-height: 1; +} + +.foxglove-panel p { + max-width: 33rem; + margin: 0; + color: var(--nodedc-text-secondary); + font-size: 0.78rem; + line-height: 1.55; +} + +.topic-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.2rem; +} + +.topic-list code { + border-radius: 999px; + background: rgba(255, 255, 255, 0.055); + color: var(--nodedc-text-secondary); + padding: 0.42rem 0.7rem; + font-size: 0.64rem; +} + +.foxglove-panel__action { + display: grid; + justify-items: end; + gap: 0.75rem; +} + +.monitor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; +} + +.status-panel, +.latency-panel, +.session-panel { + background: var(--k1-panel); +} + +.detail-list { + display: grid; + gap: 0; + margin: 1rem 0 0; +} + +.detail-row { + display: grid; + min-width: 0; + grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr); + gap: 1rem; + border-top: 1px solid var(--k1-hairline); + padding: 0.75rem 0; +} + +.detail-row dt, +.detail-row dd { + min-width: 0; + margin: 0; + font-size: 0.7rem; +} + +.detail-row dt { + color: var(--nodedc-text-muted); +} + +.detail-row dd { + overflow: hidden; + color: var(--nodedc-text-secondary); + text-align: right; + text-overflow: ellipsis; + text-transform: capitalize; + white-space: nowrap; +} + +.inline-state { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.inline-state::before { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: var(--nodedc-text-muted); + content: ""; +} + +.inline-state[data-state="open"]::before { + background: rgb(var(--nodedc-success-rgb)); +} + +.inline-state[data-state="error"]::before, +.inline-state[data-state="closed"]::before { + background: rgb(var(--nodedc-danger-rgb)); +} + +.latency-now { + color: var(--nodedc-text-primary); + font-size: 1.35rem; + font-weight: 680; + letter-spacing: -0.04em; +} + +.latency-now span { + color: var(--nodedc-text-muted); + font-size: 0.66rem; + letter-spacing: 0; +} + +.latency-trace { + display: flex; + height: 8.4rem; + align-items: end; + gap: 0.22rem; + margin-top: 1.25rem; + border-radius: 0.9rem; + background: + linear-gradient(to top, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + rgba(255, 255, 255, 0.018); + background-size: 100% 25%; + padding: 0.75rem; +} + +.latency-trace span { + min-width: 0.18rem; + flex: 1 1 0; + border-radius: 999px 999px 0.14rem 0.14rem; + background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb))); +} + +.latency-trace p { + align-self: center; + margin: auto; + color: var(--nodedc-text-muted); + font-size: 0.68rem; + line-height: 1.45; + text-align: center; +} + +.latency-legend { + display: flex; + justify-content: space-between; + margin-top: 0.4rem; + color: var(--nodedc-text-muted); + font-size: 0.58rem; +} + +.session-panel > .nodedc-segmented { + margin-top: 1.4rem; +} + +.session-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.85rem; + margin-top: 1rem; +} + +.session-form--replay { + grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto; +} + +.session-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1.25rem; + border-top: 1px solid var(--k1-hairline); + padding-top: 1rem; +} + +.session-footer p { + max-width: 42rem; + margin: 0; + color: var(--nodedc-text-muted); + font-size: 0.68rem; + line-height: 1.5; +} + +@media (max-width: 1320px) { + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .console-layout { + grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr); + } + + .monitor-grid { + grid-template-columns: 1fr; + } + + .session-form--replay { + grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.3fr); + } + + .session-form--replay > :nth-child(3), + .session-form--replay > :nth-child(4) { + grid-column: span 1; + } +} + +@media (max-width: 980px) { + .console-layout { + grid-template-columns: 1fr; + } + + .connection-panel { + order: 2; + } + + .monitor-column { + order: 1; + } + + .foxglove-panel { + min-height: 18rem; + } +} + +@media (max-width: 760px) { + .product-label { + display: none; + } + + .console-canvas { + padding-bottom: 1.5rem; + } + + .error-banner { + grid-template-columns: auto minmax(0, 1fr); + } + + .error-banner__actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .console-hero { + min-height: 20rem; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 1.4rem; + } + + .console-hero h1 { + font-size: 2.5rem; + } + + .console-hero__status { + max-width: none; + justify-items: start; + text-align: left; + } + + .metrics-grid { + grid-template-columns: 1fr 1fr; + gap: 0.7rem; + margin-top: 0.7rem; + } + + .metric-card { + min-height: 8.2rem; + } + + .console-layout, + .monitor-column, + .monitor-grid { + gap: 0.7rem; + margin-top: 0.7rem; + } + + .foxglove-panel { + grid-template-columns: 1fr; + gap: 2rem; + } + + .foxglove-panel__action { + justify-items: start; + } + + .session-form, + .session-form--replay { + grid-template-columns: 1fr; + } + + .session-form--replay > :nth-child(3), + .session-form--replay > :nth-child(4) { + grid-column: auto; + } + + .session-footer { + align-items: stretch; + flex-direction: column; + } +} + +@media (max-width: 480px) { + .metrics-grid { + grid-template-columns: 1fr; + } + + .panel-heading { + align-items: flex-start; + flex-direction: column; + } + + .wizard-step { + grid-template-columns: 1.8rem minmax(0, 1fr); + gap: 0.55rem; + } + + .wizard-step__rail span { + width: 1.65rem; + height: 1.65rem; + } + + .wizard-step__rail::after { + top: 1.65rem; + } + + .wizard-step__content > header { + align-items: flex-start; + flex-direction: column; + } + + .session-panel > .nodedc-segmented { + display: grid; + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/k1-viewer/src/useK1Console.ts b/apps/k1-viewer/src/useK1Console.ts new file mode 100644 index 0000000..9d446da --- /dev/null +++ b/apps/k1-viewer/src/useK1Console.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + ApiError, + api, + openEventSocket, + type ConnectRequest, + type ConsoleState, + type EventSocketStatus, + type LiveRequest, + type ReplayRequest, +} from "./api"; + +export type BackendStatus = "checking" | "online" | "degraded" | "offline"; +export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop"; + +function messageFor(error: unknown): string { + if (error instanceof ApiError) { + return error.status + ? `${error.message} (HTTP ${error.status})` + : error.message; + } + return error instanceof Error ? error.message : "The local K1 API request failed."; +} + +function measuredLatency(state: ConsoleState | null): number | null { + const metrics = state?.metrics; + if (!metrics) return null; + + const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms; + if (typeof reported === "number" && Number.isFinite(reported)) return reported; + + const segments = [ + metrics.mqtt_to_decode_ms, + metrics.decode_ms, + metrics.publish_ms, + ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + + return segments.length ? segments.reduce((total, value) => total + value, 0) : null; +} + +export function useK1Console() { + const [state, setState] = useState(null); + const [backendStatus, setBackendStatus] = useState("checking"); + const [eventStatus, setEventStatus] = useState("connecting"); + const [pendingAction, setPendingAction] = useState(null); + const [error, setError] = useState(null); + const [latencyHistory, setLatencyHistory] = useState([]); + const mounted = useRef(true); + + const acceptState = useCallback((nextState: ConsoleState) => { + setState(nextState); + setBackendStatus("online"); + }, []); + + const refresh = useCallback(async (reportErrors = true) => { + const [healthResult, stateResult] = await Promise.allSettled([ + api.getHealth(), + api.getState(), + ]); + + if (!mounted.current) return; + + if (stateResult.status === "fulfilled") { + acceptState(stateResult.value); + if (reportErrors) setError(null); + } + + if (healthResult.status === "fulfilled") { + const health = healthResult.value; + const healthy = health.ok !== false && health.status !== "error"; + setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded"); + } else if (stateResult.status === "rejected") { + setBackendStatus("offline"); + } + + if (stateResult.status === "rejected" && reportErrors) { + setError(messageFor(stateResult.reason)); + } + }, [acceptState]); + + const run = useCallback( + async (action: PendingAction, operation: () => Promise) => { + setPendingAction(action); + setError(null); + + try { + const nextState = await operation(); + if (mounted.current) acceptState(nextState); + return true; + } catch (operationError) { + if (mounted.current) { + setError(messageFor(operationError)); + if (operationError instanceof ApiError && operationError.status === 0) { + setBackendStatus("offline"); + } + } + return false; + } finally { + if (mounted.current) setPendingAction(null); + } + }, + [acceptState], + ); + + const scan = useCallback( + () => run("scan", () => api.scanBle({ duration_seconds: 6 })), + [run], + ); + + const connect = useCallback( + (request: ConnectRequest) => run("connect", () => api.connect(request)), + [run], + ); + + const startLive = useCallback( + (request: LiveRequest = {}) => run("live", () => api.startLive(request)), + [run], + ); + + const startReplay = useCallback( + (request: ReplayRequest) => run("replay", () => api.startReplay(request)), + [run], + ); + + const stop = useCallback( + () => run("stop", () => api.stopSession()), + [run], + ); + + useEffect(() => { + mounted.current = true; + void refresh(true); + const poll = window.setInterval(() => void refresh(false), 4_000); + + return () => { + mounted.current = false; + window.clearInterval(poll); + }; + }, [refresh]); + + useEffect(() => { + let dispose: (() => void) | undefined; + let retry: number | undefined; + let cancelled = false; + + const connectEvents = () => { + if (cancelled) return; + dispose = openEventSocket(acceptState, (status) => { + if (cancelled) return; + setEventStatus(status); + if ((status === "closed" || status === "error") && retry === undefined) { + retry = window.setTimeout(() => { + retry = undefined; + connectEvents(); + }, 3_000); + } + }); + }; + + connectEvents(); + + return () => { + cancelled = true; + if (retry !== undefined) window.clearTimeout(retry); + dispose?.(); + }; + }, [acceptState]); + + useEffect(() => { + const latency = measuredLatency(state); + if (latency === null) return; + setLatencyHistory((values) => [...values.slice(-23), latency]); + }, [state]); + + return { + state, + backendStatus, + eventStatus, + pendingAction, + error, + latencyHistory, + refresh: () => refresh(true), + clearError: () => setError(null), + scan, + connect, + startLive, + startReplay, + stop, + }; +} diff --git a/apps/k1-viewer/tsconfig.app.json b/apps/k1-viewer/tsconfig.app.json new file mode 100644 index 0000000..a5701b6 --- /dev/null +++ b/apps/k1-viewer/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/k1-viewer/tsconfig.json b/apps/k1-viewer/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/k1-viewer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/k1-viewer/tsconfig.node.json b/apps/k1-viewer/tsconfig.node.json new file mode 100644 index 0000000..3950b4b --- /dev/null +++ b/apps/k1-viewer/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/k1-viewer/vite.config.ts b/apps/k1-viewer/vite.config.ts new file mode 100644 index 0000000..ecab318 --- /dev/null +++ b/apps/k1-viewer/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000"; + + return { + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + proxy: { + "/api": { + target: apiTarget, + changeOrigin: false, + ws: true, + }, + }, + }, + preview: { + host: "127.0.0.1", + port: 4173, + strictPort: true, + }, + }; +}); diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index 2d66620..b34570f 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -16,11 +16,17 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result. | Stage 5 point cloud | GO โ€” raw-LZ4 protobuf, 1,140 live frames decoded | | Stage 5 pose | GO โ€” 1,215 live frames decoded and motion-correlated | | Stage 5 camera | PAUSE โ€” no independent frame/video stream observed | +| Stage 6 live viewer | GO โ€” React console, Foxglove cloud/path and Mac latency metrics | USB project copying remains optional ground truth rather than a blocker for the now-verified network path. MQTT control publishing remains deliberately deferred because the physical button is a known-safe start/stop mechanism. +The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization +queue cannot discard MQTT evidence. Acceptance is replay of the full captured +scan followed by a live ideal-LAN run with measured host pipeline latency. +Sensor-to-display latency remains a separate clock-correlation test. + ## Stage 0 โ€” repository and host baseline Deliverables: diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md new file mode 100644 index 0000000..feec444 --- /dev/null +++ b/docs/06_K1_LIVE_VIEWER.md @@ -0,0 +1,149 @@ +# K1 live console and Foxglove bridge + +Status: alpha implementation for the verified firmware-3 MQTT streams. It uses +real K1 data and does not generate a placeholder cloud, pose or latency value. + +## Architecture + +```text +K1 lio_pcl / lio_pose + | + v +read-only MQTT subscription on TCP 1883 + | + +--> raw .k1mqtt + JSONL + SHA-256 summary (first) + | + v +bounded latest-wins preview queue (32 messages) + | + v +raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate + | + v +ws://127.0.0.1:8765 --> Foxglove 3D + +React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000 +``` + +The Paho MQTT callback never decodes the point cloud. It writes and flushes the +raw frame and metadata, then enqueues a preview reference. If visualization cannot +keep up, the oldest queued preview is discarded while the raw capture continues. + +## Start the local application + +Prerequisites are the repository-local Python environment and Node.js 20 or +newer. No global Python package or system component is installed. + +```bash +uv sync --group dev +cd apps/k1-viewer +npm install +npm run build +cd ../.. +uv run k1link serve +``` + +Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind +option because its provisioning endpoint temporarily receives a Wi-Fi password. +The password is accepted only in the POST body, is never logged or persisted by +the connector, and is cleared from the React form after success. + +## Replay the captured proof + +1. In **Session**, select **Replay capture**. +2. Enter the repository-relative path: + + ```text + sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv + ``` + +3. Use speed `1` and enable loop for initial viewer setup. +4. Start replay, then select **Open Foxglove 3D**. +5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and + `/k1/trajectory`. +6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow + colormap or custom two-color gradient. Foxglove can also color by `x`, `y`, + `z` and its derived ``. + +The reviewed TSV reader accepts exactly four columns: receive timestamp, topic, +declared payload length and hex payload. It verifies timestamp, UTF-8 topic, +declared length, hex encoding and allocation bounds. Native +`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata +timestamps when present. + +## Connect and stream live + +1. Power K1 to its normal steady-green standby state. +2. Confirm the manual power checklist in **Connect**. +3. Run the real six-second BLE scan and select the K1 candidate. +4. Enter the existing router SSID/password and press **Provision Wi-Fi & + connect**. That button is the explicit authorization for one reviewed 99-byte + provisioning write; the backend never retries automatically. +5. When K1 reports a non-AP private address, press **Start live stream**. +6. Open Foxglove before scanning if convenient. +7. Double-click the physical K1 button to start scanning. Double-click again to + stop, wait for the LED to return to steady green, then stop the local session. + +Each live run creates an ignored `sessions/_viewer_live/` directory with a +redacted manifest, operator notes, raw MQTT frames, per-message metadata and a +hash summary. No application request topic is published. + +## Published topics + +| Topic | Foxglove schema | Meaning | +| --- | --- | --- | +| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 | +| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame | +| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing | +| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops | + +Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified. +No axis swap, quaternion normalization or vehicle extrinsic is silently applied. +Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes +as RGB remains unverified. + +## Latency semantics + +`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock +from MQTT callback receipt through raw disk write, bounded queue wait, decode, +packing and Foxglove channel publish. Rolling p50/p95 values use the last 512 +published decoded messages. + +This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency. +The K1 header timestamp epoch has not been proven, Foxglove rendering time is +outside the Python publisher, and display latency is not observable without a +clock-correlated device timestamp or high-speed-camera experiment. Foxglove +message time therefore uses host receive Unix time while durations use +`monotonic_ns`. + +## Current boundaries + +- Raw panoramic camera frames were not present on the observed MQTT report + topics; this milestone intentionally ships point cloud plus trajectory only. +- Physical double-click remains the start/stop control. A future MQTT modeling + publisher needs a separately reviewed state-changing profile. +- The React application opens the official Foxglove viewer over a local + WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove + account/seat terms apply to that viewer independently of this connector. +- Exact path axes and a scanner-to-vehicle transform must be calibrated before + mounting on the unmanned platform. + +## Verification checkpoint โ€” 2026-07-15 + +The full captured 180-second scan was passed through the production Foxglove +bridge without a viewer-side substitute: + +| Check | Result | +| --- | ---: | +| MQTT messages read | 2,836 | +| `lio_pcl` frames published | 1,140 | +| `lio_pose` frames published | 1,215 | +| points packed and published | 4,165,862 | +| decoder errors | 0 | +| final trajectory poses | 1,215 | + +The same replay was then started through the FastAPI/React contract at `10x`. +The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero +preview-drop accounting under deliberate acceleration, and released TCP 8765 +after the stop request. This validates replay, overload behavior and lifecycle; +the next checkpoint is the powered K1 ideal-LAN live run. diff --git a/pyproject.toml b/pyproject.toml index a6b9326..21923c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,13 @@ license = { text = "Proprietary" } authors = [{ name = "NODE.DC" }] dependencies = [ "bleak==3.0.2", + "fastapi>=0.116,<1", + "foxglove-sdk==0.25.3", "lz4>=4.4,<5", "paho-mqtt>=2.1,<3", "rich>=13.9,<15", "typer>=0.15,<1", + "uvicorn>=0.35,<1", ] [project.scripts] diff --git a/src/k1link/cli.py b/src/k1link/cli.py index 7af5270..5eb1897 100644 --- a/src/k1link/cli.py +++ b/src/k1link/cli.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Annotated, TypedDict import typer +import uvicorn from bleak.exc import BleakError from rich.console import Console from rich.table import Table @@ -200,6 +201,32 @@ def doctor( console.print(f"- {note}") +@app.command("serve") +def serve_console( + port: Annotated[ + int, + typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."), + ] = 8000, +) -> None: + """Serve the built K1 console and local-only control API on loopback.""" + frontend = Path(__file__).resolve().parents[2] / "apps" / "k1-viewer" / "dist" + if not frontend.is_dir(): + console.print( + "[red]Frontend build is missing.[/red] Run npm install && npm run build " + "inside apps/k1-viewer." + ) + raise typer.Exit(code=2) + console.print(f"K1 Live Console: http://127.0.0.1:{port}") + console.print("The credential endpoint is bound to this Mac only.") + uvicorn.run( + "k1link.web.app:app", + host="127.0.0.1", + port=port, + log_level="info", + access_log=True, + ) + + @ble_app.command("scan") def ble_scan( out: Annotated[ @@ -300,8 +327,7 @@ def ble_wifi_configure( raise typer.Exit(code=2) if not confirm_write: console.print( - "[red]Write not confirmed.[/red] " - "Add --confirm-write after reviewing the profile." + "[red]Write not confirmed.[/red] Add --confirm-write after reviewing the profile." ) raise typer.Exit(code=2) diff --git a/src/k1link/mqtt/__init__.py b/src/k1link/mqtt/__init__.py index 76c4a78..89afba8 100644 --- a/src/k1link/mqtt/__init__.py +++ b/src/k1link/mqtt/__init__.py @@ -4,6 +4,7 @@ from k1link.mqtt.capture import ( DEFAULT_MAX_MESSAGE_BYTES, MAX_CONFIGURABLE_MESSAGE_BYTES, REPORT_TOPICS, + CapturedMqttMessage, CaptureError, CaptureFormatError, CaptureFrame, @@ -21,6 +22,7 @@ __all__ = [ "CaptureFormatError", "CaptureFrame", "CaptureSummary", + "CapturedMqttMessage", "capture_mqtt", "iter_capture_frames", "validate_private_ipv4", diff --git a/src/k1link/mqtt/capture.py b/src/k1link/mqtt/capture.py index 2c36ce1..6f10282 100644 --- a/src/k1link/mqtt/capture.py +++ b/src/k1link/mqtt/capture.py @@ -44,6 +44,7 @@ _PRIVATE_NETWORKS = tuple( StopReason = Literal[ "duration_elapsed", + "external_stop", "keyboard_interrupt", "message_too_large", "connection_failed", @@ -127,6 +128,21 @@ class CaptureFrame: raw_frame_bytes: int +@dataclass(frozen=True, slots=True) +class CapturedMqttMessage: + """A message made durable by the raw writer and ready for live preview.""" + + sequence: int + topic: str + payload: bytes + qos: int + retain: bool + dup: bool + received_at_utc: str + received_at_epoch_ns: int + received_monotonic_ns: int + + @dataclass class _CaptureState: connected: bool = False @@ -168,19 +184,19 @@ class _CaptureWriter: self.close() raise - def record(self, message: mqtt.MQTTMessage) -> None: + def record(self, message: mqtt.MQTTMessage) -> CapturedMqttMessage: raw = self._require_raw() metadata = self._require_metadata() topic = message.topic topic_bytes = topic.encode("utf-8") payload = message.payload received_at_utc = utc_now_iso() + received_at_epoch_ns = time.time_ns() received_monotonic_ns = time.monotonic_ns() if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES: raise ValueError( - f"incoming MQTT topic is {len(topic_bytes)} bytes; " - f"expected 1..{MAX_TOPIC_BYTES}" + f"incoming MQTT topic is {len(topic_bytes)} bytes; expected 1..{MAX_TOPIC_BYTES}" ) if len(payload) > self.max_message_bytes: @@ -218,6 +234,7 @@ class _CaptureWriter: "record_type": "message", "sequence": self.message_count, "received_at_utc": received_at_utc, + "received_at_epoch_ns": received_at_epoch_ns, "received_monotonic_ns": received_monotonic_ns, "topic": topic, "qos": message.qos, @@ -230,6 +247,17 @@ class _CaptureWriter: "raw_frame_bytes": frame_bytes, } self._write_metadata(metadata, record) + return CapturedMqttMessage( + sequence=self.message_count, + topic=topic, + payload=payload, + qos=message.qos, + retain=message.retain, + dup=message.dup, + received_at_utc=received_at_utc, + received_at_epoch_ns=received_at_epoch_ns, + received_monotonic_ns=received_monotonic_ns, + ) def close(self) -> None: first_error: OSError | None = None @@ -296,8 +324,7 @@ def iter_capture_frames( """Yield validated frames from a K1 MQTT raw capture without decoding payloads.""" if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES: raise ValueError( - "max_payload_bytes must be between 1 and " - f"{MAX_CONFIGURABLE_MESSAGE_BYTES}" + f"max_payload_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}" ) if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES: raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}") @@ -367,6 +394,8 @@ def capture_mqtt( duration_seconds: float = 60.0, max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES, on_ready: Callable[[], None] | None = None, + on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None, + should_stop: Callable[[], bool] | None = None, _client_factory: Callable[[], mqtt.Client] | None = None, ) -> CaptureSummary: """Capture the fixed K1 report subscriptions once, without publishing or reconnecting.""" @@ -377,8 +406,7 @@ def capture_mqtt( raise ValueError("duration_seconds must be finite and greater than zero") if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES: raise ValueError( - "max_message_bytes must be between 1 and " - f"{MAX_CONFIGURABLE_MESSAGE_BYTES}" + f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}" ) client = ( @@ -453,11 +481,17 @@ def capture_mqtt( if state.error is not None: return try: - writer.record(message) + recorded = writer.record(message) except MessageTooLargeError as exc: fail("message_too_large", str(exc)) except (OSError, RuntimeError, ValueError) as exc: fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}") + return + if on_message_recorded is not None: + try: + on_message_recorded(recorded) + except (OSError, RuntimeError, ValueError) as exc: + fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}") def on_disconnect( _callback_client: mqtt.Client, @@ -487,6 +521,9 @@ def capture_mqtt( while state.error is None: now = time.monotonic() + if should_stop is not None and should_stop(): + state.stop_reason = "external_stop" + break if state.subscribed and capture_started is None: capture_started = now if on_ready is not None: @@ -523,9 +560,7 @@ def capture_mqtt( fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}") operation_completed = time.monotonic() - capture_elapsed = ( - 0.0 if capture_started is None else operation_completed - capture_started - ) + capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started summary = _build_summary( writer=writer, diff --git a/src/k1link/py.typed b/src/k1link/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/k1link/py.typed @@ -0,0 +1 @@ + diff --git a/src/k1link/viewer/__init__.py b/src/k1link/viewer/__init__.py new file mode 100644 index 0000000..6a96a8d --- /dev/null +++ b/src/k1link/viewer/__init__.py @@ -0,0 +1,15 @@ +"""Live/replay visualization bridge for verified K1 MQTT streams.""" + +from k1link.viewer.messages import StreamMessage +from k1link.viewer.replay import ( + ReplayFormatError, + detect_replay_format, + iter_replay_messages, +) + +__all__ = [ + "ReplayFormatError", + "StreamMessage", + "detect_replay_format", + "iter_replay_messages", +] diff --git a/src/k1link/viewer/foxglove_bridge.py b/src/k1link/viewer/foxglove_bridge.py new file mode 100644 index 0000000..ef92982 --- /dev/null +++ b/src/k1link/viewer/foxglove_bridge.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import math +import statistics +import struct +import threading +import time +from collections import deque +from dataclasses import dataclass +from typing import TypedDict + +import foxglove +from foxglove import Channel +from foxglove.channels import ( + PointCloudChannel, + PoseInFrameChannel, + SceneUpdateChannel, +) +from foxglove.messages import ( + Color, + LinePrimitive, + LinePrimitiveLineType, + PackedElementField, + PackedElementFieldNumericType, + Point3, + PointCloud, + Pose, + PoseInFrame, + Quaternion, + SceneEntity, + SceneUpdate, + Timestamp, + Vector3, +) + +from k1link.protocol.streams import ( + LegacyPointCloudFrame, + LegacyPoseFrame, + LioPointCloudFrame, + LioPoseFrame, + StreamDecodeError, + decode_legacy_pointcloud, + decode_legacy_pose, + decode_lio_pcl, + decode_lio_pose, +) +from k1link.viewer.messages import StreamMessage + +POINT_STRUCT = struct.Struct(" None: + self._lock = threading.Lock() + self._messages_received = 0 + self._payload_bytes = 0 + self._pcl_frames = 0 + self._pose_frames = 0 + self._points_published = 0 + self._last_point_count = 0 + self._decode_errors = 0 + self._preview_dropped = 0 + self._trajectory_poses = 0 + self._pcl_times: deque[int] = deque() + self._pose_times: deque[int] = deque() + self._latencies_ms: deque[float] = deque(maxlen=512) + self._decode_publish_ms: float | None = None + + def received(self, payload_bytes: int) -> None: + with self._lock: + self._messages_received += 1 + self._payload_bytes += payload_bytes + + def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None: + with self._lock: + self._pcl_frames += 1 + self._points_published += point_count + self._last_point_count = point_count + self._decode_publish_ms = decode_publish_ms + self._pcl_times.append(now_ns) + _trim_rate_window(self._pcl_times, now_ns) + + def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None: + with self._lock: + self._pose_frames += 1 + self._decode_publish_ms = decode_publish_ms + self._trajectory_poses = path_size + self._pose_times.append(now_ns) + _trim_rate_window(self._pose_times, now_ns) + + def record_latency(self, milliseconds: float) -> None: + if not math.isfinite(milliseconds) or milliseconds < 0: + return + with self._lock: + self._latencies_ms.append(milliseconds) + + def decode_error(self) -> None: + with self._lock: + self._decode_errors += 1 + + def preview_dropped(self) -> None: + with self._lock: + self._preview_dropped += 1 + + def snapshot(self) -> MetricsSnapshot: + now_ns = time.monotonic_ns() + with self._lock: + _trim_rate_window(self._pcl_times, now_ns) + _trim_rate_window(self._pose_times, now_ns) + latencies = list(self._latencies_ms) + last_latency = latencies[-1] if latencies else None + p50 = statistics.median(latencies) if latencies else None + p95 = _percentile(latencies, 0.95) if latencies else None + return { + "messages_received": self._messages_received, + "payload_bytes": self._payload_bytes, + "pcl_frames": self._pcl_frames, + "pose_frames": self._pose_frames, + "points_published": self._points_published, + "last_point_count": self._last_point_count, + "decode_errors": self._decode_errors, + "preview_dropped": self._preview_dropped, + "pcl_fps": _window_rate(self._pcl_times), + "pose_fps": _window_rate(self._pose_times), + "mqtt_to_publish_ms": _rounded(last_latency), + "mqtt_to_publish_p50_ms": _rounded(p50), + "mqtt_to_publish_p95_ms": _rounded(p95), + "decode_publish_ms": _rounded(self._decode_publish_ms), + "trajectory_poses": self._trajectory_poses, + } + + +class FoxgloveBridge: + """Decode verified K1 topics and publish Foxglove-native visualization messages.""" + + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int = 8765, + metrics: BridgeMetrics | None = None, + ) -> None: + self.metrics = metrics or BridgeMetrics() + self._server = foxglove.start_server( + name="NODE.DC K1 live bridge", + host=host, + port=port, + message_backlog_size=32, + ) + self._points = PointCloudChannel("/k1/points") + self._pose = PoseInFrameChannel("/k1/pose") + self._trajectory = SceneUpdateChannel("/k1/trajectory") + self._metrics = Channel( + "/k1/metrics", + schema={ + "type": "object", + "properties": { + "mqtt_to_publish_ms": {"type": ["number", "null"]}, + "mqtt_to_publish_p50_ms": {"type": ["number", "null"]}, + "mqtt_to_publish_p95_ms": {"type": ["number", "null"]}, + "decode_publish_ms": {"type": ["number", "null"]}, + "point_count": {"type": "integer"}, + "pcl_fps": {"type": "number"}, + "pose_fps": {"type": "number"}, + "preview_dropped": {"type": "integer"}, + }, + }, + ) + self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES) + self._last_trajectory_publish_ns = 0 + self._last_point_count = 0 + self._closed = False + + @property + def port(self) -> int: + return int(self._server.port) + + @property + def websocket_url(self) -> str: + return f"ws://127.0.0.1:{self.port}" + + @property + def viewer_url(self) -> str: + return self._server.app_url() or "https://app.foxglove.dev/" + + def process(self, message: StreamMessage) -> None: + started_ns = time.monotonic_ns() + self.metrics.received(len(message.payload)) + try: + if message.topic.endswith("/lio_pcl"): + self._publish_lio_pcl(decode_lio_pcl(message.payload), message) + elif message.topic == "RealtimePointcloud": + self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload), message) + elif message.topic.endswith("/lio_pose"): + self._publish_lio_pose(decode_lio_pose(message.payload), message) + elif message.topic == "RealtimePath": + self._publish_legacy_pose(decode_legacy_pose(message.payload), message) + else: + return + except StreamDecodeError: + self.metrics.decode_error() + return + + published_ns = time.monotonic_ns() + decode_publish_ms = (published_ns - started_ns) / 1_000_000 + if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud": + self.metrics.published_pcl(self._last_point_count, published_ns, decode_publish_ms) + else: + self.metrics.published_pose(published_ns, decode_publish_ms, len(self._path)) + if message.received_monotonic_ns is not None: + self.metrics.record_latency((published_ns - message.received_monotonic_ns) / 1_000_000) + snapshot = self.metrics.snapshot() + self._metrics.log( + { + "mqtt_to_publish_ms": snapshot["mqtt_to_publish_ms"], + "mqtt_to_publish_p50_ms": snapshot["mqtt_to_publish_p50_ms"], + "mqtt_to_publish_p95_ms": snapshot["mqtt_to_publish_p95_ms"], + "decode_publish_ms": snapshot["decode_publish_ms"], + "point_count": snapshot["last_point_count"], + "pcl_fps": snapshot["pcl_fps"], + "pose_fps": snapshot["pose_fps"], + "preview_dropped": snapshot["preview_dropped"], + }, + log_time=message.received_at_epoch_ns, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + for channel in (self._points, self._pose, self._trajectory, self._metrics): + channel.close() + self._server.stop() + + def _publish_lio_pcl(self, frame: LioPointCloudFrame, message: StreamMessage) -> None: + packed = pack_lio_point_cloud(frame) + self._publish_point_cloud(packed, message) + + def _publish_legacy_pcl( + self, + frame: LegacyPointCloudFrame, + message: StreamMessage, + ) -> None: + packed = pack_legacy_point_cloud(frame) + self._publish_point_cloud(packed, message) + + def _publish_point_cloud(self, packed: PackedPointCloud, message: StreamMessage) -> None: + timestamp = _timestamp(message.received_at_epoch_ns) + self._points.log( + PointCloud( + timestamp=timestamp, + frame_id=FRAME_ID, + pose=_identity_pose(), + point_stride=POINT_STRIDE, + fields=_point_fields(), + data=packed.data, + ), + log_time=message.received_at_epoch_ns, + ) + self._last_point_count = packed.point_count + + def _publish_lio_pose(self, frame: LioPoseFrame, message: StreamMessage) -> None: + self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message) + + def _publish_legacy_pose(self, frame: LegacyPoseFrame, message: StreamMessage) -> None: + self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message) + + def _publish_pose( + self, + position_xyz: tuple[float, float, float], + orientation_xyzw: tuple[float, float, float, float], + message: StreamMessage, + ) -> None: + pose = Pose( + position=Vector3(x=position_xyz[0], y=position_xyz[1], z=position_xyz[2]), + orientation=Quaternion( + x=orientation_xyzw[0], + y=orientation_xyzw[1], + z=orientation_xyzw[2], + w=orientation_xyzw[3], + ), + ) + timestamp = _timestamp(message.received_at_epoch_ns) + self._pose.log( + PoseInFrame(timestamp=timestamp, frame_id=FRAME_ID, pose=pose), + log_time=message.received_at_epoch_ns, + ) + self._path.append(position_xyz) + now_ns = time.monotonic_ns() + if ( + len(self._path) > 2 + and len(self._path) % 20 + and now_ns - self._last_trajectory_publish_ns < 200_000_000 + ): + return + self._last_trajectory_publish_ns = now_ns + line_points = [Point3(x=item[0], y=item[1], z=item[2]) for item in self._path] + self._trajectory.log( + SceneUpdate( + entities=[ + SceneEntity( + timestamp=timestamp, + frame_id=FRAME_ID, + id="k1-trajectory", + frame_locked=True, + lines=[ + LinePrimitive( + type=LinePrimitiveLineType.LineStrip, + thickness=3.0, + scale_invariant=True, + points=line_points, + color=Color(r=0.08, g=0.82, b=1.0, a=1.0), + ) + ], + ) + ] + ), + log_time=message.received_at_epoch_ns, + ) + + +def pack_lio_point_cloud(frame: LioPointCloudFrame) -> PackedPointCloud: + data = bytearray(len(frame.points) * POINT_STRIDE) + scaler = frame.header.scaler + for index, point in enumerate(frame.points): + x, y, z = point.scaled_xyz(scaler) + POINT_STRUCT.pack_into(data, index * POINT_STRIDE, x, y, z, point.intensity) + return PackedPointCloud(data=bytes(data), point_count=len(frame.points)) + + +def pack_legacy_point_cloud(frame: LegacyPointCloudFrame) -> PackedPointCloud: + data = bytearray(len(frame.points) * POINT_STRIDE) + for index, point in enumerate(frame.points): + POINT_STRUCT.pack_into( + data, + index * POINT_STRIDE, + point.x, + point.y, + point.z, + point.intensity, + ) + return PackedPointCloud(data=bytes(data), point_count=len(frame.points)) + + +def _point_fields() -> list[PackedElementField]: + return [ + PackedElementField(name="x", offset=0, type=PackedElementFieldNumericType.Float32), + PackedElementField(name="y", offset=4, type=PackedElementFieldNumericType.Float32), + PackedElementField(name="z", offset=8, type=PackedElementFieldNumericType.Float32), + PackedElementField( + name="intensity", + offset=12, + type=PackedElementFieldNumericType.Uint8, + ), + ] + + +def _identity_pose() -> Pose: + return Pose(position=Vector3(), orientation=Quaternion(w=1.0)) + + +def _timestamp(epoch_ns: int) -> Timestamp: + return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000) + + +def _trim_rate_window(values: deque[int], now_ns: int) -> None: + cutoff = now_ns - 1_000_000_000 + while values and values[0] < cutoff: + values.popleft() + + +def _window_rate(values: deque[int]) -> float: + if len(values) < 2: + return float(len(values)) + elapsed = (values[-1] - values[0]) / 1_000_000_000 + return len(values) / max(elapsed, 1.0) + + +def _percentile(values: list[float], fraction: float) -> float: + if not values: + raise ValueError("cannot calculate a percentile of an empty sample") + ordered = sorted(values) + index = min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1) + return ordered[index] + + +def _rounded(value: float | None) -> float | None: + return None if value is None else round(value, 3) diff --git a/src/k1link/viewer/messages.py b/src/k1link/viewer/messages.py new file mode 100644 index 0000000..33642bc --- /dev/null +++ b/src/k1link/viewer/messages.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class StreamMessage: + """One MQTT message entering the derived visualization pipeline.""" + + sequence: int + topic: str + payload: bytes + received_at_epoch_ns: int + received_monotonic_ns: int | None = None + source: str = "replay" diff --git a/src/k1link/viewer/replay.py b/src/k1link/viewer/replay.py new file mode 100644 index 0000000..b1489d5 --- /dev/null +++ b/src/k1link/viewer/replay.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +import time +from collections.abc import Generator, Iterator +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import IO, Literal + +from k1link.mqtt import CaptureFormatError, iter_capture_frames +from k1link.mqtt.capture import RAW_MAGIC +from k1link.viewer.messages import StreamMessage + +MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024 +MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024 +MAX_METADATA_LINE_CHARS = 1024 * 1024 +ReplayFormat = Literal["k1mqtt", "legacy_tsv"] + + +class ReplayFormatError(ValueError): + """A replay input is corrupt or outside the reviewed bounds.""" + + +def detect_replay_format(path: Path) -> ReplayFormat: + resolved = path.expanduser() + with resolved.open("rb") as stream: + prefix = stream.read(len(RAW_MAGIC)) + if prefix == RAW_MAGIC: + return "k1mqtt" + if resolved.suffix.casefold() == ".tsv": + return "legacy_tsv" + raise ReplayFormatError("input is neither a K1MQTT capture nor the reviewed legacy TSV") + + +def iter_replay_messages(path: Path) -> Generator[StreamMessage, None, None]: + """Yield bounded messages from native evidence or the one reviewed TSV export.""" + resolved = path.expanduser().resolve() + replay_format = detect_replay_format(resolved) + if replay_format == "legacy_tsv": + yield from _iter_legacy_tsv(resolved) + return + yield from _iter_native_capture(resolved) + + +def _iter_native_capture(path: Path) -> Iterator[StreamMessage]: + metadata_path = path.with_name("mqtt.metadata.jsonl") + metadata_stream: IO[str] | None = None + if metadata_path.is_file(): + metadata_stream = metadata_path.open("r", encoding="utf-8") + fallback_epoch_ns = time.time_ns() + try: + for frame in iter_capture_frames(path, max_payload_bytes=MAX_REPLAY_PAYLOAD_BYTES): + epoch_ns = fallback_epoch_ns + frame.sequence - 1 + monotonic_ns: int | None = None + if metadata_stream is not None: + epoch_ns, monotonic_ns = _read_native_timing( + metadata_stream, + expected_sequence=frame.sequence, + fallback_epoch_ns=epoch_ns, + ) + yield StreamMessage( + sequence=frame.sequence, + topic=frame.topic, + payload=frame.payload, + received_at_epoch_ns=epoch_ns, + received_monotonic_ns=monotonic_ns, + source="k1mqtt", + ) + except CaptureFormatError as exc: + raise ReplayFormatError(str(exc)) from exc + finally: + if metadata_stream is not None: + metadata_stream.close() + + +def _read_native_timing( + stream: IO[str], + *, + expected_sequence: int, + fallback_epoch_ns: int, +) -> tuple[int, int | None]: + line = stream.readline(MAX_METADATA_LINE_CHARS + 1) + if not line: + return fallback_epoch_ns, None + if len(line) > MAX_METADATA_LINE_CHARS: + raise ReplayFormatError("native metadata line exceeds the reviewed bound") + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ReplayFormatError( + f"native metadata line {expected_sequence} is not valid JSON" + ) from exc + if record.get("record_type") != "message" or record.get("sequence") != expected_sequence: + raise ReplayFormatError(f"native metadata is not aligned at message {expected_sequence}") + epoch_ns = record.get("received_at_epoch_ns", fallback_epoch_ns) + monotonic_ns = record.get("received_monotonic_ns") + if not isinstance(epoch_ns, int) or epoch_ns < 0: + raise ReplayFormatError("native metadata received_at_epoch_ns is invalid") + if monotonic_ns is not None and (not isinstance(monotonic_ns, int) or monotonic_ns < 0): + raise ReplayFormatError("native metadata received_monotonic_ns is invalid") + return epoch_ns, monotonic_ns + + +def _iter_legacy_tsv(path: Path) -> Iterator[StreamMessage]: + with path.open("rb") as stream: + line_number = 0 + while True: + raw_line = stream.readline(MAX_LEGACY_LINE_BYTES + 1) + if not raw_line: + return + line_number += 1 + if len(raw_line) > MAX_LEGACY_LINE_BYTES: + raise ReplayFormatError( + f"legacy TSV line {line_number} exceeds {MAX_LEGACY_LINE_BYTES} bytes" + ) + stripped = raw_line.rstrip(b"\r\n") + if not stripped: + continue + columns = stripped.split(b"\t") + if len(columns) != 4: + raise ReplayFormatError( + f"legacy TSV line {line_number} must contain exactly four columns" + ) + timestamp_raw, topic_raw, length_raw, payload_hex = columns + try: + timestamp = Decimal(timestamp_raw.decode("ascii")) + declared_length = int(length_raw.decode("ascii"), 10) + topic = topic_raw.decode("utf-8") + except (InvalidOperation, UnicodeDecodeError, ValueError) as exc: + raise ReplayFormatError( + f"legacy TSV line {line_number} has invalid timestamp/topic/length" + ) from exc + if not timestamp.is_finite() or timestamp < 0: + raise ReplayFormatError( + f"legacy TSV line {line_number} timestamp is outside bounds" + ) + if not topic: + raise ReplayFormatError(f"legacy TSV line {line_number} has an empty topic") + if declared_length < 0 or declared_length > MAX_REPLAY_PAYLOAD_BYTES: + raise ReplayFormatError( + f"legacy TSV line {line_number} payload length is outside bounds" + ) + if len(payload_hex) != declared_length * 2: + raise ReplayFormatError( + f"legacy TSV line {line_number} declared payload length does not match hex" + ) + try: + payload = bytes.fromhex(payload_hex.decode("ascii")) + except (UnicodeDecodeError, ValueError) as exc: + raise ReplayFormatError( + f"legacy TSV line {line_number} payload is not valid hex" + ) from exc + epoch_ns = int(timestamp * Decimal(1_000_000_000)) + yield StreamMessage( + sequence=line_number, + topic=topic, + payload=payload, + received_at_epoch_ns=epoch_ns, + source="legacy_tsv", + ) diff --git a/src/k1link/viewer/runtime.py b/src/k1link/viewer/runtime.py new file mode 100644 index 0000000..906abe4 --- /dev/null +++ b/src/k1link/viewer/runtime.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import math +import queue +import threading +import time +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal, TypedDict + +from k1link.artifacts import utc_now_iso, write_json_atomic +from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt +from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot +from k1link.viewer.messages import StreamMessage +from k1link.viewer.replay import iter_replay_messages + +RuntimePhase = Literal[ + "idle", + "starting_live", + "live", + "replay", + "stopping", + "error", +] +SourceMode = Literal["idle", "live", "replay"] +StateCallback = Callable[[], None] + + +class RuntimeSnapshot(TypedDict): + phase: RuntimePhase + message: str + source_mode: SourceMode + foxglove_ws_url: str | None + foxglove_viewer_url: str | None + metrics: MetricsSnapshot + + +class VisualizationRuntime: + """Own one bounded live/replay source and one deterministic publisher thread.""" + + def __init__(self, *, on_state_change: StateCallback | None = None) -> None: + self._lock = threading.Lock() + self._on_state_change = on_state_change + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._phase: RuntimePhase = "idle" + self._message = "Ready for a live scanner or replay capture." + self._source_mode: SourceMode = "idle" + self._foxglove_ws_url: str | None = None + self._foxglove_viewer_url: str | None = None + self._metrics = BridgeMetrics() + + def snapshot(self) -> RuntimeSnapshot: + with self._lock: + return { + "phase": self._phase, + "message": self._message, + "source_mode": self._source_mode, + "foxglove_ws_url": self._foxglove_ws_url, + "foxglove_viewer_url": self._foxglove_viewer_url, + "metrics": self._metrics.snapshot(), + } + + def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None: + resolved = path.expanduser().resolve() + if not resolved.is_file(): + raise ValueError("replay path must be an existing backend-local file") + if not math.isfinite(speed) or speed < 0: + raise ValueError("replay speed must be finite and non-negative") + # Validate the reviewed shape before changing runtime state. + iterator = iter_replay_messages(resolved) + try: + next(iterator) + except StopIteration as exc: + raise ValueError("replay capture contains no messages") from exc + finally: + iterator.close() + + self._start( + source_mode="replay", + phase="replay", + message=f"Starting replay: {resolved.name}", + target=lambda: self._run_replay(resolved, speed=speed, loop=loop), + ) + + def start_live( + self, + host: str, + out_dir: Path, + *, + duration_seconds: float = 3600.0, + ) -> None: + if not math.isfinite(duration_seconds) or duration_seconds <= 0: + raise ValueError("live duration must be finite and greater than zero") + self._start( + source_mode="live", + phase="starting_live", + message="Starting read-only MQTT capture and Foxglove bridge.", + target=lambda: self._run_live( + host, + out_dir.expanduser().resolve(), + duration_seconds=duration_seconds, + ), + ) + + def stop(self, *, wait_seconds: float = 5.0) -> None: + notify_only = False + with self._lock: + thread = self._thread + if thread is None or not thread.is_alive(): + self._phase = "idle" + self._source_mode = "idle" + self._message = "No active visualization session." + notify_only = True + else: + self._phase = "stopping" + self._message = "Stopping source after preserving queued evidence." + self._stop_event.set() + self._notify() + if notify_only: + return + assert thread is not None + thread.join(timeout=wait_seconds) + + def _start( + self, + *, + source_mode: SourceMode, + phase: RuntimePhase, + message: str, + target: Callable[[], None], + ) -> None: + with self._lock: + if self._thread is not None and self._thread.is_alive(): + raise RuntimeError("a visualization session is already active") + self._stop_event = threading.Event() + self._metrics = BridgeMetrics() + self._phase = phase + self._source_mode = source_mode + self._message = message + self._foxglove_ws_url = None + self._foxglove_viewer_url = None + self._thread = threading.Thread( + target=target, + name=f"k1-{source_mode}-session", + daemon=True, + ) + self._thread.start() + self._notify() + + def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None: + def produce(put: Callable[[StreamMessage], None]) -> str: + while not self._stop_event.is_set(): + first_source_ns: int | None = None + replay_started_ns = time.monotonic_ns() + count = 0 + for message in iter_replay_messages(path): + if self._stop_event.is_set(): + return "Replay stopped by operator." + source_ns = ( + message.received_monotonic_ns + if message.received_monotonic_ns is not None + else message.received_at_epoch_ns + ) + if first_source_ns is None: + first_source_ns = source_ns + if speed > 0: + target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed) + remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000 + if remaining > 0 and self._stop_event.wait(remaining): + return "Replay stopped by operator." + put(message) + count += 1 + if not loop: + return f"Replay completed: {count} MQTT messages." + return "Replay stopped by operator." + + self._run_pipeline(produce, running_phase="replay") + + def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None: + _write_live_session_preamble(out_dir, host, duration_seconds) + + def produce(put: Callable[[StreamMessage], None]) -> str: + def on_message(message: CapturedMqttMessage) -> None: + put( + StreamMessage( + sequence=message.sequence, + topic=message.topic, + payload=message.payload, + received_at_epoch_ns=message.received_at_epoch_ns, + received_monotonic_ns=message.received_monotonic_ns, + source="live_mqtt", + ) + ) + + summary = capture_mqtt( + host, + out_dir / "captures" / "mqtt_live", + duration_seconds=duration_seconds, + on_ready=lambda: self._set_running( + "live", "MQTT subscribed; waiting for K1 scan frames." + ), + on_message_recorded=on_message, + should_stop=self._stop_event.is_set, + ) + return ( + f"Live capture stopped: {summary['stop_reason']}; " + f"{summary['message_count']} messages preserved." + ) + + try: + self._run_pipeline(produce, running_phase="live") + finally: + _finalize_live_session(out_dir, self.snapshot()) + + def _run_pipeline( + self, + producer: Callable[[Callable[[StreamMessage], None]], str], + *, + running_phase: RuntimePhase, + ) -> None: + messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32) + source_done = threading.Event() + publisher_ready = threading.Event() + publisher_error: list[BaseException] = [] + + def enqueue(message: StreamMessage) -> None: + try: + messages.put_nowait(message) + return + except queue.Full: + pass + try: + messages.get_nowait() + messages.task_done() + except queue.Empty: + pass + self._metrics.preview_dropped() + try: + messages.put_nowait(message) + except queue.Full: + self._metrics.preview_dropped() + + def publish() -> None: + bridge: FoxgloveBridge | None = None + try: + bridge = FoxgloveBridge(metrics=self._metrics) + with self._lock: + self._foxglove_ws_url = bridge.websocket_url + self._foxglove_viewer_url = bridge.viewer_url + if self._phase != "stopping": + self._phase = running_phase + self._message = "Foxglove bridge ready; source is active." + publisher_ready.set() + self._notify() + while not source_done.is_set() or not messages.empty(): + if self._stop_event.is_set() and source_done.is_set() and messages.empty(): + break + try: + message = messages.get(timeout=0.1) + except queue.Empty: + continue + try: + bridge.process(message) + finally: + messages.task_done() + if self._metrics.snapshot()["messages_received"] % 10 == 0: + self._notify() + except BaseException as exc: + publisher_error.append(exc) + publisher_ready.set() + self._stop_event.set() + finally: + if bridge is not None: + bridge.close() + + publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True) + publisher.start() + if not publisher_ready.wait(timeout=15.0): + self._finish_error("Foxglove bridge did not start within 15 seconds.") + source_done.set() + self._stop_event.set() + return + if publisher_error: + self._finish_error( + f"Foxglove bridge failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}" + ) + source_done.set() + return + + final_message = "Session stopped." + try: + final_message = producer(enqueue) + except CaptureError as exc: + self._finish_error(f"Live MQTT capture failed: {exc}") + except (OSError, RuntimeError, ValueError) as exc: + self._finish_error(f"Source failed: {type(exc).__name__}: {exc}") + finally: + source_done.set() + publisher.join(timeout=15.0) + if publisher.is_alive(): + self._stop_event.set() + self._finish_error("Publisher did not drain within 15 seconds.") + elif publisher_error: + self._finish_error( + f"Publisher failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}" + ) + elif self.snapshot()["phase"] != "error": + self._finish_idle(final_message) + + def _set_running(self, phase: RuntimePhase, message: str) -> None: + with self._lock: + if self._phase != "stopping": + self._phase = phase + self._message = message + self._notify() + + def _finish_idle(self, message: str) -> None: + with self._lock: + self._phase = "idle" + self._source_mode = "idle" + self._message = message + self._foxglove_ws_url = None + self._foxglove_viewer_url = None + self._notify() + + def _finish_error(self, message: str) -> None: + with self._lock: + self._phase = "error" + self._message = message + self._foxglove_ws_url = None + self._foxglove_viewer_url = None + self._notify() + + def _notify(self) -> None: + callback = self._on_state_change + if callback is not None: + callback() + + +def new_live_session_dir(repository_root: Path) -> Path: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + base = repository_root / "sessions" / f"{stamp}_viewer_live" + candidate = base + suffix = 1 + while candidate.exists(): + suffix += 1 + candidate = base.with_name(f"{base.name}_{suffix:02d}") + return candidate + + +def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: float) -> None: + out_dir.mkdir(parents=True, exist_ok=False) + started_at_utc = utc_now_iso() + write_json_atomic( + out_dir / "manifest.redacted.json", + { + "schema_version": 1, + "started_at_utc": started_at_utc, + "started_monotonic_ns": time.monotonic_ns(), + "operation": "k1_live_mqtt_to_foxglove", + "target": "owner-controlled K1 at redacted RFC1918 address", + "requested_duration_seconds": duration_seconds, + "raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt", + "credential_storage": "none", + }, + ) + notes = ( + "# K1 live visualization session\n\n" + f"Started UTC: {started_at_utc}\n\n" + "The local control API started a read-only MQTT subscription and Foxglove " + "preview. Raw MQTT evidence is written before preview decoding. The target " + f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n" + ) + notes_path = out_dir / "operator-notes.md" + notes_path.write_text(notes, encoding="utf-8") + notes_path.chmod(0o600) + + +def _finalize_live_session(out_dir: Path, snapshot: RuntimeSnapshot) -> None: + manifest_path = out_dir / "manifest.redacted.json" + try: + import json + + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + payload["completed_at_utc"] = utc_now_iso() + payload["completed_monotonic_ns"] = time.monotonic_ns() + payload["final_phase"] = snapshot["phase"] + payload["aggregate_metrics"] = snapshot["metrics"] + write_json_atomic(manifest_path, payload) + except (OSError, ValueError): + # The MQTT capture summary remains authoritative if final annotation fails. + return diff --git a/src/k1link/web/__init__.py b/src/k1link/web/__init__.py new file mode 100644 index 0000000..f56acf8 --- /dev/null +++ b/src/k1link/web/__init__.py @@ -0,0 +1 @@ +"""Local-only control API for the K1 live console.""" diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py new file mode 100644 index 0000000..b8160fe --- /dev/null +++ b/src/k1link/web/app.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import asyncio +import threading +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from bleak.exc import BleakError +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from k1link import __version__ +from k1link.artifacts import write_json_atomic +from k1link.ble.scanner import scan +from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once +from k1link.mqtt import validate_private_ipv4 +from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] + + +class BleScanRequest(BaseModel): + duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0) + + +class ConnectRequest(BaseModel): + device_id: str = Field(min_length=1, max_length=128) + ssid: str = Field(min_length=1, max_length=128) + password: str = Field(min_length=1, max_length=256) + + +class LiveRequest(BaseModel): + host: str | None = Field(default=None, max_length=15) + duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) + + +class ReplayRequest(BaseModel): + path: str = Field(min_length=1, max_length=4096) + speed: float = Field(default=1.0, ge=0.0, le=100.0) + loop: bool = False + + +class ConsoleService: + def __init__(self, repository_root: Path) -> None: + self.repository_root = repository_root.resolve() + self._lock = threading.Lock() + self._devices: list[dict[str, Any]] = [] + self._selected_device_id: str | None = None + self._k1_ip: str | None = None + self._operation_phase: str | None = None + self._operation_message: str | None = None + self.runtime = VisualizationRuntime() + + def state(self) -> dict[str, Any]: + runtime = self.runtime.snapshot() + metrics = runtime["metrics"] + with self._lock: + operation_phase = self._operation_phase + operation_message = self._operation_message + devices = list(self._devices) + selected_device_id = self._selected_device_id + k1_ip = self._k1_ip + + runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in { + "starting_live", + "stopping", + "error", + } + if operation_phase is not None: + phase = operation_phase + message = operation_message + elif runtime_active: + phase = runtime["phase"] + message = runtime["message"] + elif k1_ip is not None: + phase = "connected" + message = runtime["message"] + elif selected_device_id is not None: + phase = "device_selected" + message = "K1 selected; Wi-Fi credentials have not been written." + else: + phase = "idle" + message = runtime["message"] + + return { + "phase": phase, + "message": message, + "devices": devices, + "selected_device_id": selected_device_id, + "k1_ip": k1_ip, + "foxglove_ws_url": runtime["foxglove_ws_url"], + "foxglove_viewer_url": runtime["foxglove_viewer_url"], + "source_mode": runtime["source_mode"], + "metrics": { + "pipeline_ms": metrics["mqtt_to_publish_ms"], + "end_to_end_ms": metrics["mqtt_to_publish_ms"], + "decode_ms": metrics["decode_publish_ms"], + "frame_rate": metrics["pcl_fps"], + "frame_rate_hz": metrics["pcl_fps"], + "point_count": metrics["last_point_count"], + "dropped_preview_frames": metrics["preview_dropped"], + **metrics, + }, + } + + async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: + self._set_operation("scanning", "Scanning for nearby K1 BLE advertisements.") + try: + result = await scan(duration_seconds) + devices = [ + { + "device_id": item["macos_uuid"], + "name": item["local_name"] or item["name"], + "rssi": item["rssi"], + "address": None, + "connectable": True, + } + for item in result["devices"] + if item["k1_name_candidate"] + ] + with self._lock: + self._devices = devices + if len(devices) == 1: + self._selected_device_id = str(devices[0]["device_id"]) + self._operation_message = f"BLE scan complete: {len(devices)} K1 candidate(s)." + finally: + with self._lock: + self._operation_phase = None + return self.state() + + async def connect(self, request: ConnectRequest) -> dict[str, Any]: + known_ids = {str(item["device_id"]) for item in self.state()["devices"]} + if request.device_id not in known_ids: + raise ValueError("device_id must come from the latest K1 BLE scan") + self._set_operation( + "provisioning", + "Sending one explicitly requested Wi-Fi provisioning write.", + ) + session_dir = _new_operation_session_dir( + self.repository_root, + "viewer_wifi_provisioning", + ) + session_dir.mkdir(parents=True, exist_ok=False) + password = request.password + try: + result = await provision_wifi_once( + request.device_id, + request.ssid, + password, + timeout_seconds=45.0, + write_mode="auto", + ) + write_json_atomic(session_dir / "provisioning.sensitive.json", result) + ipv4 = _provisioned_ipv4(result) + write_json_atomic( + session_dir / "manifest.redacted.json", + { + "schema_version": 1, + "started_at_utc": result["started_at_utc"], + "completed_at_utc": result["completed_at_utc"], + "operation": "single_reviewed_wifi_provisioning_write", + "profile_id": result["profile_id"], + "outcome": result["outcome"], + "k1_lan_address_observed": ipv4 is not None, + "credentials_persisted_by_connector": False, + }, + ) + if ipv4 is None: + raise RuntimeError( + "K1 did not report a non-AP LAN address; no automatic retry was made" + ) + with self._lock: + self._selected_device_id = request.device_id + self._k1_ip = ipv4 + self._operation_message = "K1 joined the LAN and reported its private address." + finally: + password = "" + with self._lock: + self._operation_phase = None + return self.state() + + def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: + target = host or self.state()["k1_ip"] + if not isinstance(target, str) or not target: + raise ValueError("live host is required until BLE provisioning reports a K1 address") + target = validate_private_ipv4(target) + out_dir = new_live_session_dir(self.repository_root) + self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds) + return self.state() + + def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: + replay_path = Path(path).expanduser().resolve() + if not replay_path.is_relative_to(self.repository_root): + raise ValueError("replay path must remain inside this repository") + self.runtime.start_replay(replay_path, speed=speed, loop=loop) + return self.state() + + def stop(self) -> dict[str, Any]: + self.runtime.stop() + return self.state() + + def _set_operation(self, phase: str, message: str) -> None: + with self._lock: + self._operation_phase = phase + self._operation_message = message + + +service = ConsoleService(REPOSITORY_ROOT) +app = FastAPI( + title="NODE.DC K1 Live Console API", + version=__version__, + docs_url="/api/docs", + redoc_url=None, + openapi_url="/api/openapi.json", +) + + +@app.get("/api/health") +def health() -> dict[str, Any]: + return { + "ok": True, + "status": "ok", + "service": "k1-live-console", + "version": __version__, + } + + +@app.get("/api/state") +def get_state() -> dict[str, Any]: + return service.state() + + +@app.post("/api/ble/scan") +async def scan_ble(request: BleScanRequest) -> dict[str, Any]: + try: + return await service.scan_ble(request.duration_seconds) + except (BleakError, OSError, RuntimeError, ValueError) as exc: + raise HTTPException(status_code=502, detail=f"BLE scan failed: {exc}") from exc + + +@app.post("/api/connect") +async def connect(request: ConnectRequest) -> dict[str, Any]: + try: + return await service.connect(request) + except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc: + raise HTTPException(status_code=502, detail=f"Wi-Fi provisioning failed: {exc}") from exc + + +@app.post("/api/session/live") +def start_live(request: LiveRequest) -> dict[str, Any]: + try: + return service.start_live(request.host, request.duration_seconds) + except (OSError, RuntimeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/session/replay") +def start_replay(request: ReplayRequest) -> dict[str, Any]: + try: + return service.start_replay(request.path, request.speed, request.loop) + except (OSError, RuntimeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post("/api/session/stop") +def stop_session() -> dict[str, Any]: + return service.stop() + + +@app.websocket("/api/events") +async def events(websocket: WebSocket) -> None: + await websocket.accept() + try: + while True: + await websocket.send_json({"state": service.state()}) + await asyncio.sleep(0.5) + except WebSocketDisconnect: + return + + +frontend_dist = REPOSITORY_ROOT / "apps" / "k1-viewer" / "dist" +if frontend_dist.is_dir(): + app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend") + + +def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + base = repository_root / "sessions" / f"{stamp}_{suffix}" + candidate = base + serial = 1 + while candidate.exists(): + serial += 1 + candidate = base.with_name(f"{base.name}_{serial:02d}") + return candidate + + +def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None: + observations = result.get("observations") + if not isinstance(observations, list): + return None + for observation in reversed(observations): + if not isinstance(observation, dict): + continue + status = observation.get("status") + if not isinstance(status, dict): + continue + address = status.get("ipv4") + if isinstance(address, str) and address != AP_FALLBACK_IPV4: + try: + return validate_private_ipv4(address) + except ValueError: + continue + return None diff --git a/tests/test_foxglove_bridge.py b/tests/test_foxglove_bridge.py new file mode 100644 index 0000000..acfa51e --- /dev/null +++ b/tests/test_foxglove_bridge.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import struct + +from k1link.protocol.streams import ( + LegacyPoint, + LegacyPointCloudFrame, + LioPoint, + LioPointCloudFrame, + MqttHeader, +) +from k1link.viewer.foxglove_bridge import ( + POINT_STRIDE, + BridgeMetrics, + pack_legacy_point_cloud, + pack_lio_point_cloud, +) + + +def test_lio_points_are_scaled_and_packed_with_uint8_intensity() -> None: + frame = LioPointCloudFrame( + header=MqttHeader( + seq=1, + stamp=2, + scaler=1000, + device_id="synthetic", + session_id="synthetic", + openapi_key=None, + ), + compression=0, + compressed_bytes=10, + decompressed_bytes=20, + points=(LioPoint(-1000, 2500, 3000, 0xAABBCC7F),), + ) + + packed = pack_lio_point_cloud(frame) + + assert packed.point_count == 1 + assert len(packed.data) == POINT_STRIDE + assert struct.unpack(" None: + frame = LegacyPointCloudFrame( + envelope=b"\x00" * 12, + stride=16, + points=(LegacyPoint(1.0, -2.0, 3.5, 1, 2, 3, 240),), + ) + + packed = pack_legacy_point_cloud(frame) + + assert struct.unpack(" None: + metrics = BridgeMetrics() + metrics.received(100) + metrics.preview_dropped() + metrics.record_latency(12.3456) + metrics.published_pcl(42, 2_000_000_000, 1.2345) + + snapshot = metrics.snapshot() + + assert snapshot["messages_received"] == 1 + assert snapshot["payload_bytes"] == 100 + assert snapshot["preview_dropped"] == 1 + assert snapshot["last_point_count"] == 42 + assert snapshot["mqtt_to_publish_ms"] == 12.346 + assert snapshot["decode_publish_ms"] == 1.234 diff --git a/tests/test_mqtt_capture.py b/tests/test_mqtt_capture.py index 0832679..4a121e4 100644 --- a/tests/test_mqtt_capture.py +++ b/tests/test_mqtt_capture.py @@ -95,11 +95,13 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None: def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None: fake = FakeClient() + observed = [] summary = capture_mqtt( "192.168.1.50", tmp_path / "capture", duration_seconds=30, + on_message_recorded=observed.append, _client_factory=lambda: cast(mqtt.Client, fake), ) @@ -110,6 +112,10 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) - assert summary["message_count"] == 1 assert summary["payload_bytes"] == len(fake.payload) assert summary["subscriptions"] == list(REPORT_TOPICS) + assert len(observed) == 1 + assert observed[0].topic == fake.topic + assert observed[0].payload == fake.payload + assert observed[0].received_at_epoch_ns > 0 capture_dir = tmp_path / "capture" raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes() @@ -122,14 +128,14 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) - assert raw[payload_start:] == fake.payload records: list[dict[str, object]] = [ - json.loads(line) - for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines() + json.loads(line) for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines() ] assert len(records) == 1 record = records[0] assert record["record_type"] == "message" assert record["topic"] == fake.topic assert record["payload_bytes"] == len(fake.payload) + assert isinstance(record["received_at_epoch_ns"], int) assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest() assert record["raw_frame_offset"] == len(RAW_MAGIC) assert record["raw_payload_offset"] == payload_start @@ -187,6 +193,49 @@ def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None assert raw.read_bytes() == b"existing evidence" +def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path) -> None: + fake = FakeClient() + stop_checks = 0 + + def should_stop() -> bool: + nonlocal stop_checks + stop_checks += 1 + return stop_checks >= 4 + + summary = capture_mqtt( + "192.168.1.50", + tmp_path / "capture", + duration_seconds=30, + should_stop=should_stop, + _client_factory=lambda: cast(mqtt.Client, fake), + ) + + assert summary["stop_reason"] == "external_stop" + assert summary["message_count"] == 1 + assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload + + +def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None: + fake = FakeClient() + capture_dir = tmp_path / "capture" + + def fail_preview(_message: object) -> None: + raise ValueError("synthetic preview failure") + + with pytest.raises(CaptureError, match="preview callback failed") as error: + capture_mqtt( + "192.168.1.50", + capture_dir, + on_message_recorded=fail_preview, + _client_factory=lambda: cast(mqtt.Client, fake), + ) + + assert error.value.summary is not None + assert error.value.summary["message_count"] == 1 + frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt")) + assert frames[0].payload == fake.payload + + @pytest.mark.parametrize( ("raw", "message"), [ diff --git a/tests/test_viewer_replay.py b/tests/test_viewer_replay.py new file mode 100644 index 0000000..02a3b0d --- /dev/null +++ b/tests/test_viewer_replay.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages + + +def _write_native(path: Path, topic: str, payload: bytes) -> None: + topic_raw = topic.encode() + path.write_bytes( + RAW_MAGIC + FRAME_HEADER.pack(len(topic_raw), len(payload)) + topic_raw + payload + ) + + +def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None: + capture = tmp_path / "mqtt.raw.k1mqtt" + _write_native(capture, "lixel/application/report/lio_pose", b"pose") + (tmp_path / "mqtt.metadata.jsonl").write_text( + json.dumps( + { + "record_type": "message", + "sequence": 1, + "received_at_epoch_ns": 123_000_000_000, + "received_monotonic_ns": 456, + } + ) + + "\n" + ) + + assert detect_replay_format(capture) == "k1mqtt" + message = list(iter_replay_messages(capture))[0] + assert message.topic.endswith("lio_pose") + assert message.payload == b"pose" + assert message.received_at_epoch_ns == 123_000_000_000 + assert message.received_monotonic_ns == 456 + + +def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None: + capture = tmp_path / "payloads.tsv" + capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n") + + assert detect_replay_format(capture) == "legacy_tsv" + message = list(iter_replay_messages(capture))[0] + assert message.received_at_epoch_ns == 1_784_124_315_186_225_000 + assert message.payload == b"\x00\x01\xaa\xff" + + +@pytest.mark.parametrize( + "line", + [ + b"1\tonly\tthree\n", + b"bad\ttopic\t1\t00\n", + b"1\ttopic\t2\t00\n", + b"1\ttopic\t1\tzz\n", + ], +) +def test_legacy_tsv_rejects_unreviewed_shapes(tmp_path: Path, line: bytes) -> None: + capture = tmp_path / "payloads.tsv" + capture.write_bytes(line) + with pytest.raises(ReplayFormatError): + list(iter_replay_messages(capture)) diff --git a/uv.lock b/uv.lock index cd9ce1d..23f1bfd 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "bleak" version = "3.0.2" @@ -35,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -58,6 +92,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, ] +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] +name = "foxglove-sdk" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/25/a0bb15179539bf144f8bf13b176518886191710b40fe4a8173b461d90313/foxglove_sdk-0.25.3.tar.gz", hash = "sha256:32f1066401c37538d478b4a8ecaaae4e9dd019f9f75d3ef47494bfe7d4273dee", size = 549908, upload-time = "2026-06-25T00:24:26.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/48/b8d36edbaeef75f52197fd08fe5e431cb73ab32b975377facb468da57b22/foxglove_sdk-0.25.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9667615b342651b3960dd82e6a8403678c1fc701799bf0b28e41be0392eb507d", size = 17812618, upload-time = "2026-06-25T00:24:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/2dee8063e2bf2d8cf0db97e732a66a1c75f1faa519ff87f86c9f735d0944/foxglove_sdk-0.25.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d88faf89da6da3d904b6aa1371b809f4d9dcabb8981fcedcfafccdf07b200a49", size = 16379537, upload-time = "2026-06-25T00:24:09.088Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/2bf924ee1ba5bd9eb49322d8c04850abc444e3323baba425d48112d4cbde/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcdcb054c198e892ac271c88e685e292d1ef8f1f21a14e2d3a9de5988474e60a", size = 2261344, upload-time = "2026-06-25T00:24:10.961Z" }, + { url = "https://files.pythonhosted.org/packages/5f/dc/1765ec5b19b0ddecdaff1dad1bc5a332668784e7d99924ebdf003d7e73ad/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:510c7fe47c45428e9d76eddfae1b9966b7976819a3a194a00f7e8d0f4b3eacc3", size = 2178418, upload-time = "2026-06-25T00:24:12.359Z" }, + { url = "https://files.pythonhosted.org/packages/b2/15/a9182e0d3bbee09261e79249a9d608af4739e6e25791bcd59b23b6522b34/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d7dd5e61afc65163438b858a70bec3a30126de1664a97358d4403e20db5eb1", size = 2209079, upload-time = "2026-06-25T00:24:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/82cfe8ee2bdef5a0f81c9a9993f587bb322848475f8b94d6d814fef9675a/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e00cdb87d0a1623cd60a66f7fd531bf492164433e313d4c7f77cb42fdd613420", size = 18620792, upload-time = "2026-06-25T00:24:15.059Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/6313a16cecd97b0b733967a92f85e4d1929cbbd521433a4db5e070aa850a/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bcc894b88188d8169973cfbb1370300f671760adea9d6e9447e5a03b2289527d", size = 19220466, upload-time = "2026-06-25T00:24:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/120868313bea126b893432f0a0b5143e4042d85946a8aaa4d32039e6484d/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:139bb1f2ae284673ecb17a1b47955989a58ab3a8639c1fd0f6d3f3afbbbd0fda", size = 2445762, upload-time = "2026-06-25T00:24:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/3d0744e87c6bd1d72417574df84d94d1b5711c2b0196a3325f7b52c9930d/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3d9e671be05e7539a99d84dbd254581616f7ebae8ee8430766788dd6093d58a3", size = 2452345, upload-time = "2026-06-25T00:24:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/53/bd/93dd3cd685b4605877facc12b4f1b7ffb4894cd5040813c9c6fd262e2e55/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2dbdef29d2b6115bbbc478f309ac4937fb8765b42681537db99a15282ebaac6f", size = 2468866, upload-time = "2026-06-25T00:24:21.775Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ff/c5f4f8f75c4a6c8b0822da3bf8af8bcb3db27403439dc032c2d7f0b890dc/foxglove_sdk-0.25.3-cp310-abi3-win32.whl", hash = "sha256:a2a1716cade8a9dd842df18f7b1306a9931fa114f485fc552fc14c44ddba8351", size = 1537215, upload-time = "2026-06-25T00:24:22.99Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/8ccb4a985c5baf82947e48cff18483c2125ea11e55e8a28950730ab6c065/foxglove_sdk-0.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:ac881ae307ba432766e6141d9098ced2059838226d225fbeb20de1c57d782a9f", size = 16496466, upload-time = "2026-06-25T00:24:24.906Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -162,10 +250,13 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "bleak" }, + { name = "fastapi" }, + { name = "foxglove-sdk" }, { name = "lz4" }, { name = "paho-mqtt" }, { name = "rich" }, { name = "typer" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -178,10 +269,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "bleak", specifier = "==3.0.2" }, + { name = "fastapi", specifier = ">=0.116,<1" }, + { name = "foxglove-sdk", specifier = "==0.25.3" }, { name = "lz4", specifier = ">=4.4,<5" }, { name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "rich", specifier = ">=13.9,<15" }, { name = "typer", specifier = ">=0.15,<1" }, + { name = "uvicorn", specifier = ">=0.35,<1" }, ] [package.metadata.requires-dev] @@ -227,6 +321,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -346,6 +485,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "typer" version = "0.26.8" @@ -370,6 +522,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "winrt-runtime" version = "3.2.1"