mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e994f0cd7 | |||
| ad15f1b049 | |||
| 8247d28d90 | |||
| 5d6e50d8a0 | |||
| 49fb2ca9f4 | |||
| 3439fb1402 | |||
| c00f45e296 | |||
| f54f0285bd | |||
| e964eaf14f | |||
| 961c01f4bd | |||
| 79cc2d7b22 | |||
| f5e2b5474b | |||
| 281c4cb0ce | |||
| b2e2e6d6fd | |||
| 72bbd256e7 | |||
| 50131b2519 | |||
| 50136c920d | |||
| 3bd70f7910 | |||
| 6f5ac3aa5a | |||
| 1b155ad027 | |||
| fa28318bae | |||
| ec73109d57 | |||
| acbd3ff13c | |||
| 07086c5d9d | |||
| 0310b1fa9a | |||
| 9daa8c3078 | |||
| ffa808ed4b | |||
| 59dbb76757 | |||
| 4ecc053a27 | |||
| 5170b99aca | |||
| c16dc9f80a | |||
| 04ccfcde56 | |||
| 4d45add824 | |||
| 562cb7461f | |||
| fad6828697 | |||
| 807bf0b32a | |||
| 4b602c79dd | |||
| 76321ce4bc | |||
| 1690aea22a | |||
| a80617ee84 | |||
| 75dc302952 | |||
| afc86c6fc4 | |||
| fc654034b3 | |||
| ce7983eb43 |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
- name: Extract metadata
|
||||
continue-on-error: true
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release -- --no-default-features --features wasm
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
|
||||
|
||||
- working-directory: dashboard
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
-- --no-default-features --features wasm
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/ruvnet/nvsim-server
|
||||
tags: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: v2
|
||||
file: v2/crates/nvsim-server/Dockerfile
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
|
||||
- name: Run Grype vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@v3
|
||||
uses: anchore/scan-action@v7
|
||||
id: grype-scan
|
||||
with:
|
||||
image: 'wifi-densepose:scan'
|
||||
@@ -343,7 +343,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
@@ -50,6 +50,12 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# QEMU is required so the amd64 GitHub runner can cross-build the
|
||||
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
|
||||
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
|
||||
# by the runner, not built on a separate arm64 host).
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
@@ -68,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/ruvnet/wifi-densepose
|
||||
@@ -81,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Build + push
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rust
|
||||
@@ -90,7 +96,11 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
|
||||
# as part of the docker publish refresh; arm64 was never actually wired
|
||||
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
|
||||
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Smoke-test the freshly-pushed image:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -57,7 +57,18 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: v1
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
|
||||
# order is otherwise non-deterministic across CI runs (issue #560
|
||||
# follow-up: 9- and 6-decimal quantization were not enough because
|
||||
# the divergence is from threading order, not SIMD reordering).
|
||||
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
@@ -65,7 +76,13 @@ jobs:
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: v1
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
|
||||
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
- `DELETE /api/v1/recording/delete/:id` (`recording.rs` — `id`)
|
||||
- `POST /api/v1/models/load` (`model_manager.rs` — `model_id`)
|
||||
- `training_api.rs` `load_recording_frames` (`dataset_id`s)
|
||||
|
||||
Pre-fix, unauthenticated callers could read `../../etc/passwd`-style paths, write arbitrary JSONL files, load attacker-controlled `.rvf` model files, or delete arbitrary files the server process could touch. 9 unit tests in `path_safety::tests` exercise the rejection envelope (empty, too-long, path separators, parent-dir traversal, null byte, whitespace/specials, non-ASCII).
|
||||
|
||||
### Fixed
|
||||
- **WebSocket `/ws/sensing` now reports `esp32:offline` when ESP32 hardware goes stale** (closes #618). `broadcast_tick_task` was re-emitting the cached `latest_update` with a frozen `source: "esp32"` field forever after the hardware lost power or network. The REST `/health` endpoint already called `effective_source()` (which returns `"esp32:offline"` after `ESP32_OFFLINE_TIMEOUT` = 5 s with no UDP frames), but the WS broadcast path was the one consumer that didn't. Result: the UI's "LIVE — ESP32 HARDWARE Connected" banner stayed green long after the hardware went away, and `vital_signs`/`features`/`classification` re-broadcasted the last-seen values indefinitely. Fix: clone the cached `latest_update` per tick, overwrite `source` with `s.effective_source()`, then serialize and broadcast. UI can now switch to an offline state on the same 5-second budget the REST surface uses.
|
||||
- **Proof replay (`archive/v1/data/proof/verify.py`) is now cross-platform deterministic** (closes #560). Three changes together: (1) `features_to_bytes()` now `np.round(.., HASH_QUANTIZATION_DECIMALS=6)`s each feature array before packing as little-endian f64, collapsing ULP-level drift from scipy.fft pocketfft SIMD reordering; (2) the `Verify Pipeline Determinism` workflow pins `OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`, `VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1` — multi-threaded BLAS reductions were a deeper source of non-determinism than SIMD reordering, and 6-decimal quantization alone wasn't enough across Azure VM microarchitectures; (3) `expected_features.sha256` regenerated under the new conditions. CI now passes the determinism check (same hash across consecutive runs on canonical Linux x86_64 CI runner: `667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7`). `scripts/probe-fft-platform.py` updated to mirror `HASH_QUANTIZATION_DECIMALS=6` for cross-machine spot-checks.
|
||||
- **`archive/v1/src/services/pose_service.py:223` calls the right method on `PhaseSanitizer`** (closes #612). The call was `self.phase_sanitizer.sanitize(phase_data)`, but `PhaseSanitizer`'s full-pipeline entry point is named `sanitize_phase()` (`unwrap_phase` + `remove_outliers` + `smooth_phase` chained, see `archive/v1/src/core/phase_sanitizer.py:266`). The shorter `sanitize` name doesn't exist on the class, so any path that reached this branch raised `AttributeError` and crashed the pose service mid-frame.
|
||||
- **`adaptive_classifier.rs:94` no longer panics on NaN feature values** (closes #611).
|
||||
`sorted.sort_by(|a, b| a.partial_cmp(b).unwrap())` returned `None` and panicked
|
||||
whenever a single `NaN` reached the classifier from real ESP32 hardware (silent
|
||||
DSP div-by-zero, empty buffer). One bad frame killed the entire sensing-server
|
||||
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
|
||||
same file already used at lines 149-150 and 155. Per-frame hot path; this was
|
||||
a real production crash vector.
|
||||
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
|
||||
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
|
||||
and missed seven additional production sites that use comparator variants
|
||||
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
|
||||
the same crash class — a single `NaN` in CSI-derived state panics the whole
|
||||
sensing-server. Fixed:
|
||||
- `adaptive_classifier.rs:205` — `AdaptiveModel::classify()` argmax over softmax
|
||||
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
|
||||
logits → softmax and still reaches this site even after the #613 IQR fix.
|
||||
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
|
||||
(training/per-class accuracy reporting).
|
||||
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
|
||||
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
|
||||
catches an empty iterator; it cannot rescue a comparator panic.
|
||||
|
||||
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
|
||||
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
|
||||
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
|
||||
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
|
||||
|
||||
### Removed
|
||||
- **Stub crates `wifi-densepose-api`, `wifi-densepose-db`, `wifi-densepose-config`** (closes #578).
|
||||
Each was a single-line doc-comment placeholder with an empty `[dependencies]`
|
||||
section and zero references from any source file or `Cargo.toml`. The names
|
||||
were reserved early for an envisioned REST/database/config split that never
|
||||
materialised; the functionality they would provide is covered today by
|
||||
`wifi-densepose-sensing-server` (Axum REST/WS), per-crate config + CLI args,
|
||||
and the project's real-time-only (no-persistent-state) posture. Removing them
|
||||
from the workspace prevents `cargo` from listing dead crates and shipping
|
||||
empty published artifacts. If any of these names is needed in the future,
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
|
||||
New `wifi_densepose_sensing_server::introspection` module wires
|
||||
@@ -108,6 +162,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
|
||||
|
||||
### Added
|
||||
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
|
||||
feature normalization to whatever distribution the deployment produces. Replaces
|
||||
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
|
||||
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
|
||||
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
|
||||
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
|
||||
|
||||
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Exposes the multi-node person-count deduplication divisor at runtime via REST:
|
||||
- `GET /api/v1/config/dedup-factor` — read current value.
|
||||
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.0–10.0, persisted).
|
||||
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
|
||||
person count (`{"count": N}`); derives optimal divisor from current node-sum.
|
||||
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
|
||||
|
||||
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
|
||||
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
|
||||
magnetic sensing path: scene → source synthesis (Biot–Savart, dipole,
|
||||
|
||||
@@ -14,9 +14,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-api` | REST API (Axum) |
|
||||
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration management |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
@@ -135,17 +132,14 @@ Crates must be published in dependency order:
|
||||
2. `wifi-densepose-vitals` (no internal deps)
|
||||
3. `wifi-densepose-wifiscan` (no internal deps)
|
||||
4. `wifi-densepose-hardware` (no internal deps)
|
||||
5. `wifi-densepose-config` (no internal deps)
|
||||
6. `wifi-densepose-db` (no internal deps)
|
||||
7. `wifi-densepose-signal` (depends on core)
|
||||
8. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
10. `wifi-densepose-train` (depends on signal, nn)
|
||||
11. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
12. `wifi-densepose-api` (no internal deps)
|
||||
13. `wifi-densepose-wasm` (depends on mat)
|
||||
14. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
15. `wifi-densepose-cli` (depends on mat)
|
||||
5. `wifi-densepose-signal` (depends on core)
|
||||
6. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
8. `wifi-densepose-train` (depends on signal, nn)
|
||||
9. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
10. `wifi-densepose-wasm` (depends on mat)
|
||||
11. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
12. `wifi-densepose-cli` (depends on mat)
|
||||
|
||||
### Validation & Witness Verification (ADR-028)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Built on [RuVector](https://github.com/ruvnet/ruvector/) and [Cognitum Seed](htt
|
||||
|
||||
The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.
|
||||
|
||||
RuView also supports pose estimation (17 COCO keypoints via the WiFlow architecture), trained entirely without cameras using 10 sensor signals — a technique pioneered from the original *DensePose From WiFi* research at Carnegie Mellon University.
|
||||
RuView **ships pretrained CSI weights on Hugging Face** at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — a self-supervised contrastive CSI encoder (128-dim embeddings, 12.2M training steps, 60K frames) + a presence-detection head reporting 100% accuracy on the validation set + per-node LoRA adapters. Models are released as `.safetensors`, 4-bit/8-bit/2-bit quantized `.bin` (4 KB–16 KB), and a JSONL RVF container. The Python training and evaluation tooling consumes these today via `safetensors`. **Pending wiring**: the sensing-server's `--model` flag still expects binary RVF, so live-server consumption of the JSONL bundle is gated on a JSONL adapter (or a re-publish in binary RVF) — see [Pretrained model on Hugging Face](#-pretrained-model-on-hugging-face) below for the workaround. **Not yet released**: a 17-keypoint pose-estimation model — training pipeline is implemented (WiFlow + AETHER + MERIDIAN heads) but camera-supervised fine-tune phases P7–P9 of [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) are `Pending`, tracked in [#509](https://github.com/ruvnet/RuView/issues/509). The live sensing server therefore drives the on-screen output from signal-based DSP heuristics today.
|
||||
|
||||
### Built for low-power edge applications
|
||||
|
||||
@@ -47,18 +47,26 @@ RuView also supports pose estimation (17 COCO keypoints via the WiFlow architect
|
||||
[](https://crates.io/crates/wifi-densepose-ruvector)
|
||||
|
||||
|
||||
> | What | How | Speed |
|
||||
> |------|-----|-------|
|
||||
> | 🦴 **Pose estimation** | CSI subcarrier amplitude/phase → 17 COCO keypoints | 171K emb/s (M4 Pro) |
|
||||
> | 🫁 **Breathing detection** | Bandpass 0.1-0.5 Hz → zero-crossing BPM | 6-30 BPM |
|
||||
> | 💓 **Heart rate** | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM |
|
||||
> | 👤 **Presence sensing** | Trained model + PIR fusion — 100% accuracy | 0.012 ms latency |
|
||||
> | 🧱 **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
|
||||
> | 🧠 **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $140 total BOM |
|
||||
> | 🎯 **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
|
||||
> | 📷 **Camera-supervised training** | MediaPipe + ESP32 CSI → **35%+ PCK@20 target** (ADR-079; eval phases pending) | ~19 min on laptop (pipeline) |
|
||||
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
|
||||
> | 🌐 **3D point cloud** *(optional fusion)* | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame |
|
||||
> | What | Status | How | Speed |
|
||||
> |------|--------|-----|-------|
|
||||
> | 🫁 **Breathing rate** | ✅ Works today | Bandpass 0.1-0.5 Hz → zero-crossing BPM, circular variance on wrapped phase ([#593](https://github.com/ruvnet/RuView/issues/593)) | 6-30 BPM |
|
||||
> | 💓 **Heart rate** | ✅ Works today | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM (needs good SNR) |
|
||||
> | 👤 **Presence detection** | ✅ Heuristic in server · 🤗 Trained head on HF (loader wiring pending) | Live server uses phase-variance vs adaptive threshold (60 s ambient calibration). A trained `presence-head.json` reporting 100% validation accuracy is published in [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) but the sensing-server's `--model` loader only accepts binary RVF today — JSONL adapter pending. | <1 ms heuristic |
|
||||
> | 🧬 **CSI embeddings** | 🤗 Trained encoder on HF | 128-dim contrastive encoder, **164,183 emb/s** on M4 Pro. Usable today from Python / training via `model.safetensors`; sensing-server consumption pending the same JSONL loader gap as above. | 8 KB q4 fits ESP32 SRAM |
|
||||
> | 🚶 **Motion / activity** | ✅ Works today | Motion-band power + phase acceleration | Real-time |
|
||||
> | 🤸 **Fall detection** | ✅ Works today | Phase acceleration > threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
|
||||
> | 🧮 **Multi-person slot count** | ⚠️ Heuristic, not learned | Subcarrier diversity divided by 2 (capped). **Not** a learned counter — see [firmware README](firmware/esp32-csi-node/README.md#tier-2--full-pipeline-stable) "Tier 2 caveats". Adaptive normalisation in [#491](https://github.com/ruvnet/RuView/pull/491). | Real-time |
|
||||
> | 🦴 **17-keypoint pose estimation** | 🔬 Pipeline only, no shipped weights | Training infrastructure complete (WiFlow + AETHER + MERIDIAN heads); the published HF model is presence + embeddings, not keypoints. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509). | Pending data collection |
|
||||
> | 🧱 **Through-wall sensing** | ✅ Works today | Fresnel zone geometry + multipath modeling | Up to ~5m signal-dependent |
|
||||
> | 🧠 **Edge intelligence** | ✅ Works today | Optional Cognitum Seed for persistent vector store + kNN + witness chain | $140 total BOM |
|
||||
> | 🎯 **Camera-free pre-training** | ✅ Shipped weights on HF | Self-supervised contrastive encoder, 12.2M training steps on 60K frames. See [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). | 84 s/epoch retrain on M4 Pro |
|
||||
> | 📷 **Camera-supervised fine-tune** | 🔬 Pipeline only | MediaPipe + ESP32 CSI paired training, [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md). Target **35%+ PCK@20**. P7–P9 (data + train + eval) `Pending`. | ~19 min/epoch on laptop |
|
||||
> | 📡 **Multi-frequency mesh** | ✅ Works today | Channel hopping across 6 bands, TDM slot scheduling (ADR-029) | 3x sensing bandwidth |
|
||||
> | 🌐 **3D point cloud fusion** | 🔬 Reference impl | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model. Requires camera. | 22 ms pipeline · 19K+ points/frame |
|
||||
>
|
||||
> Legend: ✅ shipped + tested on hardware (some have learned weights on [HF](https://huggingface.co/ruvnet/wifi-densepose-pretrained), others are deterministic DSP) · ⚠️ ships and runs, but is a heuristic/threshold (not a learned classifier) — accuracy depends on calibration · 🔬 implementation + tests in repo, weights/data/eval pending
|
||||
>
|
||||
> 🤗 **Pretrained weights**: download from [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — see [Loading the pretrained model](#loading-the-pretrained-model) below for one-command setup.
|
||||
|
||||
```bash
|
||||
# Option 1: Docker (simulated data, no hardware needed)
|
||||
@@ -88,10 +96,10 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
>
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence indicator, motion, breathing rate, heart rate, fall detection, slot-count multi-person heuristic + persistent vector store, kNN search, witness chain, MCP proxy. (Pose pending weights — see [#509](https://github.com/ruvnet/RuView/issues/509).) |
|
||||
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
|
||||
>
|
||||
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
|
||||
>
|
||||
@@ -115,6 +123,31 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
|
||||
|
||||
|
||||
## 🤗 Pretrained model on Hugging Face
|
||||
|
||||
Pretrained CSI weights live at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 12.2M training steps on 60K frames / 610K contrastive triplets, **100% presence accuracy** on the validation set, 4-bit quantized variant fits in 8 KB. The release includes a contrastive **CSI encoder** producing 128-dim embeddings (164,183 emb/s on M4 Pro) and a **presence-detection head**. Per-node LoRA adapters are included for environment-specific fine-tuning.
|
||||
|
||||
```bash
|
||||
# Download the model bundle
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wifi-densepose-pretrained
|
||||
```
|
||||
|
||||
**What works today vs. what's pending wiring:**
|
||||
|
||||
| Consumer | Format used | Status |
|
||||
|----------|-------------|--------|
|
||||
| Python training / evaluation / embedding extraction | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
|
||||
| Inspect / re-export the bundle | `model.rvf.jsonl` (line-by-line JSON) | ✅ Works — plain JSONL |
|
||||
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Loader does not yet accept the JSONL container |
|
||||
|
||||
**Known gap:** the HF model ships in JSONL RVF format, but `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format. Pointing `--model` at `model.rvf.jsonl` currently errors with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` and the live pipeline degrades to null output rather than falling back to heuristic mode — so for the live sensing-server, run **without** `--model` until a JSONL adapter lands (or the model is re-published as binary RVF). Use the weights from Python / training in the meantime.
|
||||
|
||||
**Quantization choices** (all in the HF repo): `model-q2.bin` (4 KB) · `model-q4.bin` ⭐ recommended (8 KB) · `model-q8.bin` (16 KB) · `model.safetensors` full (48 KB)
|
||||
|
||||
The separate **17-keypoint pose-estimation model** is not in this release — pipeline is implemented but keypoint weights are still pending. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases P7–P9.
|
||||
|
||||
|
||||
## 🔬 How It Works
|
||||
|
||||
WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened:
|
||||
|
||||
@@ -1 +1 @@
|
||||
8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6
|
||||
667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7
|
||||
@@ -164,18 +164,44 @@ def frame_to_csi_data(frame, signal_meta):
|
||||
)
|
||||
|
||||
|
||||
# Quantization precision for cross-platform hash stability (issue #560).
|
||||
#
|
||||
# The bytes packed below feed SHA-256. Without quantization, the hash diverges
|
||||
# across SIMD backends (Intel AVX2/AVX-512 vs ARM NEON vs different x86 micro-
|
||||
# architectures in the same CI pool) because scipy.fft's pocketfft kernels
|
||||
# reorder vectorized FP operations differently per build. IEEE 754 guarantees
|
||||
# per-operation determinism, not associativity under reordering.
|
||||
#
|
||||
# Empirically: 9 decimals was NOT enough to collapse the divergence — two
|
||||
# back-to-back Ubuntu 24.04 / Python 3.11 / scipy 1.17 CI runs landed on
|
||||
# different Azure VM microarchitectures (likely Skylake vs Cascade Lake)
|
||||
# and produced two different SHA-256s even after np.round(.., 9). The DSP
|
||||
# pipeline (preprocess → biquad bandpass → FFT → PSD → variance accumulation)
|
||||
# amplifies the ~1e-14 raw FFT divergence by several orders of magnitude
|
||||
# downstream — the actual drift at features_to_bytes() input can reach 1e-7
|
||||
# or worse.
|
||||
#
|
||||
# 6 decimals (parts per million) gives ~6 orders of magnitude headroom over
|
||||
# observed pipeline-amplified ULP drift and is still far below any meaningful
|
||||
# signal change (CSI phase precision is ~1e-3 rad; PSD bins differ by orders
|
||||
# of magnitude). Round to this precision, then hash.
|
||||
HASH_QUANTIZATION_DECIMALS = 6
|
||||
|
||||
|
||||
def features_to_bytes(features):
|
||||
"""Convert CSIFeatures to a deterministic byte representation.
|
||||
|
||||
We serialize each numpy array to bytes in a canonical order
|
||||
using little-endian float64 representation. This ensures the
|
||||
hash is platform-independent for IEEE 754 compliant systems.
|
||||
Each feature array is quantized to ``HASH_QUANTIZATION_DECIMALS`` decimal
|
||||
places before being packed as little-endian float64. The quantization is
|
||||
what makes the resulting SHA-256 hash actually platform-independent — the
|
||||
raw float values diverge at ULP precision across scipy.fft SIMD backends
|
||||
(issue #560), even though all platforms compute the "correct" answer.
|
||||
|
||||
Args:
|
||||
features: CSIFeatures instance.
|
||||
|
||||
Returns:
|
||||
bytes: Canonical byte representation.
|
||||
bytes: Canonical, quantized byte representation.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
@@ -189,6 +215,10 @@ def features_to_bytes(features):
|
||||
features.power_spectral_density,
|
||||
]:
|
||||
flat = np.asarray(array, dtype=np.float64).ravel()
|
||||
# Quantize before packing so SIMD-level FP reordering across
|
||||
# Intel AVX vs Apple Silicon NEON pocketfft kernels does not
|
||||
# leak into the SHA-256 input.
|
||||
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
|
||||
# Pack as little-endian double (8 bytes each)
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
|
||||
|
||||
@@ -220,7 +220,11 @@ class PoseService:
|
||||
# Apply phase sanitization if we have phase data
|
||||
if hasattr(detection_result.features, 'phase_difference'):
|
||||
phase_data = detection_result.features.phase_difference
|
||||
sanitized_phase = self.phase_sanitizer.sanitize(phase_data)
|
||||
# PhaseSanitizer's full-pipeline method is sanitize_phase,
|
||||
# not sanitize (issue #612). The shorter name was an
|
||||
# AttributeError waiting to fire on any code path that
|
||||
# reaches this branch.
|
||||
sanitized_phase = self.phase_sanitizer.sanitize_phase(phase_data)
|
||||
# Combine amplitude and phase data
|
||||
return np.concatenate([amplitude_data, sanitized_phase])
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+65
-3
@@ -29,13 +29,14 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
8. [Vital Sign Detection](#vital-sign-detection)
|
||||
9. [CLI Reference](#cli-reference)
|
||||
10. [Observatory Visualization](#observatory-visualization)
|
||||
11. [Adaptive Classifier](#adaptive-classifier)
|
||||
11. [Loading the Pretrained Model from Hugging Face](#loading-the-pretrained-model-from-hugging-face)
|
||||
12. [Adaptive Classifier](#adaptive-classifier)
|
||||
- [Recording Training Data](#recording-training-data)
|
||||
- [Training the Model](#training-the-model)
|
||||
- [Using the Trained Model](#using-the-trained-model)
|
||||
12. [Training a Model](#training-a-model)
|
||||
13. [Training a Model](#training-a-model)
|
||||
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
|
||||
13. [RVF Model Containers](#rvf-model-containers)
|
||||
14. [RVF Model Containers](#rvf-model-containers)
|
||||
14. [Hardware Setup](#hardware-setup)
|
||||
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
||||
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
||||
@@ -793,6 +794,67 @@ The Observatory is an immersive Three.js visualization that renders WiFi sensing
|
||||
|
||||
---
|
||||
|
||||
## Loading the Pretrained Model from Hugging Face
|
||||
|
||||
A pretrained CSI encoder + presence-detection head is published on Hugging Face at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). It was trained on 60,630 frames / 610,615 contrastive triplets (12.2M steps, final loss 0.065) and reports 100% presence accuracy and ~164k embeddings/sec on an Apple M4 Pro.
|
||||
|
||||
What it ships (and what it does not):
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
|
||||
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
|
||||
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
|
||||
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
|
||||
### Download
|
||||
|
||||
```bash
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download ruvnet/wifi-densepose-pretrained \
|
||||
--local-dir models/wifi-densepose-pretrained
|
||||
```
|
||||
|
||||
The download yields a small set of files (the `.rvf.jsonl` is the canonical container the sensing server reads):
|
||||
|
||||
```
|
||||
models/wifi-densepose-pretrained/
|
||||
model.rvf.jsonl # RVF container (encoder + presence head + lora)
|
||||
model.safetensors # 48 KB — same encoder weights, safetensors format
|
||||
model-q4.bin # 8 KB — recommended quantization for edge
|
||||
presence-head.json # presence classifier head
|
||||
config.json # sona-lora rank=8 alpha=16, target encoder + task_heads
|
||||
```
|
||||
|
||||
### Using the weights
|
||||
|
||||
The HF artifact is in **JSONL RVF** format (one JSON object per line: `metadata`, `encoder`, `lora`). What you can do with it today:
|
||||
|
||||
| Consumer | Format it reads | Status |
|
||||
|----------|-----------------|--------|
|
||||
| Python / PyTorch training pipeline | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
|
||||
| RVF JSONL inspection / re-export | `model.rvf.jsonl` | ✅ Works — plain JSONL, parse line-by-line |
|
||||
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Does **not** accept the JSONL file yet — see gap below |
|
||||
|
||||
**Known gap (tracked):** `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format (magic `0x52564653`). Pointing `--model` at `model.rvf.jsonl` causes the progressive loader to error with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` (`0x7974227B` is the ASCII bytes `{"ty…` from the JSONL header), and the live pipeline degrades to null output rather than falling back to heuristic mode. Until a JSONL adapter lands (or the model is re-published as binary RVF), run the sensing-server **without** `--model` and consume the HF weights from Python or the training pipeline.
|
||||
|
||||
```bash
|
||||
# Works today — Python side (training, evaluation, embedding extraction):
|
||||
python -c "
|
||||
from safetensors.torch import load_file
|
||||
state = load_file('models/wifi-densepose-pretrained/model.safetensors')
|
||||
print({k: tuple(v.shape) for k, v in state.items()})
|
||||
"
|
||||
|
||||
# Sensing server — run heuristic for now:
|
||||
cargo run -p wifi-densepose-sensing-server --release -- \
|
||||
--source esp32 --udp-port 5005 --http-port 3000
|
||||
```
|
||||
|
||||
See [RVF Model Containers](#rvf-model-containers) for the binary format the loader expects, and [Training a Model](#training-a-model) for using the encoder as a starting point for environment-specific fine-tuning.
|
||||
|
||||
---
|
||||
|
||||
## Adaptive Classifier
|
||||
|
||||
The adaptive classifier (ADR-048) learns your environment's specific WiFi signal patterns from labeled recordings. It replaces static threshold-based classification with a trained logistic regression model that uses 15 features (7 server-computed + 8 subcarrier-derived statistics).
|
||||
|
||||
@@ -8,3 +8,6 @@ dependencies:
|
||||
|
||||
## LCD touch abstraction
|
||||
espressif/esp_lcd_touch: "^1.0"
|
||||
|
||||
## Onboard WS2812 LED Disabling
|
||||
espressif/led_strip: "^3.0.0"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "nvs_flash.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "stream_sender.h"
|
||||
@@ -149,6 +150,23 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — v%s — Node ID: %d",
|
||||
app_desc->version, g_nvs_config.node_id);
|
||||
|
||||
/* Turn off onboard WS2812 LED on GPIO 38 */
|
||||
led_strip_handle_t led_strip;
|
||||
led_strip_config_t strip_config = {
|
||||
.strip_gpio_num = 38,
|
||||
.max_leds = 1,
|
||||
.led_model = LED_MODEL_WS2812,
|
||||
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
|
||||
.flags.invert_out = false,
|
||||
};
|
||||
led_strip_rmt_config_t rmt_config = {
|
||||
.resolution_hz = 10 * 1000 * 1000, // 10MHz
|
||||
.flags.with_dma = false,
|
||||
};
|
||||
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
|
||||
led_strip_clear(led_strip);
|
||||
}
|
||||
|
||||
/* Initialize WiFi STA (skip entirely under QEMU mock — no RF hardware) */
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
wifi_init_sta();
|
||||
|
||||
@@ -109,7 +109,7 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
|
||||
switch (type) {
|
||||
case MR60_TYPE_BREATHING:
|
||||
if (len >= 4) {
|
||||
if (len >= sizeof(float)) {
|
||||
/* Breathing rate as float32 (little-endian in payload). */
|
||||
float br;
|
||||
memcpy(&br, data, sizeof(float));
|
||||
@@ -120,7 +120,7 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
break;
|
||||
|
||||
case MR60_TYPE_HEARTRATE:
|
||||
if (len >= 4) {
|
||||
if (len >= sizeof(float)) {
|
||||
float hr;
|
||||
memcpy(&hr, data, sizeof(float));
|
||||
if (hr >= 0.0f && hr <= 250.0f) {
|
||||
@@ -130,13 +130,13 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
break;
|
||||
|
||||
case MR60_TYPE_DISTANCE:
|
||||
if (len >= 8) {
|
||||
if (len >= sizeof(uint32_t) + sizeof(float)) {
|
||||
/* Bytes 0-3: range flag (uint32 LE). 0 = no valid distance. */
|
||||
uint32_t range_flag;
|
||||
memcpy(&range_flag, data, sizeof(uint32_t));
|
||||
if (range_flag != 0 && len >= 8) {
|
||||
if (range_flag != 0) {
|
||||
float dist;
|
||||
memcpy(&dist, &data[4], sizeof(float));
|
||||
memcpy(&dist, &data[sizeof(uint32_t)], sizeof(float));
|
||||
s_state.distance_cm = dist;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,24 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
|
||||
|
||||
/**
|
||||
* ADR-050: Verify the Authorization header contains the correct PSK.
|
||||
* Returns true if auth is disabled (no PSK provisioned) or if the
|
||||
* Bearer token matches the stored PSK.
|
||||
* Returns true only when a PSK is provisioned AND the Bearer token
|
||||
* matches it. An unprovisioned node refuses all OTA requests
|
||||
* (fail-closed, see RuView#596 audit). The OTA server still starts so
|
||||
* the operator can `provision.py --ota-psk <hex>` over USB-CDC without
|
||||
* a reflash, but the upload endpoint will reject every request until
|
||||
* the PSK is set.
|
||||
*/
|
||||
static bool ota_check_auth(httpd_req_t *req)
|
||||
{
|
||||
if (s_ota_psk[0] == '\0') {
|
||||
/* No PSK provisioned — auth disabled (permissive for dev). */
|
||||
return true;
|
||||
/* No PSK provisioned — fail closed. Previously this returned
|
||||
* true ("permissive for dev"), which let any host on the WiFi
|
||||
* push attacker-controlled firmware to a freshly-flashed node.
|
||||
* Plain HTTP transport + no Secure Boot V2 + no signed-image
|
||||
* verification meant a single LAN call could brick or back-
|
||||
* door a node. Reject until provisioned. */
|
||||
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk <hex>)");
|
||||
return false;
|
||||
}
|
||||
|
||||
char auth_header[128] = {0};
|
||||
@@ -241,26 +251,45 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
/**
|
||||
* Load the OTA PSK from NVS into the module-local s_ota_psk cache and log
|
||||
* the resulting posture. Called by both ota_update_init() and
|
||||
* ota_update_init_ex() so the per-boot diagnostic prints no matter which
|
||||
* entry point main.c uses — historically only ota_update_init() loaded the
|
||||
* PSK, which left ota_update_init_ex() with an empty s_ota_psk and an
|
||||
* invisible fail-closed posture (RuView#596 follow-up).
|
||||
*/
|
||||
static void ota_load_psk_from_nvs(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(s_ota_psk);
|
||||
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until "
|
||||
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
|
||||
}
|
||||
nvs_close(nvs);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all "
|
||||
"requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
ota_load_psk_from_nvs();
|
||||
return ota_start_server(NULL);
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init_ex(void **out_server)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. main.c uses this
|
||||
* variant (not ota_update_init), so without this call s_ota_psk
|
||||
* stayed empty forever and the fail-closed posture was invisible
|
||||
* in serial logs. */
|
||||
ota_load_psk_from_nvs();
|
||||
return ota_start_server((httpd_handle_t *)out_server);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,39 @@ NVS_PARTITION_OFFSET = 0x9000
|
||||
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
|
||||
|
||||
|
||||
CONFIG_VALUE_CHECKS = [
|
||||
("ssid", bool),
|
||||
("password", lambda value: value is not None),
|
||||
("target_ip", bool),
|
||||
("target_port", lambda value: value is not None),
|
||||
("node_id", lambda value: value is not None),
|
||||
("tdm_slot", lambda value: value is not None),
|
||||
("tdm_total", lambda value: value is not None),
|
||||
("edge_tier", lambda value: value is not None),
|
||||
("pres_thresh", lambda value: value is not None),
|
||||
("fall_thresh", lambda value: value is not None),
|
||||
("vital_win", lambda value: value is not None),
|
||||
("vital_int", lambda value: value is not None),
|
||||
("subk_count", lambda value: value is not None),
|
||||
("channel", lambda value: value is not None),
|
||||
("filter_mac", lambda value: value is not None),
|
||||
("hop_channels", lambda value: value is not None),
|
||||
("seed_url", lambda value: value is not None),
|
||||
("seed_token", lambda value: value is not None),
|
||||
("zone", lambda value: value is not None),
|
||||
("swarm_hb", lambda value: value is not None),
|
||||
("swarm_ingest", lambda value: value is not None),
|
||||
]
|
||||
|
||||
|
||||
def has_config_value(args):
|
||||
"""Return True when args include at least one NVS-writing config value."""
|
||||
return any(
|
||||
check(getattr(args, name, None))
|
||||
for name, check in CONFIG_VALUE_CHECKS
|
||||
)
|
||||
|
||||
|
||||
def build_nvs_csv(args):
|
||||
"""Build an NVS CSV string for the csi_cfg namespace."""
|
||||
buf = io.StringIO()
|
||||
@@ -223,17 +256,7 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
has_value = any([
|
||||
args.ssid, args.password is not None, args.target_ip,
|
||||
args.target_port, args.node_id is not None,
|
||||
args.tdm_slot is not None, args.tdm_total is not None,
|
||||
args.edge_tier is not None, args.pres_thresh is not None,
|
||||
args.fall_thresh is not None, args.vital_win is not None,
|
||||
args.vital_int is not None, args.subk_count is not None,
|
||||
args.channel is not None, args.filter_mac is not None,
|
||||
args.seed_url is not None, args.zone is not None,
|
||||
])
|
||||
if not has_value:
|
||||
if not has_config_value(args):
|
||||
parser.error("At least one config value must be specified")
|
||||
|
||||
# Bug 2 (#391): Prevent silent wipe of WiFi credentials on partial invocations.
|
||||
|
||||
@@ -34,3 +34,11 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# Extra WiFi IRAM placement (defense-in-depth for RuView#396 SPI cache race)
|
||||
CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y
|
||||
|
||||
# ADR-081: adaptive_controller runs emit_feature_state + stream_sender
|
||||
# network I/O inside Timer Svc callbacks, exceeding the 2 KiB default.
|
||||
# Without this, the device bootloops with
|
||||
# "***ERROR*** A stack overflow in task Tmr Svc has been detected."
|
||||
# Was present in sdkconfig.defaults.template but missing here — fixed
|
||||
# in the v0.6.5-esp32 release.
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import csv
|
||||
import importlib.util
|
||||
import io
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
|
||||
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
|
||||
provision = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(provision)
|
||||
|
||||
|
||||
def make_args(**overrides):
|
||||
values = {name: None for name, _ in provision.CONFIG_VALUE_CHECKS}
|
||||
values["hop_dwell"] = 200
|
||||
values.update(overrides)
|
||||
return types.SimpleNamespace(**values)
|
||||
|
||||
|
||||
def csv_rows(content):
|
||||
return list(csv.DictReader(io.StringIO(content)))
|
||||
|
||||
|
||||
class ProvisionConfigValueTests(unittest.TestCase):
|
||||
def test_swarm_and_hopping_flags_count_as_config_values(self):
|
||||
cases = [
|
||||
{"hop_channels": "1,6,11"},
|
||||
{"seed_token": "token-123"},
|
||||
{"swarm_hb": 15},
|
||||
{"swarm_ingest": 3},
|
||||
]
|
||||
|
||||
for values in cases:
|
||||
with self.subTest(values=values):
|
||||
self.assertTrue(provision.has_config_value(make_args(**values)))
|
||||
|
||||
def test_operational_flags_alone_do_not_count_as_config_values(self):
|
||||
self.assertFalse(provision.has_config_value(make_args()))
|
||||
|
||||
def test_swarm_and_hopping_values_are_written_to_csv(self):
|
||||
args = make_args(
|
||||
hop_channels="1,6,11",
|
||||
hop_dwell=250,
|
||||
seed_token="token-123",
|
||||
swarm_hb=15,
|
||||
swarm_ingest=3,
|
||||
)
|
||||
|
||||
rows = csv_rows(provision.build_nvs_csv(args))
|
||||
values_by_key = {row["key"]: row["value"] for row in rows}
|
||||
|
||||
self.assertEqual(values_by_key["hop_count"], "3")
|
||||
self.assertEqual(values_by_key["chan_list"], "01060b")
|
||||
self.assertEqual(values_by_key["dwell_ms"], "250")
|
||||
self.assertEqual(values_by_key["seed_token"], "token-123")
|
||||
self.assertEqual(values_by_key["swarm_hb"], "15")
|
||||
self.assertEqual(values_by_key["swarm_ingest"], "3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1 +1 @@
|
||||
0.6.4
|
||||
0.6.5
|
||||
+2
-2
@@ -7,7 +7,7 @@ torchvision>=0.13.0
|
||||
# API dependencies
|
||||
fastapi>=0.95.0
|
||||
uvicorn>=0.20.0
|
||||
websockets>=10.4
|
||||
websockets>=15.0.1
|
||||
pydantic>=1.10.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
python-multipart>=0.0.6
|
||||
@@ -26,7 +26,7 @@ click>=8.0.0
|
||||
alembic>=1.10.0
|
||||
|
||||
# Hardware interface dependencies
|
||||
asyncio-mqtt>=0.11.0
|
||||
asyncio-mqtt>=0.16.2
|
||||
aiohttp>=3.13.5
|
||||
paramiko>=3.0.0
|
||||
|
||||
|
||||
@@ -171,6 +171,48 @@
|
||||
"require": ["--force-partial"],
|
||||
"rationale": "The per-node TDM/channel overlay intentionally omits WiFi creds (those live in the base flash image). Without --force-partial the issue #391 wifi-trio guard in provision.py rejects the call and breaks the Swarm Test (ADR-062) job. Was red on main for ~5 weeks before PR #590.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/590"
|
||||
},
|
||||
{
|
||||
"id": "RuView#615",
|
||||
"title": "path_safety::safe_id gates user-controlled IDs at filesystem boundaries",
|
||||
"files": [
|
||||
"v2/crates/wifi-densepose-sensing-server/src/path_safety.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/recording.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/model_manager.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/training_api.rs"
|
||||
],
|
||||
"require": [
|
||||
"path_safety::safe_id",
|
||||
"pub fn safe_id"
|
||||
],
|
||||
"rationale": "Five endpoints used to embed user-controlled identifiers (session_name, model_id, dataset_id, recording id) into format!() paths with no sanitization, allowing classic '../../etc/passwd' reads, writes, and deletes on the server filesystem. The safe_id helper enforces [A-Za-z0-9._-] only (no leading '.', max 64 chars) and must run before any user input reaches a format!() that builds a path. Removing the helper or skipping it at any of these call sites reintroduces the #615 attack surface.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/615"
|
||||
},
|
||||
{
|
||||
"id": "RuView#596-ota-fail-closed",
|
||||
"title": "ESP32 OTA upload fails closed when no PSK is provisioned",
|
||||
"files": ["firmware/esp32-csi-node/main/ota_update.c"],
|
||||
"require": [
|
||||
"fail-closed, see RuView#596 audit",
|
||||
"OTA rejected: no PSK in NVS"
|
||||
],
|
||||
"forbid": [
|
||||
"/auth disabled \\(permissive for dev\\)/",
|
||||
"/No PSK provisioned \\u2014 auth disabled/"
|
||||
],
|
||||
"rationale": "ota_check_auth previously returned true when s_ota_psk[0] == '\\0', so any host on the WiFi could push attacker-controlled firmware to a freshly-flashed node over plain HTTP on port 8032 — no Secure Boot V2, no signed-image verification, single LAN call could brick or backdoor a node. Flagged in the deep-review of PR #596. Fail-closed means the OTA server still starts (so operators can provision a PSK via USB-CDC without reflashing) but the upload endpoint refuses every request until provision.py --ota-psk <hex> writes the NVS key. Reverting this lets the rogue-LAN attack reopen.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/596#pullrequestreview"
|
||||
},
|
||||
{
|
||||
"id": "RuView#560",
|
||||
"title": "verify.py quantizes features before SHA-256 for cross-platform hash stability",
|
||||
"files": ["archive/v1/data/proof/verify.py"],
|
||||
"require": [
|
||||
"HASH_QUANTIZATION_DECIMALS",
|
||||
"np.round(flat, HASH_QUANTIZATION_DECIMALS)"
|
||||
],
|
||||
"rationale": "Without quantization, the SHA-256 of features_to_bytes() diverges across SIMD backends (Intel AVX2/AVX-512 vs Apple Silicon NEON) because scipy.fft's pocketfft kernels reorder vectorized FP operations differently per build. IEEE 754 guarantees per-operation determinism, not associativity. Rounding to 9 decimal places (~5 orders of magnitude headroom over observed ULP drift) collapses the cross-platform divergence to a single canonical hash. Removing the round() call reintroduces the macOS arm64 vs Linux x86_64 hash mismatch in issue #560.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/560"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,16 +4,21 @@
|
||||
Runs the same scipy.fft.fft / scipy.signal calls that verify.py hashes
|
||||
(csi_processor.py:426, :438, :349) on a deterministic synthetic input,
|
||||
without dragging in src.app / pydantic Settings. Used to empirically
|
||||
locate the source of platform divergence in issue #560.
|
||||
locate the source of platform divergence in issue #560 — and now also to
|
||||
verify the quantize-before-hash fix shipped in archive/v1/data/proof/verify.py.
|
||||
|
||||
Usage: python3 scripts/probe-fft-platform.py
|
||||
Output: single JSON object on stdout. Run on each platform and diff.
|
||||
|
||||
If two machines print the same `first8_doppler_bytes_hex` and the same
|
||||
`first4_psd_floats` but different `sha256`, the divergence is in later
|
||||
FFT bins (SIMD reordering). If even the first values differ, it's a
|
||||
true ULP-level divergence at every bin (Apple Silicon NEON vs x86_64
|
||||
AVX, or different scipy pocketfft builds).
|
||||
The output now contains TWO hashes:
|
||||
- `sha256_raw` — hash of unrounded little-endian f64 bytes (legacy)
|
||||
- `sha256_quantized` — hash after np.round(.., 9) (matches verify.py
|
||||
behaviour after the issue-#560 fix; should be
|
||||
IDENTICAL across Intel AVX, ARM NEON, and any
|
||||
scipy pocketfft build)
|
||||
|
||||
If `sha256_raw` differs across machines but `sha256_quantized` matches,
|
||||
the quantize-before-hash fix is doing its job.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
@@ -40,12 +45,26 @@ doppler = np.abs(scipy.fft.fft(mean_phase_diff, n=64)) ** 2
|
||||
psd = np.abs(scipy.fft.fft(amp.flatten(), n=128)) ** 2
|
||||
window = scipy.signal.windows.hamming(56)
|
||||
|
||||
# Pack the same way verify.py:features_to_bytes does (little-endian f64)
|
||||
parts = []
|
||||
for arr in (doppler, psd, window):
|
||||
flat = np.asarray(arr, dtype=np.float64).ravel()
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
blob = b"".join(parts)
|
||||
# Quantization decimals — kept in sync with
|
||||
# archive/v1/data/proof/verify.py:HASH_QUANTIZATION_DECIMALS so this probe
|
||||
# verifies the production hash, not just the FFT outputs.
|
||||
HASH_QUANTIZATION_DECIMALS = 6
|
||||
|
||||
|
||||
def pack_floats(arrays, quantize):
|
||||
"""Pack arrays as little-endian f64, optionally rounding first."""
|
||||
parts = []
|
||||
for arr in arrays:
|
||||
flat = np.asarray(arr, dtype=np.float64).ravel()
|
||||
if quantize:
|
||||
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
arrays = (doppler, psd, window)
|
||||
blob_raw = pack_floats(arrays, quantize=False)
|
||||
blob_quantized = pack_floats(arrays, quantize=True)
|
||||
|
||||
try:
|
||||
blas_info = np.show_config(mode="dicts")
|
||||
@@ -57,8 +76,10 @@ print(json.dumps({
|
||||
"python": sys.version.split()[0],
|
||||
"numpy": np.__version__,
|
||||
"scipy": __import__("scipy").__version__,
|
||||
"blob_len": len(blob),
|
||||
"sha256": hashlib.sha256(blob).hexdigest(),
|
||||
"blob_len": len(blob_raw),
|
||||
"sha256_raw": hashlib.sha256(blob_raw).hexdigest(),
|
||||
"sha256_quantized": hashlib.sha256(blob_quantized).hexdigest(),
|
||||
"quantization_decimals": HASH_QUANTIZATION_DECIMALS,
|
||||
"first8_doppler_bytes_hex": doppler[:8].tobytes().hex(),
|
||||
"first4_psd_floats": psd[:4].tolist(),
|
||||
"blas_backend": blas_info if isinstance(blas_info, dict) else str(blas_info),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"no-undef": "error",
|
||||
"no-var": "error",
|
||||
"prefer-const": "warn",
|
||||
"eqeqeq": ["error", "always"],
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-new-func": "error",
|
||||
"no-script-url": "error",
|
||||
"no-alert": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error", "info"] }],
|
||||
"curly": ["warn", "multi-line"],
|
||||
"no-throw-literal": "error",
|
||||
"prefer-template": "warn",
|
||||
"no-duplicate-imports": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"mobile/",
|
||||
"vendor/",
|
||||
"*.min.js"
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,24 @@ import { wsService } from './services/websocket.service.js';
|
||||
import { healthService } from './services/health.service.js';
|
||||
import { sensingService } from './services/sensing.service.js';
|
||||
import { backendDetector } from './utils/backend-detector.js';
|
||||
import { KeyboardShortcuts } from './utils/keyboard-shortcuts.js';
|
||||
import { PerfMonitor } from './utils/perf-monitor.js';
|
||||
import { toastManager } from './utils/toast.js';
|
||||
import { ThemeToggle } from './utils/theme-toggle.js';
|
||||
import { CommandPalette } from './utils/command-palette.js';
|
||||
import { ActivityLog } from './utils/activity-log.js';
|
||||
import { DataExport } from './utils/data-export.js';
|
||||
import { FullscreenManager } from './utils/fullscreen.js';
|
||||
import { ConnectionStatus } from './utils/connection-status.js';
|
||||
import { MobileNav } from './utils/mobile-nav.js';
|
||||
import { Router } from './utils/router.js';
|
||||
import { Onboarding } from './utils/onboarding.js';
|
||||
import { IdleManager } from './utils/idle-manager.js';
|
||||
import { NotificationCenter } from './utils/notification-center.js';
|
||||
import { i18n } from './utils/i18n.js';
|
||||
import { ScreenshotTool } from './utils/screenshot.js';
|
||||
import { UptimeClock } from './utils/uptime-clock.js';
|
||||
import { QuickSettings } from './utils/quick-settings.js';
|
||||
|
||||
class WiFiDensePoseApp {
|
||||
constructor() {
|
||||
@@ -30,10 +48,13 @@ class WiFiDensePoseApp {
|
||||
|
||||
// Initialize UI components
|
||||
this.initializeComponents();
|
||||
|
||||
|
||||
// Initialize enhancements
|
||||
this.initializeEnhancements();
|
||||
|
||||
// Set up global event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('WiFi DensePose UI initialized successfully');
|
||||
|
||||
@@ -167,6 +188,118 @@ class WiFiDensePoseApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize enhancement modules
|
||||
initializeEnhancements() {
|
||||
// Toast notifications
|
||||
toastManager.init();
|
||||
|
||||
// Connection status widget in header
|
||||
this.connectionStatus = new ConnectionStatus();
|
||||
this.connectionStatus.init();
|
||||
|
||||
// Theme toggle
|
||||
this.themeToggle = new ThemeToggle();
|
||||
this.themeToggle.init();
|
||||
|
||||
// Performance monitor
|
||||
this.perfMonitor = new PerfMonitor();
|
||||
this.perfMonitor.init();
|
||||
|
||||
// Activity log
|
||||
this.activityLog = new ActivityLog();
|
||||
this.activityLog.init();
|
||||
|
||||
// Data export
|
||||
this.dataExport = new DataExport();
|
||||
this.dataExport.init();
|
||||
|
||||
// Fullscreen manager
|
||||
this.fullscreenManager = new FullscreenManager();
|
||||
this.fullscreenManager.init();
|
||||
|
||||
// Command palette (Ctrl+K)
|
||||
this.commandPalette = new CommandPalette(this);
|
||||
this.commandPalette.init();
|
||||
|
||||
// Mobile navigation (hamburger menu for small screens)
|
||||
this.mobileNav = new MobileNav();
|
||||
this.mobileNav.init();
|
||||
|
||||
// Notification center (bell icon in header)
|
||||
this.notificationCenter = new NotificationCenter();
|
||||
this.notificationCenter.init();
|
||||
|
||||
// Screenshot tool
|
||||
this.screenshotTool = new ScreenshotTool();
|
||||
this.screenshotTool.init();
|
||||
|
||||
// Uptime clock
|
||||
this.uptimeClock = new UptimeClock();
|
||||
this.uptimeClock.init();
|
||||
|
||||
// Quick settings panel
|
||||
this.quickSettings = new QuickSettings(this);
|
||||
this.quickSettings.init();
|
||||
|
||||
// Internationalization (EN/PL)
|
||||
i18n.init();
|
||||
|
||||
// Keyboard shortcuts (pass app reference for tab switching)
|
||||
this.keyboardShortcuts = new KeyboardShortcuts(this);
|
||||
this.keyboardShortcuts.register('l', 'Toggle activity log', () => {
|
||||
document.dispatchEvent(new CustomEvent('toggle-activity-log'));
|
||||
});
|
||||
this.keyboardShortcuts.register('e', 'Export sensor data', () => {
|
||||
document.dispatchEvent(new CustomEvent('export-data'));
|
||||
});
|
||||
this.keyboardShortcuts.register('f', 'Toggle fullscreen', () => {
|
||||
document.dispatchEvent(new CustomEvent('toggle-fullscreen'));
|
||||
});
|
||||
this.keyboardShortcuts.register('s', 'Take screenshot', () => {
|
||||
document.dispatchEvent(new CustomEvent('take-screenshot'));
|
||||
});
|
||||
this.keyboardShortcuts.init();
|
||||
|
||||
// Listen for show-shortcuts from command palette
|
||||
document.addEventListener('show-shortcuts', () => {
|
||||
this.keyboardShortcuts.showHelp();
|
||||
});
|
||||
|
||||
// Register PWA service worker
|
||||
this.registerServiceWorker();
|
||||
|
||||
// URL hash router (bookmarkable tabs)
|
||||
this.router = new Router(this);
|
||||
this.router.init();
|
||||
|
||||
// Idle detection (pause updates when inactive)
|
||||
this.idleManager = new IdleManager();
|
||||
this.idleManager.onIdle(() => {
|
||||
healthService.stopHealthMonitoring();
|
||||
console.info('[App] Paused health monitoring (idle)');
|
||||
});
|
||||
this.idleManager.onActive(() => {
|
||||
healthService.startHealthMonitoring();
|
||||
console.info('[App] Resumed health monitoring (active)');
|
||||
});
|
||||
this.idleManager.init();
|
||||
|
||||
// Onboarding tour (first-run walkthrough)
|
||||
this.onboarding = new Onboarding(this);
|
||||
this.onboarding.init();
|
||||
}
|
||||
|
||||
// Register service worker for offline capability
|
||||
registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js').then(reg => {
|
||||
console.info('Service worker registered:', reg.scope);
|
||||
}).catch(err => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab changes
|
||||
handleTabChange(newTab, oldTab) {
|
||||
console.log(`Tab changed from ${oldTab} to ${newTab}`);
|
||||
@@ -272,45 +405,17 @@ class WiFiDensePoseApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Show backend status notification
|
||||
// Show backend status notification (uses enhanced toast system)
|
||||
showBackendStatus(message, type) {
|
||||
// Create status notification if it doesn't exist
|
||||
let statusToast = document.getElementById('backendStatusToast');
|
||||
if (!statusToast) {
|
||||
statusToast = document.createElement('div');
|
||||
statusToast.id = 'backendStatusToast';
|
||||
statusToast.className = 'backend-status-toast';
|
||||
document.body.appendChild(statusToast);
|
||||
}
|
||||
|
||||
statusToast.textContent = message;
|
||||
statusToast.className = `backend-status-toast ${type}`;
|
||||
statusToast.classList.add('show');
|
||||
|
||||
// Auto-hide success messages, keep warnings and errors longer
|
||||
const timeout = type === 'success' ? 3000 : 8000;
|
||||
setTimeout(() => {
|
||||
statusToast.classList.remove('show');
|
||||
}, timeout);
|
||||
const toastType = type === 'success' ? 'success' : 'warning';
|
||||
toastManager[toastType](message, {
|
||||
duration: type === 'success' ? 3000 : 8000
|
||||
});
|
||||
}
|
||||
|
||||
// Show global error message
|
||||
// Show global error message (uses enhanced toast system)
|
||||
showGlobalError(message) {
|
||||
// Create error toast if it doesn't exist
|
||||
let errorToast = document.getElementById('globalErrorToast');
|
||||
if (!errorToast) {
|
||||
errorToast = document.createElement('div');
|
||||
errorToast.id = 'globalErrorToast';
|
||||
errorToast.className = 'error-toast';
|
||||
document.body.appendChild(errorToast);
|
||||
}
|
||||
|
||||
errorToast.textContent = message;
|
||||
errorToast.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.classList.remove('show');
|
||||
}, 5000);
|
||||
toastManager.error(message, { duration: 6000 });
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
@@ -326,9 +431,29 @@ class WiFiDensePoseApp {
|
||||
|
||||
// Disconnect all WebSocket connections
|
||||
wsService.disconnectAll();
|
||||
|
||||
|
||||
// Stop health monitoring
|
||||
healthService.dispose();
|
||||
|
||||
// Dispose enhancements
|
||||
if (this.keyboardShortcuts) this.keyboardShortcuts.dispose();
|
||||
if (this.perfMonitor) this.perfMonitor.dispose();
|
||||
if (this.themeToggle) this.themeToggle.dispose();
|
||||
if (this.commandPalette) this.commandPalette.dispose();
|
||||
if (this.activityLog) this.activityLog.dispose();
|
||||
if (this.dataExport) this.dataExport.dispose();
|
||||
if (this.fullscreenManager) this.fullscreenManager.dispose();
|
||||
if (this.connectionStatus) this.connectionStatus.dispose();
|
||||
if (this.mobileNav) this.mobileNav.dispose();
|
||||
if (this.router) this.router.dispose();
|
||||
if (this.onboarding) this.onboarding.dispose();
|
||||
if (this.idleManager) this.idleManager.dispose();
|
||||
if (this.notificationCenter) this.notificationCenter.dispose();
|
||||
if (this.screenshotTool) this.screenshotTool.dispose();
|
||||
if (this.uptimeClock) this.uptimeClock.dispose();
|
||||
if (this.quickSettings) this.quickSettings.dispose();
|
||||
i18n.dispose();
|
||||
toastManager.dispose();
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
@@ -19,6 +19,33 @@ export class TabManager {
|
||||
tab.addEventListener('click', () => this.switchTab(tab));
|
||||
});
|
||||
|
||||
// Arrow key navigation within tab bar (WCAG)
|
||||
const nav = this.container.querySelector('.nav-tabs');
|
||||
if (nav) {
|
||||
nav.addEventListener('keydown', (e) => {
|
||||
const buttonTabs = this.tabs.filter(t => t.tagName === 'BUTTON' && !t.disabled);
|
||||
const currentIndex = buttonTabs.indexOf(document.activeElement);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % buttonTabs.length;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
nextIndex = (currentIndex - 1 + buttonTabs.length) % buttonTabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (e.key === 'End') {
|
||||
nextIndex = buttonTabs.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex >= 0) {
|
||||
e.preventDefault();
|
||||
buttonTabs[nextIndex].focus();
|
||||
this.switchTab(buttonTabs[nextIndex]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Activate first tab if none active
|
||||
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
|
||||
if (activeTab) {
|
||||
@@ -36,14 +63,22 @@ export class TabManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tab states
|
||||
// Update tab states and ARIA attributes
|
||||
this.tabs.forEach(tab => {
|
||||
tab.classList.toggle('active', tab === tabElement);
|
||||
const isActive = tab === tabElement;
|
||||
tab.classList.toggle('active', isActive);
|
||||
if (tab.hasAttribute('aria-selected')) {
|
||||
tab.setAttribute('aria-selected', String(isActive));
|
||||
}
|
||||
});
|
||||
|
||||
// Update content visibility
|
||||
// Update content visibility and ARIA
|
||||
this.tabContents.forEach(content => {
|
||||
content.classList.toggle('active', content.id === tabId);
|
||||
const isActive = content.id === tabId;
|
||||
content.classList.toggle('active', isActive);
|
||||
if (content.hasAttribute('role')) {
|
||||
content.setAttribute('aria-hidden', String(!isActive));
|
||||
}
|
||||
});
|
||||
|
||||
// Update active tab
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>RuView Icon Generator</title></head>
|
||||
<body>
|
||||
<p>Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png</p>
|
||||
<canvas id="c192" width="192" height="192"></canvas>
|
||||
<canvas id="c512" width="512" height="512"></canvas>
|
||||
<script>
|
||||
function drawIcon(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const s = canvas.width;
|
||||
// Background
|
||||
ctx.fillStyle = '#1f2121';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, s, s, s * 0.15);
|
||||
ctx.fill();
|
||||
// WiFi arcs
|
||||
ctx.strokeStyle = '#32b8c6';
|
||||
ctx.lineWidth = s * 0.035;
|
||||
ctx.lineCap = 'round';
|
||||
const cx = s * 0.5, cy = s * 0.55;
|
||||
[0.35, 0.25, 0.15].forEach(r => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, s * r, -Math.PI * 0.75, -Math.PI * 0.25);
|
||||
ctx.stroke();
|
||||
});
|
||||
// Center dot
|
||||
ctx.fillStyle = '#32b8c6';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, s * 0.03, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Person silhouette
|
||||
ctx.strokeStyle = '#21808d';
|
||||
ctx.lineWidth = s * 0.025;
|
||||
// Head
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy - s * 0.15, s * 0.045, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Body
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - s * 0.1);
|
||||
ctx.lineTo(cx, cy + s * 0.05);
|
||||
ctx.stroke();
|
||||
// Arms
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - s * 0.08, cy - s * 0.04);
|
||||
ctx.lineTo(cx + s * 0.08, cy - s * 0.04);
|
||||
ctx.stroke();
|
||||
// Legs
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy + s * 0.05);
|
||||
ctx.lineTo(cx - s * 0.06, cy + s * 0.15);
|
||||
ctx.moveTo(cx, cy + s * 0.05);
|
||||
ctx.lineTo(cx + s * 0.06, cy + s * 0.15);
|
||||
ctx.stroke();
|
||||
// Text
|
||||
ctx.fillStyle = '#f5f5f5';
|
||||
ctx.font = `bold ${s * 0.08}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('RuView', cx, s * 0.88);
|
||||
}
|
||||
drawIcon(document.getElementById('c192'));
|
||||
drawIcon(document.getElementById('c512'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+38
-30
@@ -3,40 +3,48 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#21808d">
|
||||
<meta name="description" content="WiFi-based human pose estimation, vital sign detection, and presence sensing through walls">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>WiFi DensePose: Human Tracking Through Walls</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||
<a href="#dashboard" class="skip-to-content">Skip to main content</a>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<header class="header" role="banner">
|
||||
<h1>WiFi DensePose</h1>
|
||||
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
|
||||
<p class="subtitle" data-i18n="dashboard.subtitle">Human Tracking Through Walls Using WiFi Signals</p>
|
||||
<div class="header-info">
|
||||
<span class="api-version"></span>
|
||||
<span class="api-environment"></span>
|
||||
<span class="overall-health"></span>
|
||||
<span class="api-version" aria-label="API version"></span>
|
||||
<span class="api-environment" aria-label="Environment"></span>
|
||||
<span class="overall-health" role="status" aria-live="polite" aria-label="System health"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="dashboard">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo">Live Demo</button>
|
||||
<button class="nav-tab" data-tab="architecture">Architecture</button>
|
||||
<button class="nav-tab" data-tab="performance">Performance</button>
|
||||
<button class="nav-tab" data-tab="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training">Training</button>
|
||||
<nav class="nav-tabs" role="tablist" aria-label="Main navigation">
|
||||
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true" aria-controls="dashboard">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false" aria-controls="hardware">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false" aria-controls="demo">Live Demo</button>
|
||||
<button class="nav-tab" data-tab="architecture" role="tab" aria-selected="false" aria-controls="architecture">Architecture</button>
|
||||
<button class="nav-tab" data-tab="performance" role="tab" aria-selected="false" aria-controls="performance">Performance</button>
|
||||
<button class="nav-tab" data-tab="applications" role="tab" aria-selected="false" aria-controls="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing" role="tab" aria-selected="false" aria-controls="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training" role="tab" aria-selected="false" aria-controls="training">Training</button>
|
||||
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
|
||||
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<section id="dashboard" class="tab-content active">
|
||||
<section id="dashboard" class="tab-content active" role="tabpanel" aria-labelledby="dashboard">
|
||||
<div class="hero-section">
|
||||
<h2>Revolutionary WiFi-Based Human Pose Detection</h2>
|
||||
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
|
||||
<p class="hero-description">
|
||||
AI can track your full-body movement through walls using just WiFi signals.
|
||||
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
|
||||
@@ -48,7 +56,7 @@
|
||||
|
||||
<!-- Live Status Panel -->
|
||||
<div class="live-status-panel">
|
||||
<h3>System Status</h3>
|
||||
<h3 data-i18n="dashboard.status">System Status</h3>
|
||||
<div class="status-grid">
|
||||
<div class="component-status" data-component="api">
|
||||
<span class="component-name">API Server</span>
|
||||
@@ -80,24 +88,24 @@
|
||||
|
||||
<!-- System Metrics -->
|
||||
<div class="system-metrics-panel">
|
||||
<h3>System Metrics</h3>
|
||||
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">CPU Usage</span>
|
||||
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
|
||||
<div class="progress-bar" data-type="cpu">
|
||||
<div class="progress-fill normal" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="cpu-usage">0%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">Memory Usage</span>
|
||||
<span class="metric-label" data-i18n="metrics.memory">Memory Usage</span>
|
||||
<div class="progress-bar" data-type="memory">
|
||||
<div class="progress-fill normal" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="memory-usage">0%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">Disk Usage</span>
|
||||
<span class="metric-label" data-i18n="metrics.disk">Disk Usage</span>
|
||||
<div class="progress-bar" data-type="disk">
|
||||
<div class="progress-fill normal" style="width: 0%"></div>
|
||||
</div>
|
||||
@@ -108,13 +116,13 @@
|
||||
|
||||
<!-- Features Status -->
|
||||
<div class="features-panel">
|
||||
<h3>Features</h3>
|
||||
<h3 data-i18n="dashboard.features">Features</h3>
|
||||
<div class="features-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Live Statistics -->
|
||||
<div class="live-stats-panel">
|
||||
<h3>Live Statistics</h3>
|
||||
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Active Persons</span>
|
||||
@@ -181,7 +189,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Hardware Tab -->
|
||||
<section id="hardware" class="tab-content">
|
||||
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
|
||||
<h2>Hardware Configuration</h2>
|
||||
|
||||
<div class="hardware-grid">
|
||||
@@ -259,7 +267,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Demo Tab -->
|
||||
<section id="demo" class="tab-content">
|
||||
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
|
||||
<h2>Live Demonstration</h2>
|
||||
|
||||
<div class="demo-controls">
|
||||
@@ -312,7 +320,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Architecture Tab -->
|
||||
<section id="architecture" class="tab-content">
|
||||
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
|
||||
<h2>System Architecture</h2>
|
||||
|
||||
<div class="architecture-flow">
|
||||
@@ -350,7 +358,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Performance Tab -->
|
||||
<section id="performance" class="tab-content">
|
||||
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
|
||||
<h2>Performance Analysis</h2>
|
||||
|
||||
<div class="performance-chart">
|
||||
@@ -422,7 +430,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Applications Tab -->
|
||||
<section id="applications" class="tab-content">
|
||||
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
|
||||
<h2>Real-World Applications</h2>
|
||||
|
||||
<div class="applications-grid">
|
||||
@@ -489,10 +497,10 @@
|
||||
</section>
|
||||
|
||||
<!-- Sensing Tab -->
|
||||
<section id="sensing" class="tab-content"></section>
|
||||
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
|
||||
|
||||
<!-- Training Tab -->
|
||||
<section id="training" class="tab-content">
|
||||
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
|
||||
<div class="tab-header">
|
||||
<h2>Model Training</h2>
|
||||
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "RuView - WiFi DensePose",
|
||||
"short_name": "RuView",
|
||||
"description": "WiFi-based human pose estimation, vital sign detection, and presence sensing through walls",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1f2121",
|
||||
"theme_color": "#21808d",
|
||||
"orientation": "any",
|
||||
"categories": ["utilities", "medical"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+149
-146
@@ -10,14 +10,14 @@
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.3",
|
||||
"@react-navigation/bottom-tabs": "^7.15.10",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@types/three": "^0.183.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"expo": "~55.0.4",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "0.85.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
@@ -29,20 +29,20 @@
|
||||
"react-native-wifi-reborn": "^4.13.6",
|
||||
"three": "^0.183.2",
|
||||
"victory-native": "^41.20.2",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"babel-preset-expo": "^55.0.10",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint": "^10.2.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-expo": "^55.0.9",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-worklets": "^0.7.4",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
@@ -1585,37 +1585,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
|
||||
"integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.2",
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.1"
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
|
||||
"integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
|
||||
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.0"
|
||||
"@eslint/core": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
|
||||
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1626,9 +1626,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
|
||||
"integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -1636,13 +1636,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
|
||||
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4473,17 +4473,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-navigation/bottom-tabs": {
|
||||
"version": "7.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.3.tgz",
|
||||
"integrity": "sha512-CIaHk5TuLeYlDgR1eij38kEbrQU5dAQxQZCC4Xv1wFZ/RRxppBopRMLzv2Il529a7mic6xG33OHcr9aEdOzq+A==",
|
||||
"version": "7.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.10.tgz",
|
||||
"integrity": "sha512-Ao/yYlrpr0cwYYGxt9FDMQk+tTSHNm4WTaszyhroINLdoEMuKH19k1tGFdYbRBKHJx1UIH8kD+EZTYW1w6LL3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/elements": "^2.9.8",
|
||||
"@react-navigation/elements": "^2.9.15",
|
||||
"color": "^4.2.3",
|
||||
"sf-symbols-typescript": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*",
|
||||
"react-native-safe-area-context": ">= 4.0.0",
|
||||
@@ -4491,9 +4491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/core": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.15.1.tgz",
|
||||
"integrity": "sha512-Fqr6qxfZJIC4ewho7LtTa9zz6hcOzohX7D1lcDfrkGaYkS5xBwEZViGNxCJK/czUc74ua8NThyrObQFjB6Q/RQ==",
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz",
|
||||
"integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/routers": "^7.5.3",
|
||||
@@ -4510,15 +4510,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/core/node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-navigation/elements": {
|
||||
"version": "2.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.8.tgz",
|
||||
"integrity": "sha512-3gpwUmVnDJYvK9nFmAA/YXw0hmT/C/lZx8RkRMK+ux9l1T+32EWnQFnn34Wa1BMDX8HN2r64yrlW93DIzKI7Uw==",
|
||||
"version": "2.9.15",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.15.tgz",
|
||||
"integrity": "sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
@@ -4527,7 +4527,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-masked-view/masked-view": ">= 0.2.0",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*",
|
||||
"react-native-safe-area-context": ">= 4.0.0"
|
||||
@@ -4539,12 +4539,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/native": {
|
||||
"version": "7.1.31",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.31.tgz",
|
||||
"integrity": "sha512-+YCUwtfDgsux59Q0LDHc3Zid9ih93ecUCFWZOH6/+eNoUGnWx77wjS6ZfvBO/7E+EiIup11IVShDzCHR4of8hw==",
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz",
|
||||
"integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.15.1",
|
||||
"@react-navigation/core": "^7.17.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^3.3.11",
|
||||
@@ -5033,20 +5033,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
"integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/type-utils": "8.56.1",
|
||||
"@typescript-eslint/utils": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/type-utils": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5056,9 +5056,9 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
@@ -5072,16 +5072,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5093,18 +5093,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||
"integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.56.1",
|
||||
"@typescript-eslint/types": "^8.56.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5115,18 +5115,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
|
||||
"integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1"
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5137,9 +5137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
|
||||
"integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5150,21 +5150,21 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
|
||||
"integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||
"@typescript-eslint/utils": "8.56.1",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5175,13 +5175,13 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
|
||||
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5193,21 +5193,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
|
||||
"integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.56.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5217,20 +5217,20 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
|
||||
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/typescript-estree": "8.56.1"
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5241,17 +5241,17 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
|
||||
"integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5770,14 +5770,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
@@ -7306,18 +7306,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
|
||||
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
|
||||
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.2",
|
||||
"@eslint/config-helpers": "^0.5.2",
|
||||
"@eslint/core": "^1.1.0",
|
||||
"@eslint/plugin-kit": "^0.6.0",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.5.5",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
@@ -7326,9 +7326,9 @@
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^9.1.1",
|
||||
"eslint-scope": "^9.1.2",
|
||||
"eslint-visitor-keys": "^5.0.1",
|
||||
"espree": "^11.1.1",
|
||||
"espree": "^11.2.0",
|
||||
"esquery": "^1.7.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -7339,7 +7339,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -7362,9 +7362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
|
||||
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
@@ -7472,9 +7472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz",
|
||||
"integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
@@ -14561,9 +14561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -14643,10 +14643,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
@@ -14751,15 +14754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
@@ -16361,9 +16364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -17039,9 +17042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.3",
|
||||
"@react-navigation/bottom-tabs": "^7.15.10",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@types/three": "^0.183.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"expo": "~55.0.4",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "0.85.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
@@ -32,20 +32,20 @@
|
||||
"react-native-wifi-reborn": "^4.13.6",
|
||||
"three": "^0.183.2",
|
||||
"victory-native": "^41.20.2",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"babel-preset-expo": "^55.0.10",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint": "^10.2.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-expo": "^55.0.9",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-worklets": "^0.7.4",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
|
||||
+1741
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
// RuView Service Worker - Offline caching for the dashboard shell
|
||||
// Strategy: Network-first for API calls, Cache-first for static assets
|
||||
|
||||
const CACHE_NAME = 'ruview-v1';
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/style.css',
|
||||
'/app.js',
|
||||
'/config/api.config.js',
|
||||
'/components/TabManager.js',
|
||||
'/components/DashboardTab.js',
|
||||
'/components/HardwareTab.js',
|
||||
'/components/LiveDemoTab.js',
|
||||
'/components/SensingTab.js',
|
||||
'/components/PoseDetectionCanvas.js',
|
||||
'/services/api.service.js',
|
||||
'/services/websocket.service.js',
|
||||
'/services/health.service.js',
|
||||
'/services/sensing.service.js',
|
||||
'/services/pose.service.js',
|
||||
'/services/stream.service.js',
|
||||
'/utils/backend-detector.js',
|
||||
'/utils/keyboard-shortcuts.js',
|
||||
'/utils/perf-monitor.js',
|
||||
'/utils/toast.js',
|
||||
'/utils/theme-toggle.js',
|
||||
'/utils/command-palette.js',
|
||||
'/utils/activity-log.js',
|
||||
'/utils/data-export.js',
|
||||
'/utils/fullscreen.js',
|
||||
'/utils/connection-status.js',
|
||||
'/utils/mobile-nav.js'
|
||||
];
|
||||
|
||||
// Install - cache shell assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Don't fail install if some assets are missing (dev mode)
|
||||
console.warn('[SW] Some assets failed to cache:', err);
|
||||
});
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate - clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch - network-first for API, cache-first for static
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Skip WebSocket upgrade requests
|
||||
if (request.headers.get('Upgrade') === 'websocket') return;
|
||||
|
||||
// Skip cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API calls: network-first with cache fallback
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first with network fallback
|
||||
event.respondWith(cacheFirst(request));
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Return offline fallback for HTML navigation
|
||||
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||
const fallback = await caches.match('/index.html');
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView UI - Unit Tests</title>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; }
|
||||
h1 { font-size: 20px; margin-bottom: 4px; color: #32b8c6; }
|
||||
.subtitle { font-size: 13px; color: #a7a9a9; margin-bottom: 20px; }
|
||||
.suite { margin-bottom: 16px; }
|
||||
.suite-name { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #a7a9a9; }
|
||||
.test { padding: 4px 0 4px 16px; font-size: 13px; font-family: monospace; }
|
||||
.pass { color: #32b8c6; }
|
||||
.fail { color: #ff5459; }
|
||||
.pass::before { content: "PASS "; font-weight: bold; }
|
||||
.fail::before { content: "FAIL "; font-weight: bold; }
|
||||
.summary { margin-top: 24px; padding: 12px; border-top: 1px solid #333; font-size: 14px; font-weight: 600; }
|
||||
.error-detail { color: #ff8a8a; font-size: 12px; padding-left: 32px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView UI - Unit Tests</h1>
|
||||
<p class="subtitle">Tests for UI components and utility modules</p>
|
||||
<div id="output"></div>
|
||||
<div id="summary" class="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
// ---- Minimal test framework (zero deps) ----
|
||||
const results = [];
|
||||
let currentSuite = '';
|
||||
|
||||
function describe(name, fn) { currentSuite = name; fn(); }
|
||||
|
||||
function it(name, fn) {
|
||||
try { fn(); results.push({ suite: currentSuite, name, passed: true }); }
|
||||
catch (e) { results.push({ suite: currentSuite, name, passed: false, error: e.message }); }
|
||||
}
|
||||
|
||||
function expect(actual) {
|
||||
return {
|
||||
toBe(exp) { if (actual !== exp) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||
toEqual(exp) { if (JSON.stringify(actual) !== JSON.stringify(exp)) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||
toBeTruthy() { if (!actual) throw new Error(`Expected truthy, got ${JSON.stringify(actual)}`); },
|
||||
toBeFalsy() { if (actual) throw new Error(`Expected falsy, got ${JSON.stringify(actual)}`); },
|
||||
toBeGreaterThan(n) { if (!(actual > n)) throw new Error(`Expected ${actual} > ${n}`); },
|
||||
toContain(str) { if (typeof actual === 'string' ? !actual.includes(str) : !actual.includes(str)) throw new Error(`Expected to contain "${str}"`); },
|
||||
not: {
|
||||
toBe(exp) { if (actual === exp) throw new Error(`Expected not ${JSON.stringify(exp)}`); },
|
||||
toContain(str) { if (typeof actual === 'string' && actual.includes(str)) throw new Error(`Expected not to contain "${str}"`); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mockDOM() {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'container';
|
||||
c.innerHTML = `
|
||||
<header class="header"><div class="header-info"></div></header>
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true">Dashboard</button>
|
||||
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false">Hardware</button>
|
||||
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false">Live Demo</button>
|
||||
</nav>
|
||||
<section id="dashboard" class="tab-content active" role="tabpanel"></section>
|
||||
<section id="hardware" class="tab-content" role="tabpanel"></section>
|
||||
<section id="demo" class="tab-content" role="tabpanel"></section>
|
||||
`;
|
||||
document.body.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ===== ToastManager =====
|
||||
const { ToastManager } = await import('../utils/toast.js');
|
||||
|
||||
describe('ToastManager', () => {
|
||||
it('creates container with role=region on init', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
expect(tm.container.getAttribute('role')).toBe('region');
|
||||
expect(tm.container.getAttribute('aria-live')).toBe('polite');
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('show() returns unique incremental ids', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
const a = tm.show('A'); const b = tm.show('B');
|
||||
expect(b).toBeGreaterThan(a);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dismiss() removes toast from list', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
const id = tm.show('X', { duration: 0 });
|
||||
expect(tm.toasts.length).toBe(1);
|
||||
tm.dismiss(id);
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dismiss() is safe to call with unknown id', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.dismiss(99999); // should not throw
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('success/error/warning/info create correct types', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.success('a'); tm.error('b'); tm.warning('c'); tm.info('d');
|
||||
expect(tm.toasts.length).toBe(4);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('escapes HTML entities to prevent XSS', () => {
|
||||
const tm = new ToastManager();
|
||||
const safe = tm.escapeHtml('<img src=x onerror=alert(1)>');
|
||||
expect(safe).not.toContain('<img');
|
||||
expect(safe).toContain('<img');
|
||||
});
|
||||
|
||||
it('stacks multiple toasts in container', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.show('1', { duration: 0 });
|
||||
tm.show('2', { duration: 0 });
|
||||
tm.show('3', { duration: 0 });
|
||||
expect(tm.container.children.length).toBe(3);
|
||||
tm.dispose();
|
||||
});
|
||||
|
||||
it('dispose() removes container from DOM', () => {
|
||||
const tm = new ToastManager();
|
||||
tm.init();
|
||||
tm.show('Z', { duration: 0 });
|
||||
const c = tm.container;
|
||||
tm.dispose();
|
||||
expect(c.parentNode).toBeFalsy();
|
||||
expect(tm.toasts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== ThemeToggle =====
|
||||
const { ThemeToggle } = await import('../utils/theme-toggle.js');
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
const dom = mockDOM();
|
||||
|
||||
it('detects system theme as dark or light', () => {
|
||||
const tt = new ThemeToggle();
|
||||
const t = tt.getSystemTheme();
|
||||
expect(t === 'dark' || t === 'light').toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates button with aria-label in header', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.init();
|
||||
expect(tt.button).toBeTruthy();
|
||||
expect(tt.button.getAttribute('aria-label')).toBeTruthy();
|
||||
tt.dispose();
|
||||
});
|
||||
|
||||
it('toggle() alternates between dark and light', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.init();
|
||||
const initial = tt.currentTheme;
|
||||
tt.toggle();
|
||||
expect(tt.currentTheme).not.toBe(initial);
|
||||
tt.toggle();
|
||||
expect(tt.currentTheme).toBe(initial);
|
||||
tt.dispose();
|
||||
});
|
||||
|
||||
it('applyTheme() sets data-color-scheme on <html>', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.applyTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||
tt.applyTheme('light');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||
});
|
||||
|
||||
it('persists and retrieves theme from localStorage', () => {
|
||||
const tt = new ThemeToggle();
|
||||
tt.saveTheme('dark');
|
||||
expect(tt.getSavedTheme()).toBe('dark');
|
||||
tt.saveTheme('light');
|
||||
expect(tt.getSavedTheme()).toBe('light');
|
||||
localStorage.removeItem('ruview-theme');
|
||||
});
|
||||
|
||||
dom.remove();
|
||||
});
|
||||
|
||||
// ===== KeyboardShortcuts =====
|
||||
const { KeyboardShortcuts } = await import('../utils/keyboard-shortcuts.js');
|
||||
|
||||
describe('KeyboardShortcuts', () => {
|
||||
it('has default shortcuts for ?, Escape, and number keys', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
expect(ks.shortcuts.has('?')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('Escape')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('1')).toBeTruthy();
|
||||
expect(ks.shortcuts.has('8')).toBeTruthy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('register() adds custom handler', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
let ran = false;
|
||||
ks.register('z', 'Test', () => { ran = true; });
|
||||
expect(ks.shortcuts.has('z')).toBeTruthy();
|
||||
ks.shortcuts.get('z').handler();
|
||||
expect(ran).toBeTruthy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('formatKey() maps Escape to Esc', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
expect(ks.formatKey('Escape')).toBe('Esc');
|
||||
expect(ks.formatKey('a')).toBe('A');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('init() creates dialog overlay', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
expect(ks.overlay).toBeTruthy();
|
||||
expect(ks.overlay.getAttribute('role')).toBe('dialog');
|
||||
expect(ks.overlay.getAttribute('aria-modal')).toBe('true');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('showHelp/hideHelp toggles overlay visibility', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
ks.showHelp();
|
||||
expect(ks.helpVisible).toBeTruthy();
|
||||
expect(ks.overlay.classList.contains('visible')).toBeTruthy();
|
||||
ks.hideHelp();
|
||||
expect(ks.helpVisible).toBeFalsy();
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('buildHelpHTML() includes Navigation/Actions/General groups', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
const html = ks.buildHelpHTML();
|
||||
expect(html).toContain('Navigation');
|
||||
expect(html).toContain('Actions');
|
||||
expect(html).toContain('General');
|
||||
ks.dispose();
|
||||
});
|
||||
|
||||
it('dispose() removes overlay from DOM', () => {
|
||||
const ks = new KeyboardShortcuts(null);
|
||||
ks.init();
|
||||
const o = ks.overlay;
|
||||
ks.dispose();
|
||||
expect(o.parentNode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== PerfMonitor =====
|
||||
const { PerfMonitor } = await import('../utils/perf-monitor.js');
|
||||
|
||||
describe('PerfMonitor', () => {
|
||||
it('creates panel with role=status and aria-label', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
expect(pm.panel.getAttribute('role')).toBe('status');
|
||||
expect(pm.panel.getAttribute('aria-label')).toBe('Performance monitor');
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('show/hide updates visible state', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.show();
|
||||
expect(pm.visible).toBeTruthy();
|
||||
expect(pm.panel.classList.contains('visible')).toBeTruthy();
|
||||
pm.hide();
|
||||
expect(pm.visible).toBeFalsy();
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('toggle() flips visibility', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.toggle();
|
||||
expect(pm.visible).toBeTruthy();
|
||||
pm.toggle();
|
||||
expect(pm.visible).toBeFalsy();
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('updateMetric() sets text and CSS class', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.updateMetric('fps', 60, 'ok');
|
||||
const el = pm.panel.querySelector('[data-metric="fps"]');
|
||||
expect(el.textContent).toBe('60');
|
||||
expect(el.className).toContain('perf-ok');
|
||||
pm.updateMetric('fps', 15, 'warning');
|
||||
expect(el.className).toContain('perf-warning');
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('pushSpark() appends data and caps at 60', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
for (let i = 0; i < 70; i++) pm.pushSpark('fps', i, 0, 120);
|
||||
expect(pm.sparkData.fps.length).toBe(60);
|
||||
pm.dispose();
|
||||
});
|
||||
|
||||
it('dispose() cleans up panel', () => {
|
||||
const pm = new PerfMonitor();
|
||||
pm.init();
|
||||
pm.show();
|
||||
const p = pm.panel;
|
||||
pm.dispose();
|
||||
expect(p.parentNode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== TabManager =====
|
||||
const { TabManager } = await import('../components/TabManager.js');
|
||||
|
||||
describe('TabManager', () => {
|
||||
it('initializes and finds all tabs', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
expect(tm.tabs.length).toBe(3);
|
||||
expect(tm.activeTab).toBe('dashboard');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('switchToTab() changes active tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.switchToTab('hardware');
|
||||
expect(tm.activeTab).toBe('hardware');
|
||||
expect(d.querySelector('[data-tab="hardware"]').classList.contains('active')).toBeTruthy();
|
||||
expect(d.querySelector('[data-tab="dashboard"]').classList.contains('active')).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('updates aria-selected on tab switch', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.switchToTab('demo');
|
||||
expect(d.querySelector('[data-tab="dashboard"]').getAttribute('aria-selected')).toBe('false');
|
||||
expect(d.querySelector('[data-tab="demo"]').getAttribute('aria-selected')).toBe('true');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('fires onTabChange callbacks with correct args', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let newId = '', oldId = '';
|
||||
tm.onTabChange((n, o) => { newId = n; oldId = o; });
|
||||
tm.switchToTab('hardware');
|
||||
expect(newId).toBe('hardware');
|
||||
expect(oldId).toBe('dashboard');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('does not fire callback when switching to already active tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let count = 0;
|
||||
tm.onTabChange(() => { count++; });
|
||||
tm.switchToTab('dashboard');
|
||||
expect(count).toBe(0);
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('onTabChange() returns unsubscribe function', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
let count = 0;
|
||||
const unsub = tm.onTabChange(() => { count++; });
|
||||
tm.switchToTab('hardware');
|
||||
expect(count).toBe(1);
|
||||
unsub();
|
||||
tm.switchToTab('demo');
|
||||
expect(count).toBe(1); // not incremented
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabEnabled(false) disables tab button', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabEnabled('hardware', false);
|
||||
const btn = d.querySelector('[data-tab="hardware"]');
|
||||
expect(btn.disabled).toBeTruthy();
|
||||
expect(btn.classList.contains('disabled')).toBeTruthy();
|
||||
tm.setTabEnabled('hardware', true);
|
||||
expect(btn.disabled).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabVisible(false) hides tab', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabVisible('demo', false);
|
||||
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('none');
|
||||
tm.setTabVisible('demo', true);
|
||||
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('');
|
||||
d.remove();
|
||||
});
|
||||
|
||||
it('setTabBadge() adds/removes badge', () => {
|
||||
const d = mockDOM();
|
||||
const tm = new TabManager(d);
|
||||
tm.init();
|
||||
tm.setTabBadge('hardware', '3');
|
||||
const badge = d.querySelector('[data-tab="hardware"] .tab-badge');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toBe('3');
|
||||
tm.setTabBadge('hardware', null);
|
||||
expect(d.querySelector('[data-tab="hardware"] .tab-badge')).toBeFalsy();
|
||||
d.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== RENDER RESULTS =====
|
||||
const output = document.getElementById('output');
|
||||
let lastSuite = '', passed = 0, failed = 0;
|
||||
|
||||
results.forEach(r => {
|
||||
if (r.suite !== lastSuite) {
|
||||
lastSuite = r.suite;
|
||||
const s = document.createElement('div');
|
||||
s.className = 'suite';
|
||||
s.innerHTML = `<div class="suite-name">${r.suite}</div>`;
|
||||
output.appendChild(s);
|
||||
}
|
||||
const t = document.createElement('div');
|
||||
t.className = `test ${r.passed ? 'pass' : 'fail'}`;
|
||||
t.textContent = r.name;
|
||||
output.lastChild.appendChild(t);
|
||||
if (!r.passed) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'error-detail';
|
||||
e.textContent = r.error;
|
||||
output.lastChild.appendChild(e);
|
||||
}
|
||||
r.passed ? passed++ : failed++;
|
||||
});
|
||||
|
||||
const summary = document.getElementById('summary');
|
||||
summary.textContent = `${passed + failed} tests: ${passed} passed, ${failed} failed`;
|
||||
summary.style.color = failed === 0 ? '#32b8c6' : '#ff5459';
|
||||
|
||||
console.info(`[UNIT-TESTS] ${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) results.filter(r => !r.passed).forEach(r => console.error(`[FAIL] ${r.suite} > ${r.name}: ${r.error}`));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
// Activity Log - Scrollable panel showing system events in real-time
|
||||
// Toggle with 'L' key or command palette
|
||||
|
||||
export class ActivityLog {
|
||||
constructor() {
|
||||
this.panel = null;
|
||||
this.visible = false;
|
||||
this.entries = [];
|
||||
this.maxEntries = 200;
|
||||
this.logBody = null;
|
||||
this.filters = { info: true, warning: true, error: true, connection: true };
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
this.interceptConsole();
|
||||
document.addEventListener('toggle-activity-log', () => this.toggle());
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'activity-log';
|
||||
this.panel.setAttribute('role', 'log');
|
||||
this.panel.setAttribute('aria-label', 'Activity log');
|
||||
this.panel.innerHTML = `
|
||||
<div class="activity-log-header">
|
||||
<span class="activity-log-title">Activity Log</span>
|
||||
<div class="activity-log-controls">
|
||||
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
|
||||
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
|
||||
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
|
||||
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
|
||||
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
|
||||
<button class="activity-log-close" aria-label="Close activity log">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-log-body"></div>
|
||||
`;
|
||||
|
||||
this.logBody = this.panel.querySelector('.activity-log-body');
|
||||
|
||||
// Filter toggles
|
||||
this.panel.querySelectorAll('.activity-log-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const filter = btn.dataset.filter;
|
||||
this.filters[filter] = !this.filters[filter];
|
||||
btn.classList.toggle('active', this.filters[filter]);
|
||||
this.rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear button
|
||||
this.panel.querySelector('.activity-log-clear').addEventListener('click', () => {
|
||||
this.entries = [];
|
||||
this.rerender();
|
||||
});
|
||||
|
||||
// Close button
|
||||
this.panel.querySelector('.activity-log-close').addEventListener('click', () => this.hide());
|
||||
|
||||
// Make resizable by dragging top edge
|
||||
this.makeResizable();
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
}
|
||||
|
||||
makeResizable() {
|
||||
let resizing = false;
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
this.panel.addEventListener('mousedown', (e) => {
|
||||
// Only top 5px edge
|
||||
const rect = this.panel.getBoundingClientRect();
|
||||
if (e.clientY - rect.top > 5) return;
|
||||
resizing = true;
|
||||
startY = e.clientY;
|
||||
startHeight = rect.height;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!resizing) return;
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.max(150, Math.min(window.innerHeight * 0.7, startHeight + delta));
|
||||
this.panel.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => { resizing = false; });
|
||||
}
|
||||
|
||||
interceptConsole() {
|
||||
const origInfo = console.info;
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
|
||||
console.info = (...args) => {
|
||||
origInfo.apply(console, args);
|
||||
this.addEntry('info', args.map(String).join(' '));
|
||||
};
|
||||
|
||||
console.warn = (...args) => {
|
||||
origWarn.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
const type = msg.includes('[WS-') || msg.includes('connect') ? 'connection' : 'warning';
|
||||
this.addEntry(type, msg);
|
||||
};
|
||||
|
||||
console.error = (...args) => {
|
||||
origError.apply(console, args);
|
||||
this.addEntry('error', args.map(String).join(' '));
|
||||
};
|
||||
}
|
||||
|
||||
addEntry(type, message) {
|
||||
const entry = {
|
||||
time: new Date(),
|
||||
type,
|
||||
message: this.truncate(message, 300)
|
||||
};
|
||||
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > this.maxEntries) {
|
||||
this.entries.shift();
|
||||
}
|
||||
|
||||
if (this.visible && this.filters[type]) {
|
||||
this.appendEntry(entry);
|
||||
// Auto-scroll to bottom
|
||||
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
appendEntry(entry) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `activity-log-entry activity-log-${entry.type}`;
|
||||
const time = entry.time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
el.innerHTML = `<span class="activity-log-time">${time}</span><span class="activity-log-type">${entry.type.toUpperCase().charAt(0)}</span><span class="activity-log-msg">${this.escapeHtml(entry.message)}</span>`;
|
||||
this.logBody.appendChild(el);
|
||||
}
|
||||
|
||||
rerender() {
|
||||
this.logBody.innerHTML = '';
|
||||
this.entries
|
||||
.filter(e => this.filters[e.type])
|
||||
.forEach(e => this.appendEntry(e));
|
||||
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
this.panel.classList.add('visible');
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
this.panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.hide();
|
||||
if (this.panel?.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
|
||||
// Fuzzy search across tabs, actions, and settings
|
||||
|
||||
export class CommandPalette {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.overlay = null;
|
||||
this.input = null;
|
||||
this.results = null;
|
||||
this.visible = false;
|
||||
this.commands = [];
|
||||
this.selectedIndex = 0;
|
||||
this.filteredCommands = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.registerCommands();
|
||||
this.createDOM();
|
||||
this.bindGlobalShortcut();
|
||||
}
|
||||
|
||||
registerCommands() {
|
||||
// Navigation commands
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
|
||||
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
|
||||
{ id: 'demo', label: 'Live Demo', icon: 'play' },
|
||||
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
|
||||
{ id: 'performance', label: 'Performance', icon: 'zap' },
|
||||
{ id: 'applications', label: 'Applications', icon: 'box' },
|
||||
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
|
||||
{ id: 'training', label: 'Training', icon: 'database' },
|
||||
];
|
||||
|
||||
tabs.forEach(tab => {
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: `Go to ${tab.label}`,
|
||||
keywords: [tab.id, tab.label.toLowerCase()],
|
||||
icon: tab.icon,
|
||||
action: () => {
|
||||
const tm = this.app?.getComponent?.('tabManager');
|
||||
if (tm) tm.switchToTab(tab.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// External pages
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: 'Open Pose Fusion',
|
||||
keywords: ['pose', 'fusion', 'camera'],
|
||||
icon: 'external',
|
||||
action: () => { window.location.href = 'pose-fusion.html'; }
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: 'Open Observatory',
|
||||
keywords: ['observatory', '3d', 'signal'],
|
||||
icon: 'external',
|
||||
action: () => { window.location.href = 'observatory.html'; }
|
||||
});
|
||||
|
||||
// Actions
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Dark/Light Theme',
|
||||
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
|
||||
icon: 'moon',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Performance Monitor',
|
||||
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
|
||||
icon: 'activity',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Activity Log',
|
||||
keywords: ['log', 'events', 'activity', 'history'],
|
||||
icon: 'list',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Export Sensor Data',
|
||||
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
|
||||
icon: 'download',
|
||||
action: () => document.dispatchEvent(new CustomEvent('export-data'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Fullscreen',
|
||||
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
|
||||
icon: 'maximize',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Show Keyboard Shortcuts',
|
||||
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
|
||||
icon: 'keyboard',
|
||||
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
|
||||
});
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'cmd-palette-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Command palette');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
|
||||
this.overlay.innerHTML = `
|
||||
<div class="cmd-palette">
|
||||
<div class="cmd-palette-input-wrap">
|
||||
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
|
||||
<kbd class="cmd-palette-hint">Esc</kbd>
|
||||
</div>
|
||||
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
|
||||
<span><kbd>Enter</kbd> execute</span>
|
||||
<span><kbd>Esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.hide();
|
||||
});
|
||||
|
||||
this.input = this.overlay.querySelector('.cmd-palette-input');
|
||||
this.results = this.overlay.querySelector('.cmd-palette-results');
|
||||
|
||||
this.input.addEventListener('input', () => this.onInput());
|
||||
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
|
||||
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
bindGlobalShortcut() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+K or Cmd+K
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
this.overlay.classList.add('visible');
|
||||
this.input.value = '';
|
||||
this.selectedIndex = 0;
|
||||
this.filteredCommands = [...this.commands];
|
||||
this.renderResults();
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
this.overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
onInput() {
|
||||
const query = this.input.value.toLowerCase().trim();
|
||||
if (!query) {
|
||||
this.filteredCommands = [...this.commands];
|
||||
} else {
|
||||
this.filteredCommands = this.commands
|
||||
.map(cmd => {
|
||||
const score = this.fuzzyScore(query, cmd);
|
||||
return { ...cmd, score };
|
||||
})
|
||||
.filter(cmd => cmd.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
this.selectedIndex = 0;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
fuzzyScore(query, cmd) {
|
||||
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
|
||||
let best = 0;
|
||||
for (const target of targets) {
|
||||
if (target === query) return 100;
|
||||
if (target.startsWith(query)) best = Math.max(best, 80);
|
||||
if (target.includes(query)) best = Math.max(best, 60);
|
||||
// Check each word
|
||||
const words = query.split(/\s+/);
|
||||
const allMatch = words.every(w => targets.some(t => t.includes(w)));
|
||||
if (allMatch) best = Math.max(best, 40);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
if (this.filteredCommands.length === 0) {
|
||||
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let lastCategory = '';
|
||||
let html = '';
|
||||
|
||||
this.filteredCommands.forEach((cmd, i) => {
|
||||
if (cmd.category !== lastCategory) {
|
||||
lastCategory = cmd.category;
|
||||
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
|
||||
}
|
||||
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
|
||||
html += `
|
||||
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
|
||||
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
|
||||
<span class="cmd-palette-item-label">${cmd.label}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
this.results.innerHTML = html;
|
||||
|
||||
// Click handlers
|
||||
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.dataset.index, 10);
|
||||
this.executeCommand(idx);
|
||||
});
|
||||
el.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = parseInt(el.dataset.index, 10);
|
||||
this.updateSelection();
|
||||
});
|
||||
});
|
||||
|
||||
// Scroll selected into view
|
||||
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
||||
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
|
||||
el.classList.toggle('cmd-palette-item-selected', isSelected);
|
||||
el.setAttribute('aria-selected', String(isSelected));
|
||||
});
|
||||
}
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
||||
this.updateSelection();
|
||||
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection();
|
||||
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeCommand(this.selectedIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand(index) {
|
||||
const cmd = this.filteredCommands[index];
|
||||
if (cmd) {
|
||||
this.hide();
|
||||
cmd.action();
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(name) {
|
||||
const icons = {
|
||||
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
|
||||
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
|
||||
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
|
||||
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
|
||||
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
|
||||
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||||
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
|
||||
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
|
||||
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||||
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
|
||||
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||||
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
|
||||
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
|
||||
};
|
||||
return icons[name] || '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Connection Status Widget - Persistent indicator in header
|
||||
// Shows WebSocket and API connection state with reconnect button
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
export class ConnectionStatus {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
this._unsub = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createWidget();
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
createWidget() {
|
||||
this.widget = document.createElement('div');
|
||||
this.widget.className = 'conn-status';
|
||||
this.widget.setAttribute('role', 'status');
|
||||
this.widget.setAttribute('aria-live', 'polite');
|
||||
this.widget.innerHTML = `
|
||||
<span class="conn-status-dot"></span>
|
||||
<span class="conn-status-label">Connecting</span>
|
||||
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
|
||||
this.setStatus('reconnecting', 'Reconnecting...');
|
||||
sensingService.reconnect?.();
|
||||
});
|
||||
|
||||
// Insert into header-info, after theme toggle if present
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.widget);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
this._unsub = sensingService.onStateChange(() => {
|
||||
this.update();
|
||||
});
|
||||
// Initial
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
const state = sensingService.state;
|
||||
const source = sensingService.dataSource;
|
||||
|
||||
if (state === 'connected' || state === 'streaming') {
|
||||
const label = source === 'live' ? 'Live' :
|
||||
source === 'server-simulated' ? 'Simulated' :
|
||||
'Connected';
|
||||
this.setStatus('connected', label);
|
||||
} else if (state === 'connecting' || state === 'reconnecting') {
|
||||
this.setStatus('reconnecting', 'Connecting...');
|
||||
} else if (state === 'error') {
|
||||
this.setStatus('error', 'Error');
|
||||
} else {
|
||||
this.setStatus('disconnected', 'Offline');
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status, label) {
|
||||
if (!this.widget) return;
|
||||
this.widget.className = `conn-status conn-status-${status}`;
|
||||
this.widget.querySelector('.conn-status-label').textContent = label;
|
||||
|
||||
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
|
||||
reconnectBtn.style.display =
|
||||
(status === 'disconnected' || status === 'error') ? '' : 'none';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsub) this._unsub();
|
||||
if (this.widget?.parentNode) {
|
||||
this.widget.parentNode.removeChild(this.widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Data Export Utility - Export sensor/pose data as JSON or CSV
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
import { toastManager } from './toast.js';
|
||||
|
||||
export class DataExport {
|
||||
constructor() {
|
||||
this.buffer = [];
|
||||
this.maxBuffer = 1000;
|
||||
this.recording = false;
|
||||
this._unsub = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('export-data', () => this.showExportDialog());
|
||||
|
||||
// Continuously buffer sensing data when available
|
||||
this._unsub = sensingService.onData((data) => {
|
||||
if (this.buffer.length >= this.maxBuffer) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
this.buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
...this.extractFields(data)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extractFields(data) {
|
||||
// Extract relevant fields from sensing data
|
||||
return {
|
||||
rssi: data.rssi ?? null,
|
||||
variance: data.variance ?? null,
|
||||
motion_band: data.motion_band ?? null,
|
||||
breathing_band: data.breathing_band ?? null,
|
||||
classification: data.classification ?? null,
|
||||
person_count: data.person_count ?? data.persons ?? null,
|
||||
subcarriers: data.subcarrier_count ?? null,
|
||||
source: data.source ?? null
|
||||
};
|
||||
}
|
||||
|
||||
showExportDialog() {
|
||||
if (this.buffer.length === 0) {
|
||||
toastManager.warning('No sensor data to export. Connect to a data source first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dialog
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'export-dialog-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
|
||||
<h3>Export Sensor Data</h3>
|
||||
<p class="export-dialog-info">${this.buffer.length} data points available</p>
|
||||
<div class="export-dialog-options">
|
||||
<label class="export-option">
|
||||
<input type="radio" name="export-format" value="json" checked>
|
||||
<span>JSON</span>
|
||||
<small>Full data with nested fields</small>
|
||||
</label>
|
||||
<label class="export-option">
|
||||
<input type="radio" name="export-format" value="csv">
|
||||
<span>CSV</span>
|
||||
<small>Flat table, spreadsheet-ready</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="export-dialog-range">
|
||||
<label>
|
||||
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
|
||||
</label>
|
||||
</div>
|
||||
<div class="export-dialog-actions">
|
||||
<button class="btn btn--secondary export-cancel">Cancel</button>
|
||||
<button class="btn btn--primary export-confirm">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.export-confirm').addEventListener('click', () => {
|
||||
const format = overlay.querySelector('input[name="export-format"]:checked').value;
|
||||
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
|
||||
this.exportData(format, count);
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('.export-confirm').focus();
|
||||
}
|
||||
|
||||
exportData(format, count) {
|
||||
const data = this.buffer.slice(-count);
|
||||
|
||||
let content, filename, mimeType;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(data, null, 2);
|
||||
filename = `ruview-data-${this.timestamp()}.json`;
|
||||
mimeType = 'application/json';
|
||||
} else {
|
||||
content = this.toCSV(data);
|
||||
filename = `ruview-data-${this.timestamp()}.csv`;
|
||||
mimeType = 'text/csv';
|
||||
}
|
||||
|
||||
this.downloadFile(content, filename, mimeType);
|
||||
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
|
||||
}
|
||||
|
||||
toCSV(data) {
|
||||
if (data.length === 0) return '';
|
||||
const headers = Object.keys(data[0]);
|
||||
const rows = data.map(row => headers.map(h => {
|
||||
const val = row[h];
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
||||
return `"${val.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return String(val);
|
||||
}).join(','));
|
||||
return [headers.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsub) this._unsub();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Fullscreen Mode - Toggle fullscreen on visualization tabs
|
||||
// Activated via F11 key, command palette, or button
|
||||
|
||||
export class FullscreenManager {
|
||||
constructor() {
|
||||
this.isFullscreen = false;
|
||||
this.targetElement = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('toggle-fullscreen', () => this.toggle());
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
this.updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isFullscreen) {
|
||||
this.exit();
|
||||
} else {
|
||||
this.enter();
|
||||
}
|
||||
}
|
||||
|
||||
enter() {
|
||||
// Find the active tab content
|
||||
const activePanel = document.querySelector('.tab-content.active');
|
||||
if (!activePanel) return;
|
||||
|
||||
this.targetElement = activePanel;
|
||||
|
||||
if (activePanel.requestFullscreen) {
|
||||
activePanel.requestFullscreen();
|
||||
} else if (activePanel.webkitRequestFullscreen) {
|
||||
activePanel.webkitRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
exit() {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
this.targetElement = null;
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
|
||||
|
||||
// Add/remove exit button when in fullscreen
|
||||
let exitBtn = document.getElementById('fullscreen-exit-btn');
|
||||
if (this.isFullscreen && !exitBtn) {
|
||||
exitBtn = document.createElement('button');
|
||||
exitBtn.id = 'fullscreen-exit-btn';
|
||||
exitBtn.className = 'fullscreen-exit-btn';
|
||||
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
|
||||
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
exitBtn.title = 'Exit fullscreen (F11)';
|
||||
exitBtn.addEventListener('click', () => this.exit());
|
||||
document.body.appendChild(exitBtn);
|
||||
} else if (!this.isFullscreen && exitBtn) {
|
||||
exitBtn.remove();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.isFullscreen) this.exit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Internationalization - EN/PL language support
|
||||
// Detects browser language, persists choice, translates UI strings
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.hardware': 'Hardware',
|
||||
'nav.demo': 'Live Demo',
|
||||
'nav.architecture': 'Architecture',
|
||||
'nav.performance': 'Performance',
|
||||
'nav.applications': 'Applications',
|
||||
'nav.sensing': 'Sensing',
|
||||
'nav.training': 'Training',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
|
||||
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
|
||||
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
|
||||
'dashboard.status': 'System Status',
|
||||
'dashboard.metrics': 'System Metrics',
|
||||
'dashboard.features': 'Features',
|
||||
'dashboard.liveStats': 'Live Statistics',
|
||||
'dashboard.activePersons': 'Active Persons',
|
||||
'dashboard.avgConfidence': 'Avg Confidence',
|
||||
'dashboard.totalDetections': 'Total Detections',
|
||||
'dashboard.zoneOccupancy': 'Zone Occupancy',
|
||||
|
||||
// Status
|
||||
'status.apiServer': 'API Server',
|
||||
'status.hardware': 'Hardware',
|
||||
'status.inference': 'Inference',
|
||||
'status.streaming': 'Streaming',
|
||||
'status.dataSource': 'Data Source',
|
||||
|
||||
// Metrics
|
||||
'metrics.cpu': 'CPU Usage',
|
||||
'metrics.memory': 'Memory Usage',
|
||||
'metrics.disk': 'Disk Usage',
|
||||
|
||||
// Benefits
|
||||
'benefit.throughWalls': 'Through Walls',
|
||||
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
|
||||
'benefit.privacy': 'Privacy-Preserving',
|
||||
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
|
||||
'benefit.realtime': 'Real-Time',
|
||||
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
|
||||
'benefit.lowCost': 'Low Cost',
|
||||
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
|
||||
|
||||
// Stats
|
||||
'stat.bodyRegions': 'Body Regions',
|
||||
'stat.samplingRate': 'Sampling Rate',
|
||||
'stat.accuracy': 'Accuracy (AP@50)',
|
||||
'stat.hardwareCost': 'Hardware Cost',
|
||||
|
||||
// Actions
|
||||
'action.startDetection': 'Start Detection',
|
||||
'action.stopDetection': 'Stop Detection',
|
||||
'action.toggleTheme': 'Toggle theme',
|
||||
'action.exportData': 'Export data',
|
||||
'action.screenshot': 'Take screenshot',
|
||||
|
||||
// Connection
|
||||
'conn.connected': 'Connected',
|
||||
'conn.connecting': 'Connecting...',
|
||||
'conn.offline': 'Offline',
|
||||
'conn.reconnecting': 'Reconnecting...',
|
||||
'conn.live': 'Live',
|
||||
'conn.simulated': 'Simulated',
|
||||
|
||||
// Misc
|
||||
'misc.loading': 'Loading...',
|
||||
'misc.error': 'An error occurred',
|
||||
'misc.noData': 'No data available',
|
||||
'misc.close': 'Close',
|
||||
'misc.cancel': 'Cancel',
|
||||
'misc.confirm': 'Confirm',
|
||||
'misc.settings': 'Settings',
|
||||
'misc.language': 'Language'
|
||||
},
|
||||
|
||||
pl: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Panel',
|
||||
'nav.hardware': 'Sprzet',
|
||||
'nav.demo': 'Demo na zywo',
|
||||
'nav.architecture': 'Architektura',
|
||||
'nav.performance': 'Wydajnosc',
|
||||
'nav.applications': 'Aplikacje',
|
||||
'nav.sensing': 'Czujniki',
|
||||
'nav.training': 'Trening',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
|
||||
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
|
||||
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
|
||||
'dashboard.status': 'Status systemu',
|
||||
'dashboard.metrics': 'Metryki systemu',
|
||||
'dashboard.features': 'Funkcje',
|
||||
'dashboard.liveStats': 'Statystyki na zywo',
|
||||
'dashboard.activePersons': 'Aktywne osoby',
|
||||
'dashboard.avgConfidence': 'Srednia pewnosc',
|
||||
'dashboard.totalDetections': 'Laczne detekcje',
|
||||
'dashboard.zoneOccupancy': 'Zajecie stref',
|
||||
|
||||
// Status
|
||||
'status.apiServer': 'Serwer API',
|
||||
'status.hardware': 'Sprzet',
|
||||
'status.inference': 'Wnioskowanie',
|
||||
'status.streaming': 'Streaming',
|
||||
'status.dataSource': 'Zrodlo danych',
|
||||
|
||||
// Metrics
|
||||
'metrics.cpu': 'Uzycie CPU',
|
||||
'metrics.memory': 'Uzycie pamieci',
|
||||
'metrics.disk': 'Uzycie dysku',
|
||||
|
||||
// Benefits
|
||||
'benefit.throughWalls': 'Przez sciany',
|
||||
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
|
||||
'benefit.privacy': 'Ochrona prywatnosci',
|
||||
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
|
||||
'benefit.realtime': 'Czas rzeczywisty',
|
||||
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
|
||||
'benefit.lowCost': 'Niski koszt',
|
||||
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
|
||||
|
||||
// Stats
|
||||
'stat.bodyRegions': 'Regiony ciala',
|
||||
'stat.samplingRate': 'Czestotliwosc',
|
||||
'stat.accuracy': 'Dokladnosc (AP@50)',
|
||||
'stat.hardwareCost': 'Koszt sprzetu',
|
||||
|
||||
// Actions
|
||||
'action.startDetection': 'Rozpocznij detekcje',
|
||||
'action.stopDetection': 'Zatrzymaj detekcje',
|
||||
'action.toggleTheme': 'Zmien motyw',
|
||||
'action.exportData': 'Eksportuj dane',
|
||||
'action.screenshot': 'Zrob zrzut ekranu',
|
||||
|
||||
// Connection
|
||||
'conn.connected': 'Polaczono',
|
||||
'conn.connecting': 'Laczenie...',
|
||||
'conn.offline': 'Offline',
|
||||
'conn.reconnecting': 'Ponowne laczenie...',
|
||||
'conn.live': 'Na zywo',
|
||||
'conn.simulated': 'Symulacja',
|
||||
|
||||
// Misc
|
||||
'misc.loading': 'Ladowanie...',
|
||||
'misc.error': 'Wystapil blad',
|
||||
'misc.noData': 'Brak danych',
|
||||
'misc.close': 'Zamknij',
|
||||
'misc.cancel': 'Anuluj',
|
||||
'misc.confirm': 'Potwierdz',
|
||||
'misc.settings': 'Ustawienia',
|
||||
'misc.language': 'Jezyk'
|
||||
}
|
||||
};
|
||||
|
||||
export class I18n {
|
||||
constructor() {
|
||||
this.locale = this.getSavedLocale() || this.detectLocale();
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createSelector();
|
||||
this.applyTranslations();
|
||||
}
|
||||
|
||||
detectLocale() {
|
||||
const lang = navigator.language?.toLowerCase() || 'en';
|
||||
if (lang.startsWith('pl')) return 'pl';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
getSavedLocale() {
|
||||
try { return localStorage.getItem('ruview-locale'); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
saveLocale(locale) {
|
||||
try { localStorage.setItem('ruview-locale', locale); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
t(key) {
|
||||
const dict = translations[this.locale] || translations.en;
|
||||
return dict[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
setLocale(locale) {
|
||||
if (!translations[locale]) return;
|
||||
this.locale = locale;
|
||||
this.saveLocale(locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
this.applyTranslations();
|
||||
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
|
||||
}
|
||||
|
||||
onLocaleChange(callback) {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
const i = this.listeners.indexOf(callback);
|
||||
if (i > -1) this.listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
applyTranslations() {
|
||||
// Translate elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = this.t(key);
|
||||
});
|
||||
|
||||
// Translate placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// Translate aria-labels
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
el.setAttribute('aria-label', this.t(key));
|
||||
});
|
||||
|
||||
// Update language selector
|
||||
const selector = document.getElementById('lang-selector');
|
||||
if (selector) selector.value = this.locale;
|
||||
}
|
||||
|
||||
createSelector() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'lang-selector-wrap';
|
||||
wrapper.innerHTML = `
|
||||
<select id="lang-selector" class="lang-selector" aria-label="Language">
|
||||
<option value="en">EN</option>
|
||||
<option value="pl">PL</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
const select = wrapper.querySelector('select');
|
||||
select.value = this.locale;
|
||||
select.addEventListener('change', () => this.setLocale(select.value));
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableLocales() {
|
||||
return Object.keys(translations);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listeners = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
@@ -0,0 +1,83 @@
|
||||
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
|
||||
// Reduces CPU/battery usage on idle dashboards
|
||||
|
||||
export class IdleManager {
|
||||
constructor() {
|
||||
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
|
||||
this.isIdle = false;
|
||||
this.timer = null;
|
||||
this.callbacks = { idle: [], active: [] };
|
||||
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.resetTimer();
|
||||
this.events.forEach(evt => {
|
||||
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
|
||||
});
|
||||
// Also use Page Visibility API
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.goIdle();
|
||||
} else {
|
||||
this.goActive();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onActivity() {
|
||||
if (this.isIdle) {
|
||||
this.goActive();
|
||||
}
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
|
||||
}
|
||||
|
||||
goIdle() {
|
||||
if (this.isIdle) return;
|
||||
this.isIdle = true;
|
||||
console.info('[Idle] User inactive - pausing background tasks');
|
||||
this.notify('idle');
|
||||
document.body.classList.add('user-idle');
|
||||
}
|
||||
|
||||
goActive() {
|
||||
if (!this.isIdle) return;
|
||||
this.isIdle = false;
|
||||
console.info('[Idle] User active - resuming background tasks');
|
||||
this.notify('active');
|
||||
document.body.classList.remove('user-idle');
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
onIdle(callback) {
|
||||
this.callbacks.idle.push(callback);
|
||||
return () => {
|
||||
const i = this.callbacks.idle.indexOf(callback);
|
||||
if (i > -1) this.callbacks.idle.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onActive(callback) {
|
||||
this.callbacks.active.push(callback);
|
||||
return () => {
|
||||
const i = this.callbacks.active.indexOf(callback);
|
||||
if (i > -1) this.callbacks.active.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
notify(type) {
|
||||
this.callbacks[type].forEach(cb => {
|
||||
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.callbacks = { idle: [], active: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Keyboard Shortcuts System
|
||||
// Press '?' to show help overlay, number keys to switch tabs, etc.
|
||||
|
||||
export class KeyboardShortcuts {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.shortcuts = new Map();
|
||||
this.helpVisible = false;
|
||||
this.enabled = true;
|
||||
this.overlay = null;
|
||||
this.registerDefaults();
|
||||
}
|
||||
|
||||
registerDefaults() {
|
||||
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
|
||||
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
|
||||
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
|
||||
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
|
||||
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
|
||||
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
|
||||
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
|
||||
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
|
||||
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
|
||||
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
|
||||
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
|
||||
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
register(key, description, handler) {
|
||||
this.shortcuts.set(key, { description, handler });
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Ignore when typing in inputs
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||
if (e.key === 'Escape') {
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modified keys (except shift for '?')
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const shortcut = this.shortcuts.get(e.key);
|
||||
if (shortcut) {
|
||||
e.preventDefault();
|
||||
shortcut.handler();
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabId) {
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager) {
|
||||
tabManager.switchToTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
togglePerfMonitor() {
|
||||
const event = new CustomEvent('toggle-perf-monitor');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const event = new CustomEvent('toggle-theme');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
if (this.helpVisible) {
|
||||
this.hideHelp();
|
||||
}
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'shortcuts-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
this.overlay.innerHTML = this.buildHelpHTML();
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.hideHelp();
|
||||
});
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
buildHelpHTML() {
|
||||
const groups = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[1-8]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[a-z]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => !/^[1-8a-z]$/.test(key))
|
||||
}
|
||||
];
|
||||
|
||||
return `
|
||||
<div class="shortcuts-panel">
|
||||
<div class="shortcuts-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<button class="shortcuts-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="shortcuts-body">
|
||||
${groups.map(group => `
|
||||
<div class="shortcuts-group">
|
||||
<h3>${group.title}</h3>
|
||||
${group.items.map(([key, { description }]) => `
|
||||
<div class="shortcut-row">
|
||||
<kbd>${this.formatKey(key)}</kbd>
|
||||
<span>${description}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formatKey(key) {
|
||||
const map = { Escape: 'Esc', '?': '?' };
|
||||
return map[key] || key.toUpperCase();
|
||||
}
|
||||
|
||||
toggleHelp() {
|
||||
this.helpVisible ? this.hideHelp() : this.showHelp();
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
this.overlay.classList.add('visible');
|
||||
this.helpVisible = true;
|
||||
// Focus close button
|
||||
const closeBtn = this.overlay.querySelector('.shortcuts-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => this.hideHelp();
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
hideHelp() {
|
||||
this.overlay.classList.remove('visible');
|
||||
this.helpVisible = false;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Mobile Navigation - Hamburger menu for small screens
|
||||
// Replaces wrapped tab bar with a slide-out drawer on mobile
|
||||
|
||||
export class MobileNav {
|
||||
constructor() {
|
||||
this.drawer = null;
|
||||
this.backdrop = null;
|
||||
this.hamburger = null;
|
||||
this.isOpen = false;
|
||||
this.mql = window.matchMedia('(max-width: 768px)');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createHamburger();
|
||||
this.createDrawer();
|
||||
this.bindEvents();
|
||||
this.onMediaChange(this.mql);
|
||||
}
|
||||
|
||||
createHamburger() {
|
||||
this.hamburger = document.createElement('button');
|
||||
this.hamburger.className = 'mobile-hamburger';
|
||||
this.hamburger.setAttribute('aria-label', 'Open navigation menu');
|
||||
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||
this.hamburger.innerHTML = `
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
`;
|
||||
this.hamburger.addEventListener('click', () => this.toggle());
|
||||
|
||||
const header = document.querySelector('.header');
|
||||
if (header) {
|
||||
header.style.position = 'relative';
|
||||
header.appendChild(this.hamburger);
|
||||
}
|
||||
}
|
||||
|
||||
createDrawer() {
|
||||
// Backdrop
|
||||
this.backdrop = document.createElement('div');
|
||||
this.backdrop.className = 'mobile-nav-backdrop';
|
||||
this.backdrop.addEventListener('click', () => this.close());
|
||||
document.body.appendChild(this.backdrop);
|
||||
|
||||
// Drawer
|
||||
this.drawer = document.createElement('nav');
|
||||
this.drawer.className = 'mobile-nav-drawer';
|
||||
this.drawer.setAttribute('role', 'navigation');
|
||||
this.drawer.setAttribute('aria-label', 'Mobile navigation');
|
||||
|
||||
// Clone tabs into drawer
|
||||
const tabs = document.querySelectorAll('.nav-tabs .nav-tab');
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mobile-nav-list';
|
||||
|
||||
tabs.forEach(tab => {
|
||||
const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button');
|
||||
item.className = 'mobile-nav-item';
|
||||
item.textContent = tab.textContent.trim();
|
||||
|
||||
if (tab.tagName === 'A') {
|
||||
item.href = tab.href;
|
||||
} else {
|
||||
const tabId = tab.getAttribute('data-tab');
|
||||
item.dataset.tab = tabId;
|
||||
if (tab.classList.contains('active')) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
item.addEventListener('click', () => {
|
||||
// Activate tab via the original tab manager
|
||||
tab.click();
|
||||
this.close();
|
||||
// Update active states in drawer
|
||||
list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
this.drawer.appendChild(list);
|
||||
|
||||
// Keyboard hint at bottom
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'mobile-nav-hint';
|
||||
hint.textContent = 'Tip: Press Ctrl+K for command palette';
|
||||
this.drawer.appendChild(hint);
|
||||
|
||||
document.body.appendChild(this.drawer);
|
||||
|
||||
// Sync active tab when tabs change externally
|
||||
const observer = new MutationObserver(() => {
|
||||
const activeTab = document.querySelector('.nav-tabs .nav-tab.active');
|
||||
if (activeTab) {
|
||||
const activeId = activeTab.getAttribute('data-tab');
|
||||
list.querySelectorAll('.mobile-nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.tab === activeId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const navTabs = document.querySelector('.nav-tabs');
|
||||
if (navTabs) {
|
||||
observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Listen for media query changes
|
||||
this.mql.addEventListener('change', (e) => this.onMediaChange(e));
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) this.close();
|
||||
});
|
||||
|
||||
// Swipe to close
|
||||
let touchStartX = 0;
|
||||
this.drawer.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}, { passive: true });
|
||||
this.drawer.addEventListener('touchend', (e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX;
|
||||
if (deltaX < -50) this.close(); // Swipe left to close
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
onMediaChange(mql) {
|
||||
const isMobile = mql.matches !== undefined ? mql.matches : mql;
|
||||
document.body.classList.toggle('mobile-nav-active', isMobile);
|
||||
|
||||
if (!isMobile && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.drawer.classList.add('open');
|
||||
this.backdrop.classList.add('open');
|
||||
this.hamburger.classList.add('open');
|
||||
this.hamburger.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first item
|
||||
const first = this.drawer.querySelector('.mobile-nav-item');
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.drawer.classList.remove('open');
|
||||
this.backdrop.classList.remove('open');
|
||||
this.hamburger.classList.remove('open');
|
||||
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.close();
|
||||
this.hamburger?.remove();
|
||||
this.drawer?.remove();
|
||||
this.backdrop?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Notification Center - Bell icon with event history
|
||||
// Persists notifications across page views (sessionStorage)
|
||||
|
||||
export class NotificationCenter {
|
||||
constructor() {
|
||||
this.button = null;
|
||||
this.panel = null;
|
||||
this.notifications = [];
|
||||
this.maxNotifications = 50;
|
||||
this.isOpen = false;
|
||||
this.unreadCount = 0;
|
||||
this.storageKey = 'ruview-notifications';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadFromStorage();
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
this.interceptEvents();
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'notif-bell';
|
||||
this.button.setAttribute('aria-label', 'Notifications');
|
||||
this.button.setAttribute('title', 'Notifications');
|
||||
this.button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span class="notif-badge" style="display:none">0</span>
|
||||
`;
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.button);
|
||||
}
|
||||
|
||||
this.updateBadge();
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'notif-panel';
|
||||
this.panel.setAttribute('role', 'region');
|
||||
this.panel.setAttribute('aria-label', 'Notification history');
|
||||
this.panel.innerHTML = `
|
||||
<div class="notif-panel-header">
|
||||
<span>Notifications</span>
|
||||
<div class="notif-panel-actions">
|
||||
<button class="notif-mark-read" title="Mark all read">Mark read</button>
|
||||
<button class="notif-clear" title="Clear all">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-panel-body"></div>
|
||||
`;
|
||||
|
||||
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
|
||||
this.notifications.forEach(n => n.read = true);
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.renderList();
|
||||
this.saveToStorage();
|
||||
});
|
||||
|
||||
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.renderList();
|
||||
this.saveToStorage();
|
||||
});
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interceptEvents() {
|
||||
// Listen for toast events to capture as notifications
|
||||
const origInfo = console.info;
|
||||
console.info = (...args) => {
|
||||
origInfo.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
// Only capture app-relevant messages
|
||||
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
|
||||
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
|
||||
this.add(msg, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
origWarn.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
|
||||
msg.includes('connection') || msg.includes('timeout')) {
|
||||
this.add(msg, 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const origError = console.error;
|
||||
console.error = (...args) => {
|
||||
origError.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
|
||||
this.add(msg, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
add(message, type = 'info') {
|
||||
const notification = {
|
||||
id: Date.now() + Math.random(),
|
||||
message: this.truncate(message, 200),
|
||||
type,
|
||||
time: new Date().toISOString(),
|
||||
read: false
|
||||
};
|
||||
|
||||
this.notifications.unshift(notification);
|
||||
if (this.notifications.length > this.maxNotifications) {
|
||||
this.notifications.pop();
|
||||
}
|
||||
|
||||
this.unreadCount++;
|
||||
this.updateBadge();
|
||||
this.saveToStorage();
|
||||
|
||||
if (this.isOpen) {
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const body = this.panel.querySelector('.notif-panel-body');
|
||||
if (this.notifications.length === 0) {
|
||||
body.innerHTML = '<div class="notif-empty">No notifications</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = this.notifications.map(n => {
|
||||
const time = new Date(n.time);
|
||||
const ago = this.timeAgo(time);
|
||||
return `
|
||||
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
|
||||
<div class="notif-item-dot"></div>
|
||||
<div class="notif-item-content">
|
||||
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
|
||||
<span class="notif-item-time">${ago}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
updateBadge() {
|
||||
const badge = this.button?.querySelector('.notif-badge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.style.display = '';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
loadFromStorage() {
|
||||
try {
|
||||
const data = sessionStorage.getItem(this.storageKey);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
this.notifications = parsed.notifications || [];
|
||||
this.unreadCount = parsed.unreadCount || 0;
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
saveToStorage() {
|
||||
try {
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify({
|
||||
notifications: this.notifications.slice(0, 20),
|
||||
unreadCount: this.unreadCount
|
||||
}));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.close();
|
||||
this.button?.remove();
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Onboarding Tour - Interactive first-run walkthrough
|
||||
// Shows on first visit, can be re-triggered from command palette or help
|
||||
|
||||
const STORAGE_KEY = 'ruview-onboarding-done';
|
||||
|
||||
export class Onboarding {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.overlay = null;
|
||||
this.currentStep = 0;
|
||||
this.steps = [];
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.defineSteps();
|
||||
document.addEventListener('start-onboarding', () => this.start());
|
||||
|
||||
// Auto-start on first visit
|
||||
if (!this.isDone()) {
|
||||
// Delay to let the app render first
|
||||
setTimeout(() => this.start(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
defineSteps() {
|
||||
this.steps = [
|
||||
{
|
||||
title: 'Welcome to RuView',
|
||||
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
|
||||
target: null, // No highlight, centered
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
title: 'System Status',
|
||||
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
|
||||
target: '.live-status-panel',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Live Demo',
|
||||
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
|
||||
target: '[data-tab="demo"]',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Sensing Visualization',
|
||||
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
|
||||
target: '[data-tab="sensing"]',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
|
||||
target: null,
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
title: 'You\'re all set!',
|
||||
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
|
||||
target: null,
|
||||
position: 'center'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
isDone() {
|
||||
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
markDone() {
|
||||
try { localStorage.setItem(STORAGE_KEY, 'true'); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
start() {
|
||||
this.currentStep = 0;
|
||||
this.active = true;
|
||||
this.createOverlay();
|
||||
this.showStep();
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
// Remove existing if any
|
||||
this.removeOverlay();
|
||||
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'onboarding-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Onboarding tour');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
showStep() {
|
||||
if (this.currentStep >= this.steps.length) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = this.steps[this.currentStep];
|
||||
const total = this.steps.length;
|
||||
const isFirst = this.currentStep === 0;
|
||||
const isLast = this.currentStep === total - 1;
|
||||
|
||||
// Clear highlight
|
||||
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||
|
||||
// Highlight target
|
||||
let targetRect = null;
|
||||
if (step.target) {
|
||||
const targetEl = document.querySelector(step.target);
|
||||
if (targetEl) {
|
||||
targetEl.classList.add('onboarding-highlight');
|
||||
targetRect = targetEl.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
this.overlay.innerHTML = `
|
||||
<div class="onboarding-backdrop"></div>
|
||||
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
|
||||
<div class="onboarding-progress">
|
||||
${Array.from({ length: total }, (_, i) =>
|
||||
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
|
||||
).join('')}
|
||||
</div>
|
||||
<h3 class="onboarding-title">${step.title}</h3>
|
||||
<p class="onboarding-text">${step.text}</p>
|
||||
<div class="onboarding-actions">
|
||||
<button class="onboarding-skip">Skip tour</button>
|
||||
<div class="onboarding-nav">
|
||||
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
|
||||
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
|
||||
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
|
||||
this.currentStep++;
|
||||
this.showStep();
|
||||
});
|
||||
const prevBtn = this.overlay.querySelector('.onboarding-prev');
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
this.currentStep--;
|
||||
this.showStep();
|
||||
});
|
||||
}
|
||||
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
|
||||
|
||||
// Focus next button
|
||||
this.overlay.querySelector('.onboarding-next').focus();
|
||||
|
||||
// Escape to close
|
||||
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
|
||||
document.addEventListener('keydown', this._escHandler);
|
||||
}
|
||||
|
||||
positionTooltip(rect, position) {
|
||||
const margin = 12;
|
||||
if (position === 'bottom') {
|
||||
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
|
||||
}
|
||||
if (position === 'top') {
|
||||
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.active = false;
|
||||
this.markDone();
|
||||
this.removeOverlay();
|
||||
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
|
||||
}
|
||||
|
||||
removeOverlay() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Performance Monitor Overlay
|
||||
// Shows FPS, memory usage, and network latency in real-time
|
||||
|
||||
export class PerfMonitor {
|
||||
constructor() {
|
||||
this.visible = false;
|
||||
this.panel = null;
|
||||
this.frames = [];
|
||||
this.lastFrameTime = 0;
|
||||
this.rafId = null;
|
||||
this.latencyHistory = [];
|
||||
this.maxHistory = 60;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
document.addEventListener('toggle-perf-monitor', () => this.toggle());
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'perf-monitor';
|
||||
this.panel.setAttribute('role', 'status');
|
||||
this.panel.setAttribute('aria-label', 'Performance monitor');
|
||||
this.panel.innerHTML = `
|
||||
<div class="perf-header">
|
||||
<span>PERF</span>
|
||||
<button class="perf-close" aria-label="Close performance monitor">×</button>
|
||||
</div>
|
||||
<div class="perf-metrics">
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">FPS</span>
|
||||
<span class="perf-value" data-metric="fps">--</span>
|
||||
<canvas class="perf-spark" data-spark="fps" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">MEM</span>
|
||||
<span class="perf-value" data-metric="memory">--</span>
|
||||
<canvas class="perf-spark" data-spark="memory" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">LAT</span>
|
||||
<span class="perf-value" data-metric="latency">--</span>
|
||||
<canvas class="perf-spark" data-spark="latency" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">DOM</span>
|
||||
<span class="perf-value" data-metric="dom">--</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.panel.querySelector('.perf-close').addEventListener('click', () => this.hide());
|
||||
|
||||
// Make it draggable
|
||||
this.makeDraggable();
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
this.sparkData = {
|
||||
fps: [],
|
||||
memory: [],
|
||||
latency: []
|
||||
};
|
||||
}
|
||||
|
||||
makeDraggable() {
|
||||
const header = this.panel.querySelector('.perf-header');
|
||||
let dragging = false;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
dragging = true;
|
||||
offsetX = e.clientX - this.panel.offsetLeft;
|
||||
offsetY = e.clientY - this.panel.offsetTop;
|
||||
header.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
this.panel.style.left = `${e.clientX - offsetX}px`;
|
||||
this.panel.style.top = `${e.clientY - offsetY}px`;
|
||||
this.panel.style.right = 'auto';
|
||||
this.panel.style.bottom = 'auto';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
dragging = false;
|
||||
header.style.cursor = 'grab';
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.panel.classList.add('visible');
|
||||
this.visible = true;
|
||||
this.lastFrameTime = performance.now();
|
||||
this.tick();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.panel.classList.remove('visible');
|
||||
this.visible = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.visible) return;
|
||||
|
||||
const now = performance.now();
|
||||
this.frames.push(now);
|
||||
|
||||
// Keep only last second of frames
|
||||
while (this.frames.length > 0 && this.frames[0] < now - 1000) {
|
||||
this.frames.shift();
|
||||
}
|
||||
|
||||
const fps = this.frames.length;
|
||||
this.updateMetric('fps', fps, 'fps');
|
||||
this.pushSpark('fps', fps, 0, 120);
|
||||
|
||||
// Memory (if available)
|
||||
if (performance.memory) {
|
||||
const mb = Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
|
||||
const total = Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024));
|
||||
this.updateMetric('memory', `${mb}MB`, mb > total * 0.8 ? 'warning' : 'ok');
|
||||
this.pushSpark('memory', mb, 0, total);
|
||||
} else {
|
||||
this.updateMetric('memory', 'N/A', 'na');
|
||||
}
|
||||
|
||||
// DOM node count
|
||||
const domNodes = document.querySelectorAll('*').length;
|
||||
this.updateMetric('dom', domNodes, domNodes > 3000 ? 'warning' : 'ok');
|
||||
|
||||
// Estimate latency from last navigation or resource timing
|
||||
this.measureLatency();
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.tick());
|
||||
}
|
||||
|
||||
measureLatency() {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
const latency = Math.round(last.responseEnd - last.requestStart);
|
||||
if (latency > 0 && latency < 30000) {
|
||||
this.latencyHistory.push(latency);
|
||||
if (this.latencyHistory.length > this.maxHistory) {
|
||||
this.latencyHistory.shift();
|
||||
}
|
||||
const avg = Math.round(
|
||||
this.latencyHistory.reduce((a, b) => a + b, 0) / this.latencyHistory.length
|
||||
);
|
||||
this.updateMetric('latency', `${avg}ms`, avg > 500 ? 'warning' : 'ok');
|
||||
this.pushSpark('latency', avg, 0, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMetric(metric, value, status) {
|
||||
const el = this.panel.querySelector(`[data-metric="${metric}"]`);
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
el.className = `perf-value perf-${status}`;
|
||||
}
|
||||
|
||||
pushSpark(name, value, min, max) {
|
||||
const data = this.sparkData[name];
|
||||
if (!data) return;
|
||||
data.push(value);
|
||||
if (data.length > 60) data.shift();
|
||||
this.drawSpark(name, data, min, max);
|
||||
}
|
||||
|
||||
drawSpark(name, data, min, max) {
|
||||
const canvas = this.panel.querySelector(`[data-spark="${name}"]`);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
const range = max - min || 1;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(50, 184, 198, 0.8)';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
data.forEach((val, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - ((val - min) / range) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.hide();
|
||||
if (this.panel?.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,14 +651,18 @@ export class PoseRenderer {
|
||||
this.performanceMetrics.frameCount++;
|
||||
|
||||
if (this.performanceMetrics.lastFrameTime > 0) {
|
||||
const deltaTime = currentTime - this.performanceMetrics.lastFrameTime;
|
||||
// Clamp to a minimum dt so consecutive frames within the same
|
||||
// performance.now() tick don't yield Infinity (issue #519 Bug 2).
|
||||
// 1 ms floor caps the displayed FPS at 1000 — far above any real
|
||||
// render rate, but finite so the EMA stays well-defined.
|
||||
const deltaTime = Math.max(currentTime - this.performanceMetrics.lastFrameTime, 1);
|
||||
const fps = 1000 / deltaTime;
|
||||
|
||||
|
||||
// Update average FPS using exponential moving average
|
||||
if (this.performanceMetrics.averageFps === 0) {
|
||||
this.performanceMetrics.averageFps = fps;
|
||||
} else {
|
||||
this.performanceMetrics.averageFps =
|
||||
this.performanceMetrics.averageFps =
|
||||
(this.performanceMetrics.averageFps * 0.9) + (fps * 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Quick Settings Panel - Centralized configuration for all UI features
|
||||
// Accessible via gear icon in header
|
||||
|
||||
export class QuickSettings {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.button = null;
|
||||
this.panel = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'settings-gear';
|
||||
this.button.setAttribute('aria-label', 'Settings');
|
||||
this.button.setAttribute('title', 'Quick settings');
|
||||
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
|
||||
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) headerInfo.appendChild(this.button);
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'quick-settings-panel';
|
||||
this.panel.setAttribute('role', 'dialog');
|
||||
this.panel.setAttribute('aria-label', 'Quick settings');
|
||||
|
||||
this.panel.innerHTML = `
|
||||
<div class="qs-header">
|
||||
<h3>Settings</h3>
|
||||
<button class="qs-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="qs-body">
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Display</div>
|
||||
<label class="qs-toggle">
|
||||
<span>Reduced motion</span>
|
||||
<input type="checkbox" id="qs-reduced-motion" ${this.prefersReducedMotion() ? 'checked' : ''}>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>High contrast</span>
|
||||
<input type="checkbox" id="qs-high-contrast">
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>Compact mode</span>
|
||||
<input type="checkbox" id="qs-compact" ${this.getSetting('compact') ? 'checked' : ''}>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Monitoring</div>
|
||||
<label class="qs-toggle">
|
||||
<span>Health polling</span>
|
||||
<input type="checkbox" id="qs-health-polling" checked>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>Auto-reconnect</span>
|
||||
<input type="checkbox" id="qs-auto-reconnect" checked>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Data</div>
|
||||
<div class="qs-row">
|
||||
<span>Clear local data</span>
|
||||
<button class="qs-btn-danger" id="qs-clear-data">Clear</button>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span>Reset onboarding</span>
|
||||
<button class="qs-btn" id="qs-reset-tour">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
|
||||
|
||||
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('reduced-motion', e.target.checked);
|
||||
this.saveSetting('reduced-motion', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-high-contrast').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('high-contrast', e.target.checked);
|
||||
this.saveSetting('high-contrast', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-compact').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('compact-mode', e.target.checked);
|
||||
this.saveSetting('compact', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-health-polling').addEventListener('change', (e) => {
|
||||
const healthService = this.app?.components?.dashboard?.healthSubscription;
|
||||
if (e.target.checked) {
|
||||
// Resume would need import - just dispatch event
|
||||
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: true }));
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: false }));
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
} catch { /* noop */ }
|
||||
this.close();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-reset-tour').addEventListener('click', () => {
|
||||
try { localStorage.removeItem('ruview-onboarding-done'); } catch { /* noop */ }
|
||||
this.close();
|
||||
document.dispatchEvent(new CustomEvent('start-onboarding'));
|
||||
});
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Apply saved settings on init
|
||||
this.applySavedSettings();
|
||||
}
|
||||
|
||||
applySavedSettings() {
|
||||
if (this.getSetting('reduced-motion') || this.prefersReducedMotion()) {
|
||||
document.body.classList.add('reduced-motion');
|
||||
const cb = this.panel.querySelector('#qs-reduced-motion');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
if (this.getSetting('high-contrast')) {
|
||||
document.body.classList.add('high-contrast');
|
||||
const cb = this.panel.querySelector('#qs-high-contrast');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
if (this.getSetting('compact')) {
|
||||
document.body.classList.add('compact-mode');
|
||||
}
|
||||
}
|
||||
|
||||
prefersReducedMotion() {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
}
|
||||
|
||||
getSetting(key) {
|
||||
try { return JSON.parse(localStorage.getItem(`ruview-setting-${key}`)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
saveSetting(key, value) {
|
||||
try { localStorage.setItem(`ruview-setting-${key}`, JSON.stringify(value)); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.button?.remove();
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Hash Router - Makes tabs bookmarkable and shareable
|
||||
// URL format: #dashboard, #demo, #sensing, etc.
|
||||
|
||||
export class Router {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
|
||||
}
|
||||
|
||||
init() {
|
||||
// Navigate to hash on load
|
||||
this.onHashChange();
|
||||
|
||||
// Listen for hash changes (back/forward navigation)
|
||||
window.addEventListener('hashchange', () => this.onHashChange());
|
||||
|
||||
// Update hash when tab changes
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager) {
|
||||
tabManager.onTabChange((tabId) => {
|
||||
this.setHash(tabId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHashChange() {
|
||||
const hash = window.location.hash.replace('#', '').toLowerCase();
|
||||
if (hash && this.validTabs.includes(hash)) {
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager && tabManager.getActiveTab() !== hash) {
|
||||
tabManager.switchToTab(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHash(tabId) {
|
||||
// Only update if different to avoid infinite loop
|
||||
const current = window.location.hash.replace('#', '');
|
||||
if (current !== tabId) {
|
||||
history.replaceState(null, '', `#${tabId}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// No explicit cleanup needed - event listeners are on window
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Screenshot Tool - Capture current tab view as PNG
|
||||
// Uses html2canvas-like approach with native Canvas API
|
||||
|
||||
import { toastManager } from './toast.js';
|
||||
|
||||
export class ScreenshotTool {
|
||||
constructor() {
|
||||
this.capturing = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('take-screenshot', () => this.capture());
|
||||
}
|
||||
|
||||
async capture() {
|
||||
if (this.capturing) return;
|
||||
this.capturing = true;
|
||||
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (!activeTab) {
|
||||
toastManager.warning('No active tab to capture');
|
||||
this.capturing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Flash effect
|
||||
this.flashEffect();
|
||||
|
||||
// Try native ClipboardItem API first (modern browsers)
|
||||
if (typeof ClipboardItem !== 'undefined') {
|
||||
await this.captureToClipboard(activeTab);
|
||||
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
|
||||
} else {
|
||||
// Fallback: download as file
|
||||
await this.captureToFile(activeTab);
|
||||
toastManager.success('Screenshot saved as file', { duration: 3000 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Screenshot failed:', err);
|
||||
// Fallback: capture visible canvases + basic layout
|
||||
try {
|
||||
await this.captureCanvasFallback(activeTab);
|
||||
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
|
||||
} catch {
|
||||
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
|
||||
}
|
||||
}
|
||||
|
||||
this.capturing = false;
|
||||
}
|
||||
|
||||
async captureToClipboard(element) {
|
||||
const canvas = await this.renderToCanvas(element);
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
]);
|
||||
}
|
||||
|
||||
async captureToFile(element) {
|
||||
const canvas = await this.renderToCanvas(element);
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
async captureCanvasFallback(element) {
|
||||
// Find any canvas elements and merge them
|
||||
const canvases = element.querySelectorAll('canvas');
|
||||
if (canvases.length === 0) throw new Error('No canvas elements found');
|
||||
|
||||
const firstCanvas = canvases[0];
|
||||
const mergedCanvas = document.createElement('canvas');
|
||||
mergedCanvas.width = firstCanvas.width || 800;
|
||||
mergedCanvas.height = firstCanvas.height || 600;
|
||||
const ctx = mergedCanvas.getContext('2d');
|
||||
|
||||
// Dark background
|
||||
ctx.fillStyle = '#1f2121';
|
||||
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
|
||||
|
||||
canvases.forEach(c => {
|
||||
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
|
||||
});
|
||||
|
||||
// Add timestamp watermark
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
|
||||
|
||||
const dataUrl = mergedCanvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
async renderToCanvas(element) {
|
||||
// Simple DOM-to-canvas renderer for basic content
|
||||
const rect = element.getBoundingClientRect();
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * scale;
|
||||
canvas.height = rect.height * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Render background
|
||||
const styles = getComputedStyle(element);
|
||||
ctx.fillStyle = styles.backgroundColor || '#1f2121';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Render existing canvases
|
||||
const canvases = element.querySelectorAll('canvas');
|
||||
canvases.forEach(c => {
|
||||
const cRect = c.getBoundingClientRect();
|
||||
const x = cRect.left - rect.left;
|
||||
const y = cRect.top - rect.top;
|
||||
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
|
||||
});
|
||||
|
||||
// Render text content
|
||||
ctx.fillStyle = styles.color || '#e0e0e0';
|
||||
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
|
||||
let textY = 30;
|
||||
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
|
||||
const text = el.textContent.trim();
|
||||
if (text && textY < rect.height - 20) {
|
||||
const elStyles = getComputedStyle(el);
|
||||
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
|
||||
ctx.fillStyle = elStyles.color;
|
||||
ctx.fillText(text, 20, textY);
|
||||
textY += parseInt(elStyles.fontSize) + 8;
|
||||
}
|
||||
});
|
||||
|
||||
// Watermark
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
flashEffect() {
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'screenshot-flash';
|
||||
document.body.appendChild(flash);
|
||||
flash.addEventListener('animationend', () => flash.remove());
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Theme Toggle - Manual dark/light mode switch with persistence
|
||||
|
||||
export class ThemeToggle {
|
||||
constructor() {
|
||||
this.button = null;
|
||||
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createButton();
|
||||
this.applyTheme(this.currentTheme);
|
||||
document.addEventListener('toggle-theme', () => this.toggle());
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!this.getSavedTheme()) {
|
||||
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'theme-toggle';
|
||||
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
|
||||
this.button.setAttribute('title', 'Toggle theme (T)');
|
||||
this.updateIcon();
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
// Insert into header
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.button);
|
||||
} else {
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.appendChild(this.button);
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||
this.applyTheme(this.currentTheme);
|
||||
this.saveTheme(this.currentTheme);
|
||||
}
|
||||
|
||||
applyTheme(theme) {
|
||||
this.currentTheme = theme;
|
||||
document.documentElement.setAttribute('data-color-scheme', theme);
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
if (!this.button) return;
|
||||
const isDark = this.currentTheme === 'dark';
|
||||
this.button.innerHTML = isDark
|
||||
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
|
||||
}
|
||||
|
||||
getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
getSavedTheme() {
|
||||
try {
|
||||
return localStorage.getItem('ruview-theme');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
saveTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem('ruview-theme', theme);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.button?.parentNode) {
|
||||
this.button.parentNode.removeChild(this.button);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Enhanced Toast Notification System
|
||||
// Supports multiple types: success, error, warning, info
|
||||
// Stacking, auto-dismiss, manual close, progress bar
|
||||
|
||||
export class ToastManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.toasts = [];
|
||||
this.idCounter = 0;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'toast-container';
|
||||
this.container.setAttribute('role', 'region');
|
||||
this.container.setAttribute('aria-label', 'Notifications');
|
||||
this.container.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
show(message, options = {}) {
|
||||
const {
|
||||
type = 'info',
|
||||
duration = 5000,
|
||||
closable = true,
|
||||
icon = null,
|
||||
action = null
|
||||
} = options;
|
||||
|
||||
const id = ++this.idCounter;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.dataset.toastId = id;
|
||||
|
||||
const iconMap = {
|
||||
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
|
||||
};
|
||||
|
||||
const displayIcon = icon || iconMap[type] || iconMap.info;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${displayIcon}</div>
|
||||
<div class="toast-content">
|
||||
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
|
||||
</div>
|
||||
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">×</button>' : ''}
|
||||
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
if (closable) {
|
||||
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
|
||||
}
|
||||
if (action?.onClick) {
|
||||
toast.querySelector('.toast-action')?.addEventListener('click', () => {
|
||||
action.onClick();
|
||||
this.dismiss(id);
|
||||
});
|
||||
}
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
// Trigger enter animation
|
||||
requestAnimationFrame(() => toast.classList.add('toast-enter'));
|
||||
|
||||
// Auto-dismiss
|
||||
let timeoutId = null;
|
||||
if (duration > 0) {
|
||||
const progressBar = toast.querySelector('.toast-progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.animationDuration = `${duration}ms`;
|
||||
progressBar.classList.add('toast-progress-animate');
|
||||
}
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
|
||||
// Pause on hover
|
||||
toast.addEventListener('mouseenter', () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'paused';
|
||||
}
|
||||
});
|
||||
toast.addEventListener('mouseleave', () => {
|
||||
if (duration > 0) {
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'running';
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
|
||||
}
|
||||
});
|
||||
|
||||
this.toasts.push({ id, toast, timeoutId });
|
||||
return id;
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
const index = this.toasts.findIndex(t => t.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const { toast, timeoutId } = this.toasts[index];
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
toast.classList.add('toast-exit');
|
||||
toast.addEventListener('animationend', () => {
|
||||
toast.remove();
|
||||
}, { once: true });
|
||||
|
||||
this.toasts.splice(index, 1);
|
||||
}
|
||||
|
||||
success(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'success' });
|
||||
}
|
||||
|
||||
error(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
|
||||
}
|
||||
|
||||
warning(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
|
||||
}
|
||||
|
||||
info(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'info' });
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.toasts.forEach(({ timeoutId }) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
this.toasts = [];
|
||||
if (this.container?.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toastManager = new ToastManager();
|
||||
@@ -0,0 +1,61 @@
|
||||
// Uptime Clock - Shows system uptime and current time in header
|
||||
|
||||
export class UptimeClock {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
this.startTime = Date.now();
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createWidget();
|
||||
this.update();
|
||||
this.intervalId = setInterval(() => this.update(), 1000);
|
||||
}
|
||||
|
||||
createWidget() {
|
||||
this.widget = document.createElement('div');
|
||||
this.widget.className = 'uptime-clock';
|
||||
this.widget.setAttribute('aria-label', 'System uptime');
|
||||
this.widget.innerHTML = `
|
||||
<span class="uptime-time"></span>
|
||||
<span class="uptime-separator">|</span>
|
||||
<span class="uptime-duration" title="Session uptime"></span>
|
||||
`;
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.appendChild(this.widget);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.widget) return;
|
||||
|
||||
// Current time
|
||||
const now = new Date();
|
||||
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
this.widget.querySelector('.uptime-time').textContent = time;
|
||||
|
||||
// Uptime
|
||||
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
this.widget.querySelector('.uptime-duration').textContent = this.formatDuration(elapsed);
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.intervalId) clearInterval(this.intervalId);
|
||||
this.widget?.remove();
|
||||
}
|
||||
}
|
||||
Generated
+66
-34
@@ -913,6 +913,18 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
@@ -1300,13 +1312,34 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1317,7 +1350,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -2850,7 +2883,7 @@ version = "0.17.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
||||
dependencies = [
|
||||
"console",
|
||||
"console 0.15.11",
|
||||
"number_prefix",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
@@ -3934,7 +3967,7 @@ dependencies = [
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3949,7 +3982,7 @@ dependencies = [
|
||||
"nvsim",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
@@ -4131,7 +4164,7 @@ version = "0.10.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fccd2c4f5271ab871f2069cb6f1a13ef2c0db50e1145ce03428ee541f4c63c4f"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"openblas-build",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
@@ -5227,6 +5260,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -6796,7 +6840,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -6846,7 +6890,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -7664,7 +7708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -8309,10 +8353,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-api"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
@@ -8322,7 +8362,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored",
|
||||
"console",
|
||||
"console 0.16.3",
|
||||
"csv",
|
||||
"indicatif",
|
||||
"predicates",
|
||||
@@ -8330,7 +8370,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tabled",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -8338,10 +8378,6 @@ dependencies = [
|
||||
"wifi-densepose-mat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-config"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.0"
|
||||
@@ -8354,14 +8390,10 @@ dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-db"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-desktop"
|
||||
version = "0.3.0"
|
||||
@@ -8384,7 +8416,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-shell",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-serial",
|
||||
"tracing",
|
||||
@@ -8418,7 +8450,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -8444,7 +8476,7 @@ dependencies = [
|
||||
"ruvector-temporal-tensor",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
@@ -8472,7 +8504,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tch",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -8485,7 +8517,7 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -8510,7 +8542,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8556,7 +8588,7 @@ dependencies = [
|
||||
"ruvector-solver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-ruvector",
|
||||
]
|
||||
@@ -8589,7 +8621,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tch",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"tracing",
|
||||
@@ -8661,7 +8693,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9349,7 +9381,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
|
||||
+13
-6
@@ -4,9 +4,16 @@ members = [
|
||||
"crates/wifi-densepose-core",
|
||||
"crates/wifi-densepose-signal",
|
||||
"crates/wifi-densepose-nn",
|
||||
"crates/wifi-densepose-api",
|
||||
"crates/wifi-densepose-db",
|
||||
"crates/wifi-densepose-config",
|
||||
# wifi-densepose-api / -db / -config: removed in #578.
|
||||
# The crate names were reserved early for an envisioned REST/database/config
|
||||
# split, but no implementation followed and no code referenced them. The
|
||||
# functionality they would provide is covered today by:
|
||||
# - REST/WS: `wifi-densepose-sensing-server` (Axum)
|
||||
# - Config: per-crate config + CLI args in `wifi-densepose-sensing-server`
|
||||
# and `wifi-densepose-desktop`
|
||||
# - DB: no persistent state; system is real-time
|
||||
# If we ever need any of these as a published surface, they can be
|
||||
# reintroduced with a real implementation.
|
||||
"crates/wifi-densepose-hardware",
|
||||
"crates/wifi-densepose-wasm",
|
||||
"crates/wifi-densepose-cli",
|
||||
@@ -46,7 +53,7 @@ categories = ["science", "computer-vision", "wasm"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Core utilities
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -56,7 +63,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Signal processing
|
||||
ndarray = { version = "0.15", features = ["serde"] }
|
||||
ndarray = { version = "0.17", features = ["serde"] }
|
||||
ndarray-linalg = { version = "0.18", features = ["openblas-static"] }
|
||||
rustfft = "6.1"
|
||||
num-complex = "0.4"
|
||||
@@ -98,7 +105,7 @@ pcap = "1.1"
|
||||
petgraph = "0.6"
|
||||
|
||||
# Data loading
|
||||
ndarray-npy = "0.8"
|
||||
ndarray-npy = "0.10"
|
||||
walkdir = "2.4"
|
||||
|
||||
# Hashing (for proof)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "REST API for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "api", "rest", "densepose", "websocket"]
|
||||
categories = ["web-programming::http-server", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,71 +0,0 @@
|
||||
# wifi-densepose-api
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-api)
|
||||
[](https://docs.rs/wifi-densepose-api)
|
||||
[](LICENSE)
|
||||
|
||||
REST and WebSocket API layer for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-api` provides the HTTP service boundary for WiFi-DensePose. Built on
|
||||
[axum](https://github.com/tokio-rs/axum), it exposes REST endpoints for pose queries, CSI frame
|
||||
ingestion, and model management, plus a WebSocket feed for real-time pose streaming to frontend
|
||||
clients.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **REST endpoints** -- CRUD for scan zones, pose queries, model configuration, and health checks.
|
||||
- **WebSocket streaming** -- Real-time pose estimate broadcasts with per-client subscription filters.
|
||||
- **Authentication** -- Token-based auth middleware via `tower` layers.
|
||||
- **Rate limiting** -- Configurable per-route limits to protect hardware-constrained deployments.
|
||||
- **OpenAPI spec** -- Auto-generated documentation via `utoipa`.
|
||||
- **CORS** -- Configurable cross-origin support for browser-based dashboards.
|
||||
- **Graceful shutdown** -- Clean connection draining on SIGTERM.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_api::Server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let server = Server::builder()
|
||||
.bind("0.0.0.0:3000")
|
||||
.with_websocket("/ws/poses")
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
server.run().await
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/v1/health` | Liveness and readiness probes |
|
||||
| `GET` | `/api/v1/poses` | Latest pose estimates |
|
||||
| `POST` | `/api/v1/csi` | Ingest raw CSI frames |
|
||||
| `GET` | `/api/v1/zones` | List scan zones |
|
||||
| `POST` | `/api/v1/zones` | Create a scan zone |
|
||||
| `WS` | `/ws/poses` | Real-time pose stream |
|
||||
| `WS` | `/ws/vitals` | Real-time vital sign stream |
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | Shared types and traits |
|
||||
| [`wifi-densepose-config`](../wifi-densepose-config) | Configuration loading |
|
||||
| [`wifi-densepose-db`](../wifi-densepose-db) | Database persistence |
|
||||
| [`wifi-densepose-nn`](../wifi-densepose-nn) | Neural network inference |
|
||||
| [`wifi-densepose-signal`](../wifi-densepose-signal) | CSI signal processing |
|
||||
| [`wifi-densepose-sensing-server`](../wifi-densepose-sensing-server) | Lightweight sensing UI server |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose REST API (stub)
|
||||
@@ -30,7 +30,7 @@ clap = { version = "4.4", features = ["derive", "env", "cargo"] }
|
||||
colored = "2.1"
|
||||
tabled = { version = "0.20", features = ["ansi"] }
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
console = "0.16"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
@@ -42,7 +42,7 @@ csv = "1.3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Configuration management for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "configuration", "densepose", "settings", "toml"]
|
||||
categories = ["config", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,89 +0,0 @@
|
||||
# wifi-densepose-config
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-config)
|
||||
[](https://docs.rs/wifi-densepose-config)
|
||||
[](LICENSE)
|
||||
|
||||
Configuration management for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-config` provides a unified configuration layer that merges values from environment
|
||||
variables, TOML/YAML files, and CLI overrides into strongly-typed Rust structs. Built on the
|
||||
[config](https://docs.rs/config), [dotenvy](https://docs.rs/dotenvy), and
|
||||
[envy](https://docs.rs/envy) ecosystem from the workspace.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **Multi-source loading** -- Merge configuration from `.env`, TOML files, YAML files, and
|
||||
environment variables with well-defined precedence.
|
||||
- **Typed configuration** -- Strongly-typed structs for server, signal processing, neural network,
|
||||
hardware, and database settings.
|
||||
- **Validation** -- Schema validation with human-readable error messages on startup.
|
||||
- **Hot reload** -- Watch configuration files for changes and notify dependent services.
|
||||
- **Profile support** -- Named profiles (`development`, `production`, `testing`) with per-profile
|
||||
overrides.
|
||||
- **Secret filtering** -- Redact sensitive values (API keys, database passwords) in logs and debug
|
||||
output.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_config::AppConfig;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Loads from env, config.toml, and CLI overrides
|
||||
let config = AppConfig::load()?;
|
||||
|
||||
println!("Server bind: {}", config.server.bind_address);
|
||||
println!("CSI sample rate: {} Hz", config.signal.sample_rate);
|
||||
println!("Model path: {}", config.nn.model_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Configuration Structure
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
|
||||
[server]
|
||||
bind_address = "0.0.0.0:3000"
|
||||
websocket_path = "/ws/poses"
|
||||
|
||||
[signal]
|
||||
sample_rate = 100
|
||||
subcarrier_count = 56
|
||||
hampel_window = 5
|
||||
|
||||
[nn]
|
||||
model_path = "./models/densepose.rvf"
|
||||
backend = "ort" # ort | candle | tch
|
||||
batch_size = 8
|
||||
|
||||
[hardware]
|
||||
esp32_udp_port = 5005
|
||||
serial_baud = 921600
|
||||
|
||||
[database]
|
||||
url = "sqlite://data/wifi-densepose.db"
|
||||
max_connections = 5
|
||||
```
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | Shared types and traits |
|
||||
| [`wifi-densepose-api`](../wifi-densepose-api) | REST API (consumer) |
|
||||
| [`wifi-densepose-db`](../wifi-densepose-db) | Database layer (consumer) |
|
||||
| [`wifi-densepose-cli`](../wifi-densepose-cli) | CLI (consumer) |
|
||||
| [`wifi-densepose-sensing-server`](../wifi-densepose-sensing-server) | Sensing server (consumer) |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose configuration (stub)
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Database layer for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "database", "storage", "densepose", "persistence"]
|
||||
categories = ["database", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,106 +0,0 @@
|
||||
# wifi-densepose-db
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-db)
|
||||
[](https://docs.rs/wifi-densepose-db)
|
||||
[](LICENSE)
|
||||
|
||||
Database persistence layer for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-db` implements the `DataStore` trait defined in `wifi-densepose-core`, providing
|
||||
persistent storage for CSI frames, pose estimates, scan sessions, and alert history. The intended
|
||||
backends are [SQLx](https://docs.rs/sqlx) for relational storage (PostgreSQL and SQLite) and
|
||||
[Redis](https://docs.rs/redis) for real-time caching and pub/sub.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **Dual backend** -- PostgreSQL for production deployments, SQLite for single-node and embedded
|
||||
use. Selectable at compile time via feature flags.
|
||||
- **Redis caching** -- Connection-pooled Redis for low-latency pose estimate lookups, session
|
||||
state, and pub/sub event distribution.
|
||||
- **Migrations** -- Embedded SQL migrations managed by SQLx, applied automatically on startup.
|
||||
- **Repository pattern** -- Typed repository structs (`PoseRepository`, `SessionRepository`,
|
||||
`AlertRepository`) implementing the core `DataStore` trait.
|
||||
- **Connection pooling** -- Configurable pool sizes via `sqlx::PgPool` / `sqlx::SqlitePool`.
|
||||
- **Transaction support** -- Scoped transactions for multi-table writes (e.g., survivor detection
|
||||
plus alert creation).
|
||||
- **Time-series optimisation** -- Partitioned tables and retention policies for high-frequency CSI
|
||||
frame storage.
|
||||
|
||||
### Planned feature flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------------|---------|-------------|
|
||||
| `postgres` | no | Enable PostgreSQL backend |
|
||||
| `sqlite` | yes | Enable SQLite backend |
|
||||
| `redis` | no | Enable Redis caching layer |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_db::{Database, PoseRepository};
|
||||
use wifi_densepose_core::PoseEstimate;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db = Database::connect("sqlite://data/wifi-densepose.db").await?;
|
||||
db.run_migrations().await?;
|
||||
|
||||
let repo = PoseRepository::new(db.pool());
|
||||
|
||||
// Store a pose estimate
|
||||
repo.insert(&pose_estimate).await?;
|
||||
|
||||
// Query recent poses
|
||||
let recent = repo.find_recent(10).await?;
|
||||
println!("Last 10 poses: {:?}", recent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Schema
|
||||
|
||||
```sql
|
||||
-- Core tables
|
||||
CREATE TABLE csi_frames (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
subcarriers BYTEA NOT NULL,
|
||||
antenna_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE pose_estimates (
|
||||
id UUID PRIMARY KEY,
|
||||
frame_id UUID REFERENCES csi_frames(id),
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
keypoints JSONB NOT NULL,
|
||||
confidence REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE scan_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
config JSONB NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | `DataStore` trait definition |
|
||||
| [`wifi-densepose-config`](../wifi-densepose-config) | Database connection configuration |
|
||||
| [`wifi-densepose-api`](../wifi-densepose-api) | REST API (consumer) |
|
||||
| [`wifi-densepose-mat`](../wifi-densepose-mat) | Disaster detection (consumer) |
|
||||
| [`wifi-densepose-signal`](../wifi-densepose-signal) | CSI signal processing |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose database layer (stub)
|
||||
+26
-36
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "ruview-desktop-ui",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ruview-desktop-ui",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.4",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -53,7 +53,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1165,12 +1164,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
@@ -1247,20 +1246,19 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"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": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
@@ -1317,7 +1315,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1587,7 +1584,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1629,7 +1625,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -1638,16 +1633,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
@@ -1706,13 +1700,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"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",
|
||||
@@ -1752,9 +1743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1802,7 +1793,6 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ byteorder = "1.5"
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
# Serialization
|
||||
|
||||
@@ -39,7 +39,7 @@ axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Serialization
|
||||
|
||||
@@ -17,5 +17,5 @@ axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
chrono = "0.4"
|
||||
dirs = "6"
|
||||
dirs = "5"
|
||||
reqwest = { version = "0.12", features = ["json"], default-features = false }
|
||||
|
||||
@@ -91,7 +91,11 @@ fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
|
||||
// IQR (inter-quartile range).
|
||||
let mut sorted = amps.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
// partial_cmp returns None on NaN — fall back to Equal so a single NaN
|
||||
// frame from real ESP32 hardware (silent DSP div-by-zero, empty buffer)
|
||||
// can't panic the whole sensing server (#611). The same file already
|
||||
// uses unwrap_or(Equal) at lines 149-150 and 155; this was an oversight.
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let q1 = sorted[sorted.len() / 4];
|
||||
let q3 = sorted[3 * sorted.len() / 4];
|
||||
let iqr = q3 - q1;
|
||||
@@ -196,9 +200,11 @@ impl AdaptiveModel {
|
||||
probs[c] = ((logits[c] - max_logit).exp()) / exp_sum;
|
||||
}
|
||||
|
||||
// Pick argmax.
|
||||
// Pick argmax. Same NaN-panic class as #611: if any raw_feature is NaN
|
||||
// it propagates through normalize → logits → softmax, then partial_cmp
|
||||
// returns None and unwrap() panics the sensing server on every frame.
|
||||
let (best_c, best_p) = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap();
|
||||
let label = if best_c < self.class_names.len() {
|
||||
self.class_names[best_c].clone()
|
||||
@@ -473,7 +479,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
||||
}
|
||||
}
|
||||
let pred = logits.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap().0;
|
||||
if pred == *target { correct += 1; }
|
||||
}
|
||||
@@ -493,7 +499,7 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
||||
}
|
||||
}
|
||||
let pred = logits.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap().0;
|
||||
if pred == *target { class_correct[*target] += 1; }
|
||||
}
|
||||
|
||||
@@ -598,11 +598,13 @@ pub fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) ->
|
||||
}
|
||||
}
|
||||
|
||||
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
|
||||
// empty iterator, not a comparator panic. Same NaN-panic class as #611.
|
||||
let (max_var_idx, _) = active.iter().enumerate()
|
||||
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
|
||||
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap_or((0, &0));
|
||||
let (min_var_idx, _) = active.iter().enumerate()
|
||||
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
|
||||
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap_or((0, &0));
|
||||
if max_var_idx == min_var_idx { return 1; }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod bearer_auth;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod path_safety;
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
|
||||
@@ -555,6 +555,93 @@ fn build_node_features(
|
||||
Some(entries)
|
||||
}
|
||||
|
||||
// ── ADR-044 §5.2: Rolling P95 adaptive feature normalizer ────────────────────
|
||||
|
||||
/// Streaming P95 estimator over a fixed-size sliding window.
|
||||
///
|
||||
/// Self-calibrates feature normalization to whatever distribution the deployment
|
||||
/// produces — no hardcoded scale values that can saturate in large rooms or
|
||||
/// degrade in high-interference environments.
|
||||
///
|
||||
/// O(n log n) per query via sorted copy — acceptable at 20 Hz with window=600.
|
||||
/// Cold-start (len < min_samples) returns `None` so the caller uses the legacy
|
||||
/// fixed denominator, preserving day-0 behaviour.
|
||||
pub struct RollingP95 {
|
||||
buf: std::collections::VecDeque<f64>,
|
||||
window: usize,
|
||||
min_samples: usize,
|
||||
}
|
||||
|
||||
impl RollingP95 {
|
||||
pub fn new(window: usize, min_samples: usize) -> Self {
|
||||
Self {
|
||||
buf: std::collections::VecDeque::with_capacity(window),
|
||||
window,
|
||||
min_samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, v: f64) {
|
||||
if self.buf.len() == self.window {
|
||||
self.buf.pop_front();
|
||||
}
|
||||
self.buf.push_back(v);
|
||||
}
|
||||
|
||||
/// Returns `Some(p95)` once enough samples have accumulated, else `None`.
|
||||
pub fn current(&self) -> Option<f64> {
|
||||
if self.buf.len() < self.min_samples {
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<f64> = self.buf.iter().copied().collect();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
|
||||
Some(sorted[idx.saturating_sub(1).min(sorted.len() - 1)])
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.buf.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADR-044 §5.3: Runtime config persistence ─────────────────────────────────
|
||||
|
||||
/// Runtime configuration that persists across server restarts via `data/config.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RuntimeConfig {
|
||||
/// Divisor for multi-node person-count deduplication (sum / factor).
|
||||
pub dedup_factor: f64,
|
||||
}
|
||||
|
||||
impl Default for RuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self { dedup_factor: 3.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted runtime config from `<data_dir>/config.json`.
|
||||
/// Falls back to [`RuntimeConfig::default`] if the file is absent or malformed.
|
||||
pub(crate) fn load_runtime_config(data_dir: &std::path::Path) -> RuntimeConfig {
|
||||
let path = data_dir.join("config.json");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
|
||||
Err(_) => RuntimeConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist runtime config to `<data_dir>/config.json`.
|
||||
pub(crate) fn save_runtime_config(data_dir: &std::path::Path, config: &RuntimeConfig) {
|
||||
let path = data_dir.join("config.json");
|
||||
if let Ok(json) = serde_json::to_string_pretty(config) {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
warn!("Failed to save runtime config to {}: {e}", path.display());
|
||||
} else {
|
||||
info!("Runtime config saved to {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state
|
||||
struct AppStateInner {
|
||||
latest_update: Option<SensingUpdate>,
|
||||
@@ -662,6 +749,21 @@ struct AppStateInner {
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
/// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz).
|
||||
pub(crate) p95_variance: RollingP95,
|
||||
/// Rolling P95 of `FeatureInfo.motion_band_power` over the last ~30 s.
|
||||
pub(crate) p95_motion_band_power: RollingP95,
|
||||
/// Rolling P95 of `FeatureInfo.spectral_power` over the last ~30 s.
|
||||
pub(crate) p95_spectral_power: RollingP95,
|
||||
// ── ADR-044 §5.3: runtime-configurable dedup factor ───────────────────────
|
||||
/// Divisor for multi-node person-count deduplication (sum / factor).
|
||||
/// Default 3.0 (one body visible to ~3 nodes on average).
|
||||
/// Configurable at runtime via `POST /api/v1/config/dedup-factor` and
|
||||
/// `POST /api/v1/config/ground-truth`. Persisted across restarts.
|
||||
pub(crate) dedup_factor: f64,
|
||||
/// Data directory for persisting runtime config (parent of `firmware_dir`).
|
||||
pub(crate) data_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// If no ESP32 frame arrives within this duration, source reverts to offline.
|
||||
@@ -1748,8 +1850,13 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
|
||||
let feat_variance = features.variance;
|
||||
|
||||
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
|
||||
s.p95_variance.push(features.variance);
|
||||
s.p95_motion_band_power.push(features.motion_band_power);
|
||||
s.p95_spectral_power.push(features.spectral_power);
|
||||
|
||||
// Multi-person estimation with temporal smoothing (EMA α=0.10).
|
||||
let raw_score = compute_person_score(&features);
|
||||
let raw_score = compute_person_score(&*s, &features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
@@ -1887,8 +1994,13 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
|
||||
let feat_variance = features.variance;
|
||||
|
||||
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
|
||||
s.p95_variance.push(features.variance);
|
||||
s.p95_motion_band_power.push(features.motion_band_power);
|
||||
s.p95_spectral_power.push(features.spectral_power);
|
||||
|
||||
// Multi-person estimation with temporal smoothing (EMA α=0.10).
|
||||
let raw_score = compute_person_score(&features);
|
||||
let raw_score = compute_person_score(&*s, &features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
@@ -2350,13 +2462,19 @@ fn fuse_multi_node_features(
|
||||
///
|
||||
/// Returns a raw score (0.0..1.0) that the caller converts to person count
|
||||
/// after temporal smoothing.
|
||||
fn compute_person_score(feat: &FeatureInfo) -> f64 {
|
||||
// Normalize each feature to [0, 1] using ranges calibrated from real
|
||||
// ESP32 hardware (COM6/COM9 on ruv.net, March 2026).
|
||||
let var_norm = (feat.variance / 300.0).clamp(0.0, 1.0);
|
||||
fn compute_person_score(state: &AppStateInner, feat: &FeatureInfo) -> f64 {
|
||||
// ADR-044 §5.2: adaptive rolling-P95 normalization.
|
||||
// Legacy fixed denominators (variance/300, motion/250, spectral/500) saturate
|
||||
// when live ESP32 values exceed those limits — zero dynamic range results.
|
||||
// Use the P95 of the last ~30 s of history instead, falling back to the legacy
|
||||
// denominators during cold-start (<60 samples) to preserve day-0 behaviour.
|
||||
let var_denom = state.p95_variance.current().map(|p| p.max(50.0)).unwrap_or(300.0);
|
||||
let motion_denom = state.p95_motion_band_power.current().map(|p| p.max(50.0)).unwrap_or(250.0);
|
||||
let sp_denom = state.p95_spectral_power.current().map(|p| p.max(100.0)).unwrap_or(500.0);
|
||||
let var_norm = (feat.variance / var_denom).clamp(0.0, 1.0);
|
||||
let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0);
|
||||
let motion_norm = (feat.motion_band_power / 250.0).clamp(0.0, 1.0);
|
||||
let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0);
|
||||
let motion_norm = (feat.motion_band_power / motion_denom).clamp(0.0, 1.0);
|
||||
let sp_norm = (feat.spectral_power / sp_denom).clamp(0.0, 1.0);
|
||||
var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15
|
||||
}
|
||||
|
||||
@@ -2441,12 +2559,15 @@ fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) -> usiz
|
||||
}
|
||||
}
|
||||
|
||||
// Source → highest-variance subcarrier, Sink → lowest-variance
|
||||
// Source → highest-variance subcarrier, Sink → lowest-variance.
|
||||
// partial_cmp returns None on NaN; the outer unwrap_or only catches an
|
||||
// empty iterator, not a comparator panic. Same NaN-panic class as #611
|
||||
// — a single NaN variance frame would kill the sensing-server process.
|
||||
let (max_var_idx, _) = active.iter().enumerate()
|
||||
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
|
||||
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap_or((0, &0));
|
||||
let (min_var_idx, _) = active.iter().enumerate()
|
||||
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
|
||||
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap_or((0, &0));
|
||||
|
||||
if max_var_idx == min_var_idx {
|
||||
@@ -3805,8 +3926,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons = if vitals.presence {
|
||||
let dedup = s.dedup_factor;
|
||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||
&s.multistatic_fuser, &s.node_states,
|
||||
&s.multistatic_fuser, &s.node_states, dedup,
|
||||
);
|
||||
match fused {
|
||||
Some(ref f) => {
|
||||
@@ -4091,8 +4213,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons = if classification.presence {
|
||||
let dedup = s.dedup_factor;
|
||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||
&s.multistatic_fuser, &s.node_states,
|
||||
&s.multistatic_fuser, &s.node_states, dedup,
|
||||
);
|
||||
match fused {
|
||||
Some(ref f) => {
|
||||
@@ -4244,8 +4367,13 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
let frame_amplitudes = frame.amplitudes.clone();
|
||||
let frame_n_sub = frame.n_subcarriers;
|
||||
|
||||
// ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring.
|
||||
s.p95_variance.push(features.variance);
|
||||
s.p95_motion_band_power.push(features.motion_band_power);
|
||||
s.p95_spectral_power.push(features.spectral_power);
|
||||
|
||||
// Multi-person estimation with temporal smoothing (EMA α=0.10).
|
||||
let raw_score = compute_person_score(&features);
|
||||
let raw_score = compute_person_score(&*s, &features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
@@ -4331,7 +4459,18 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
|
||||
if s.tx.receiver_count() > 0 {
|
||||
// Re-broadcast the latest sensing_update so pose WS clients
|
||||
// always get data even when ESP32 pauses between frames.
|
||||
if let Ok(json) = serde_json::to_string(update) {
|
||||
//
|
||||
// Issue #618: overwrite `source` with `effective_source()`
|
||||
// before each broadcast so a stale latest_update (frozen
|
||||
// payload from a now-offline ESP32) is emitted with
|
||||
// `source: "esp32:offline"` instead of `source: "esp32"`.
|
||||
// The REST `/health` endpoint already does this; before
|
||||
// this fix the WS path was the only consumer that didn't,
|
||||
// so the UI's "LIVE — ESP32 HARDWARE Connected" banner
|
||||
// stayed green long after the hardware went away.
|
||||
let mut tagged = update.clone();
|
||||
tagged.source = s.effective_source();
|
||||
if let Ok(json) = serde_json::to_string(&tagged) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
}
|
||||
@@ -4904,6 +5043,11 @@ async fn main() {
|
||||
let initial_recordings = scan_recording_files();
|
||||
info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len());
|
||||
|
||||
// ADR-044 §5.3: load persisted runtime config from the data directory.
|
||||
let data_dir = std::path::PathBuf::from("data");
|
||||
let runtime_config = load_runtime_config(&data_dir);
|
||||
info!("Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor);
|
||||
|
||||
let (tx, _) = broadcast::channel::<String>(256);
|
||||
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
|
||||
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
|
||||
@@ -4985,6 +5129,13 @@ async fn main() {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples.
|
||||
p95_variance: RollingP95::new(600, 60),
|
||||
p95_motion_band_power: RollingP95::new(600, 60),
|
||||
p95_spectral_power: RollingP95::new(600, 60),
|
||||
// ADR-044 §5.3: runtime-configurable dedup factor (persisted).
|
||||
dedup_factor: runtime_config.dedup_factor,
|
||||
data_dir: data_dir.clone(),
|
||||
}));
|
||||
|
||||
// Start background tasks based on source
|
||||
@@ -5136,6 +5287,9 @@ async fn main() {
|
||||
.route("/api/v1/calibration/start", post(calibration_start))
|
||||
.route("/api/v1/calibration/stop", post(calibration_stop))
|
||||
.route("/api/v1/calibration/status", get(calibration_status))
|
||||
// ADR-044 §5.3: runtime-configurable dedup factor
|
||||
.route("/api/v1/config/dedup-factor", get(config_get_dedup_factor).post(config_set_dedup_factor))
|
||||
.route("/api/v1/config/ground-truth", post(config_set_ground_truth))
|
||||
// Static UI files
|
||||
.nest_service("/ui", ServeDir::new(&ui_path))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
@@ -5261,3 +5415,131 @@ mod novelty_tests {
|
||||
assert!(ns.last_novelty_score.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADR-044 §5.3: dedup_factor runtime configuration endpoints ────────────────
|
||||
|
||||
/// `GET /api/v1/config/dedup-factor` — read the current dedup factor.
|
||||
async fn config_get_dedup_factor(
|
||||
State(state): State<SharedState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"dedup_factor": s.dedup_factor,
|
||||
"description": "Divisor for multi-node person count deduplication (sum / factor). Range: 1.0–10.0."
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.0–10.0).
|
||||
///
|
||||
/// Body: `{ "value": <f64> }`
|
||||
async fn config_set_dedup_factor(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let value = body.get("value").and_then(|v| v.as_f64()).unwrap_or(3.0);
|
||||
let clamped = value.clamp(1.0, 10.0);
|
||||
let mut s = state.write().await;
|
||||
s.dedup_factor = clamped;
|
||||
let data_dir = s.data_dir.clone();
|
||||
drop(s);
|
||||
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"dedup_factor": clamped,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/config/ground-truth` — auto-tune dedup factor from a known person count.
|
||||
///
|
||||
/// Derives `dedup_factor = raw_node_sum / ground_truth_count` from the current
|
||||
/// per-node person counts, clamped to [1.0, 10.0]. Persisted immediately.
|
||||
///
|
||||
/// Body: `{ "count": <u64> }`
|
||||
async fn config_set_ground_truth(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let ground_truth = match body.get("count").and_then(|v| v.as_u64()) {
|
||||
Some(n) if n > 0 => n as usize,
|
||||
_ => return Json(serde_json::json!({"error": "count must be a positive integer"})),
|
||||
};
|
||||
let mut s = state.write().await;
|
||||
let raw_sum: usize = s.node_states.values()
|
||||
.filter(|ns| ns.last_frame_time
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(10))
|
||||
.unwrap_or(false))
|
||||
.map(|ns| ns.prev_person_count)
|
||||
.sum();
|
||||
let optimal = if raw_sum > 0 {
|
||||
(raw_sum as f64) / (ground_truth as f64)
|
||||
} else {
|
||||
3.0
|
||||
};
|
||||
let clamped = optimal.clamp(1.0, 10.0);
|
||||
s.dedup_factor = clamped;
|
||||
let data_dir = s.data_dir.clone();
|
||||
drop(s);
|
||||
save_runtime_config(&data_dir, &RuntimeConfig { dedup_factor: clamped });
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"ground_truth": ground_truth,
|
||||
"raw_sum": raw_sum,
|
||||
"computed_dedup_factor": clamped,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Unit tests: RollingP95 ─────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod rolling_p95_tests {
|
||||
use super::RollingP95;
|
||||
|
||||
#[test]
|
||||
fn cold_start_returns_none() {
|
||||
let p = RollingP95::new(100, 10);
|
||||
assert!(p.current().is_none(), "empty buffer must return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_min_samples_returns_none() {
|
||||
let mut p = RollingP95::new(100, 10);
|
||||
for i in 1..=9 {
|
||||
p.push(i as f64);
|
||||
}
|
||||
assert!(p.current().is_none(), "fewer than min_samples must return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p95_of_ramp_is_near_95() {
|
||||
let mut p = RollingP95::new(100, 10);
|
||||
for i in 1..=100 {
|
||||
p.push(i as f64);
|
||||
}
|
||||
let p95 = p.current().expect("should have value after 100 samples");
|
||||
assert!(
|
||||
p95 >= 94.0 && p95 <= 96.0,
|
||||
"P95 of 1..=100 should be ~95, got {p95}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_slides_evicts_oldest() {
|
||||
let mut p = RollingP95::new(5, 3);
|
||||
// Push 1..=5, then 100 — oldest (1) is evicted.
|
||||
for i in 1..=5 {
|
||||
p.push(i as f64);
|
||||
}
|
||||
p.push(100.0); // evicts 1; buf = [2, 3, 4, 5, 100]
|
||||
let p95 = p.current().expect("6 pushes, window=5 → 5 samples");
|
||||
// P95 of [2,3,4,5,100]: idx = ceil(5*0.95)=5 → sorted[4]=100
|
||||
assert_eq!(p95, 100.0, "largest value should dominate p95 after eviction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn len_reports_buffer_size() {
|
||||
let mut p = RollingP95::new(10, 5);
|
||||
assert_eq!(p.len(), 0);
|
||||
p.push(1.0);
|
||||
assert_eq!(p.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,11 @@ async fn scan_models() -> Vec<ModelInfo> {
|
||||
|
||||
/// Load a model from disk by ID and return its `LoadedModelState`.
|
||||
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
|
||||
// Path-traversal guard (#615). Reject any model_id that contains '/',
|
||||
// '..', null bytes, or anything outside [A-Za-z0-9._-]. The reject
|
||||
// happens before format!() so the path can never escape models_dir().
|
||||
let model_id = crate::path_safety::safe_id(model_id)
|
||||
.map_err(|e| format!("Invalid model_id: {e}"))?;
|
||||
let file_path = models_dir().join(format!("{model_id}.rvf"));
|
||||
let reader = RvfReader::from_file(&file_path)?;
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ pub fn node_frames_from_states(node_states: &HashMap<u8, NodeState>) -> Vec<Mult
|
||||
pub fn fuse_or_fallback(
|
||||
fuser: &MultistaticFuser,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
dedup_factor: f64,
|
||||
) -> (Option<FusedSensingFrame>, Option<usize>) {
|
||||
let frames = node_frames_from_states(node_states);
|
||||
if frames.is_empty() {
|
||||
@@ -109,9 +110,11 @@ pub fn fuse_or_fallback(
|
||||
(Some(fused), None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Multistatic fusion failed ({e}), using per-node max fallback");
|
||||
// Use max (not sum) to avoid double-counting when nodes have overlapping coverage.
|
||||
let max_count: usize = node_states
|
||||
tracing::debug!("Multistatic fusion failed ({e}), using per-node sum/dedup fallback");
|
||||
// Sum per-node counts then divide by dedup_factor (assumed average
|
||||
// visibility per body across nodes). ADR-044 §5.1.
|
||||
// dedup_factor is runtime-configurable; default 3.0.
|
||||
let total: usize = node_states
|
||||
.values()
|
||||
.filter(|ns| {
|
||||
ns.last_frame_time
|
||||
@@ -119,9 +122,9 @@ pub fn fuse_or_fallback(
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|ns| ns.prev_person_count)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(None, Some(max_count))
|
||||
.sum();
|
||||
let estimated = ((total as f64) / dedup_factor).ceil() as usize;
|
||||
(None, Some(estimated))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +260,7 @@ mod tests {
|
||||
fn test_fuse_or_fallback_empty() {
|
||||
let fuser = MultistaticFuser::new();
|
||||
let states: HashMap<u8, NodeState> = HashMap::new();
|
||||
let (fused, count) = fuse_or_fallback(&fuser, &states);
|
||||
let (fused, count) = fuse_or_fallback(&fuser, &states, 3.0);
|
||||
assert!(fused.is_none());
|
||||
assert_eq!(count, Some(0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Identifier sanitization for filesystem paths.
|
||||
//!
|
||||
//! Defense against directory traversal: the sensing-server has several REST
|
||||
//! endpoints that take a user-controlled identifier and use it directly to
|
||||
//! build a filesystem path:
|
||||
//!
|
||||
//! * `recording.rs` — `{session_name}.csi.jsonl` under `RECORDINGS_DIR`
|
||||
//! * `model_manager.rs` — `{model_id}.rvf` under `models_dir()`
|
||||
//! * `training_api.rs` — `{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`
|
||||
//!
|
||||
//! Without validation, an attacker can pass `../../etc/passwd` or similar to
|
||||
//! read, write, or delete arbitrary files the server process can access. See
|
||||
//! issue #615 for the full exploit catalogue.
|
||||
//!
|
||||
//! [`safe_id`] returns the input only when it is safe to embed in a
|
||||
//! `format!()` that builds a path under a fixed parent directory.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Maximum length for a safe identifier. 64 is generous for human-typed
|
||||
/// session names while keeping the resulting filename well under
|
||||
/// most filesystem limits.
|
||||
pub const MAX_ID_LEN: usize = 64;
|
||||
|
||||
/// Error returned by [`safe_id`] when the input is not safe to embed in a
|
||||
/// filesystem path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathSafetyError {
|
||||
/// Empty string is never a valid identifier.
|
||||
Empty,
|
||||
/// Identifier exceeds `MAX_ID_LEN` bytes.
|
||||
TooLong { len: usize, max: usize },
|
||||
/// Identifier contains a character not in the allowed set
|
||||
/// `[A-Za-z0-9._-]` (and the leading character is not `.`).
|
||||
/// Path separators, null bytes, parent-directory references, and any
|
||||
/// non-printable or non-ASCII characters all hit this.
|
||||
InvalidChar { ch: char, position: usize },
|
||||
/// Identifier is `"."` or `".."`, or any leading `.` that would
|
||||
/// otherwise be interpreted as a hidden file / parent reference.
|
||||
LeadingDot,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathSafetyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PathSafetyError::Empty => write!(f, "identifier is empty"),
|
||||
PathSafetyError::TooLong { len, max } => {
|
||||
write!(f, "identifier is {len} bytes (max {max})")
|
||||
}
|
||||
PathSafetyError::InvalidChar { ch, position } => write!(
|
||||
f,
|
||||
"identifier contains invalid character {ch:?} at position {position} \
|
||||
(only A-Z, a-z, 0-9, '.', '_', '-' are allowed)"
|
||||
),
|
||||
PathSafetyError::LeadingDot => write!(
|
||||
f,
|
||||
"identifier may not start with '.' (would be a hidden file \
|
||||
or parent-directory reference)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PathSafetyError {}
|
||||
|
||||
/// Return `Ok(input)` if the string is safe to embed in a filesystem path
|
||||
/// built under a fixed parent directory; otherwise return a structured error.
|
||||
///
|
||||
/// The allowed character set is `[A-Za-z0-9._-]`. The first character must
|
||||
/// not be `.` (rules out `..`, `.`, and hidden-file shenanigans).
|
||||
///
|
||||
/// Examples:
|
||||
/// ```ignore
|
||||
/// assert!(safe_id("my-session_42").is_ok());
|
||||
/// assert!(safe_id("session.v2").is_ok());
|
||||
/// assert!(safe_id("../../etc/passwd").is_err());
|
||||
/// assert!(safe_id("foo/bar").is_err());
|
||||
/// assert!(safe_id("..").is_err());
|
||||
/// assert!(safe_id(".env").is_err());
|
||||
/// assert!(safe_id("").is_err());
|
||||
/// ```
|
||||
pub fn safe_id(input: &str) -> Result<&str, PathSafetyError> {
|
||||
if input.is_empty() {
|
||||
return Err(PathSafetyError::Empty);
|
||||
}
|
||||
if input.len() > MAX_ID_LEN {
|
||||
return Err(PathSafetyError::TooLong {
|
||||
len: input.len(),
|
||||
max: MAX_ID_LEN,
|
||||
});
|
||||
}
|
||||
// Reject leading '.' to block `.`, `..`, `.env`, etc.
|
||||
if input.starts_with('.') {
|
||||
return Err(PathSafetyError::LeadingDot);
|
||||
}
|
||||
for (position, ch) in input.chars().enumerate() {
|
||||
let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
|
||||
if !ok {
|
||||
return Err(PathSafetyError::InvalidChar { ch, position });
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_simple_alphanumeric() {
|
||||
assert!(safe_id("foo").is_ok());
|
||||
assert!(safe_id("MyModel123").is_ok());
|
||||
assert!(safe_id("session-2026-05-17_v2").is_ok());
|
||||
assert!(safe_id("a.b.c").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert_eq!(safe_id(""), Err(PathSafetyError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_separators() {
|
||||
assert!(matches!(
|
||||
safe_id("foo/bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '/', .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo\\bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '\\', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_directory_traversal() {
|
||||
assert_eq!(safe_id("."), Err(PathSafetyError::LeadingDot));
|
||||
assert_eq!(safe_id(".."), Err(PathSafetyError::LeadingDot));
|
||||
assert_eq!(safe_id(".env"), Err(PathSafetyError::LeadingDot));
|
||||
// The classic attack vector — even after rejecting leading-dot,
|
||||
// the InvalidChar guard catches the embedded slash.
|
||||
assert!(matches!(
|
||||
safe_id("../../etc/passwd"),
|
||||
Err(PathSafetyError::LeadingDot)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_null_byte() {
|
||||
assert!(matches!(
|
||||
safe_id("foo\0bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '\0', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_whitespace_and_specials() {
|
||||
assert!(matches!(
|
||||
safe_id("foo bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: ' ', .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo;rm -rf /"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo$bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '$', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ascii() {
|
||||
// Reject unicode that could normalise to path separators in
|
||||
// weird filesystems, or just look like ASCII.
|
||||
assert!(matches!(
|
||||
safe_id("café"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
// Fullwidth slash (U+FF0F) — visually similar to '/'.
|
||||
assert!(matches!(
|
||||
safe_id("foo\u{FF0F}bar"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
let too_long = "a".repeat(MAX_ID_LEN + 1);
|
||||
assert_eq!(
|
||||
safe_id(&too_long),
|
||||
Err(PathSafetyError::TooLong {
|
||||
len: MAX_ID_LEN + 1,
|
||||
max: MAX_ID_LEN
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_max_len() {
|
||||
let at_max = "a".repeat(MAX_ID_LEN);
|
||||
assert!(safe_id(&at_max).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -274,9 +274,22 @@ async fn start_recording(
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate session_name BEFORE embedding it in a path. The legacy
|
||||
// `replace(' ', "_")` only normalised whitespace, not path traversal
|
||||
// (#615). Reject any session_name containing path separators or
|
||||
// parent-directory references.
|
||||
let safe_name = match crate::path_safety::safe_id(&body.session_name) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid session_name: {e}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let session_id = format!(
|
||||
"{}-{}",
|
||||
body.session_name.replace(' ', "_"),
|
||||
safe_name,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
||||
);
|
||||
let file_name = format!("{session_id}.csi.jsonl");
|
||||
@@ -346,6 +359,23 @@ async fn download_recording(
|
||||
State(_state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Path-traversal guard (#615). Reject any id that contains '/', '..',
|
||||
// null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
|
||||
// path. Otherwise GET /api/v1/recording/download/../../.env leaks
|
||||
// arbitrary files the server process can read.
|
||||
let id = match crate::path_safety::safe_id(&id) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid recording id: {e}"),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let dir = PathBuf::from(RECORDINGS_DIR);
|
||||
// Find the JSONL file matching the ID.
|
||||
let file_path = dir.join(format!("{id}.csi.jsonl"));
|
||||
@@ -390,6 +420,19 @@ async fn delete_recording(
|
||||
State(_state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Path-traversal guard (#615). Reject any id that contains '/', '..',
|
||||
// null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
|
||||
// paths. Otherwise DELETE /api/v1/recording/delete/../../config/database
|
||||
// can remove arbitrary files the server process can write.
|
||||
let id = match crate::path_safety::safe_id(&id) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid recording id: {e}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let dir = PathBuf::from(RECORDINGS_DIR);
|
||||
let jsonl_path = dir.join(format!("{id}.csi.jsonl"));
|
||||
let meta_path = dir.join(format!("{id}.csi.meta.json"));
|
||||
|
||||
@@ -239,7 +239,18 @@ async fn load_recording_frames(dataset_ids: &[String]) -> Vec<RecordedFrame> {
|
||||
let recordings_dir = PathBuf::from(RECORDINGS_DIR);
|
||||
|
||||
for id in dataset_ids {
|
||||
let file_path = recordings_dir.join(format!("{id}.csi.jsonl"));
|
||||
// Path-traversal guard (#615). Reject any dataset_id that contains
|
||||
// '/', '..', null bytes, or anything outside [A-Za-z0-9._-] BEFORE
|
||||
// building the format!() path. Otherwise an attacker could read any
|
||||
// file the server process can access via `dataset_ids: ["../../etc/passwd"]`.
|
||||
let safe = match crate::path_safety::safe_id(id) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("Skipping invalid dataset_id {id:?}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_path = recordings_dir.join(format!("{safe}.csi.jsonl"));
|
||||
let data = match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/ruvector updated: 53f0419782...dd5e145164
Vendored
+1
-1
Submodule vendor/sublinear-time-solver updated: 1210646955...9796cbc369
Reference in New Issue
Block a user