Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5518d5d7c1 | |||
| 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 | |||
| c4653b8bc6 | |||
| d214855228 | |||
| e6710e8988 | |||
| ab9799adc3 | |||
| bdb4484259 | |||
| ba370c7b08 | |||
| 3fdd310f89 | |||
| 98e7eeda42 | |||
| 5615edb24e | |||
| 9cc9419db9 | |||
| d544b8f070 | |||
| d33962eff2 | |||
| e22a24714a | |||
| cee414f3c0 | |||
| f853c74563 | |||
| 8b297dd706 | |||
| 9d4f7820b2 | |||
| b2fe452e74 | |||
| 88da304631 | |||
| 880a3a41d3 | |||
| 68b042faf6 | |||
| 4698f54fa0 | |||
| ea62ec4667 | |||
| 3685d16a49 | |||
| 8a155e07ec | |||
| 540ecb4538 | |||
| 10684972d7 | |||
| 27a6edba8b | |||
| 174e2365f0 | |||
| bf30844835 | |||
| 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
|
||||
|
||||
@@ -13,6 +13,9 @@ firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -127,6 +197,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
saturation, hyperfine spectroscopy, or pulsed protocols become required.
|
||||
|
||||
### Fixed
|
||||
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
|
||||
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
|
||||
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
|
||||
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
|
||||
Clients would reconnect, immediately lag again, and rapid-cycle every 2–4 s.
|
||||
`Lagged` now continues (drops missed frames, logs debug) rather than breaking.
|
||||
Added 30 s ping keepalive on the sensing handler to prevent proxy idle timeouts.
|
||||
- **Ghost skeletons in live UI with multi-node ESP32 setups** (#420, ADR-082) —
|
||||
`tracker_bridge::tracker_to_person_detections` documented itself as filtering
|
||||
to `is_alive()` tracks but in fact passed every non-Terminated track to the
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
**Turn ordinary WiFi into a spacial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
|
||||
### π RuView is a WiFi sensing platform that turns radio signals into spatial intelligence.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
@@ -155,16 +156,17 @@ class UserManager:
|
||||
return False
|
||||
|
||||
|
||||
class AuthenticationMiddleware:
|
||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""Authentication middleware for FastAPI."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.token_manager = TokenManager(settings)
|
||||
self.user_manager = UserManager()
|
||||
self.enabled = settings.enable_authentication
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable) -> Response:
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through authentication middleware."""
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from src.config.settings import Settings
|
||||
@@ -299,15 +300,16 @@ class RateLimiter:
|
||||
}
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiting middleware for FastAPI."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.rate_limiter = RateLimiter(settings)
|
||||
self.enabled = settings.enable_rate_limiting
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable) -> Response:
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through rate limiting middleware."""
|
||||
if not self.enabled:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -9,7 +9,18 @@ services:
|
||||
ports:
|
||||
- "3000:3000" # REST API
|
||||
- "3001:3001" # WebSocket
|
||||
- "5005:5005/udp" # ESP32 UDP
|
||||
# ESP32 UDP. On Linux/macOS this works with multiple ESP32 nodes out of
|
||||
# the box. On Docker Desktop for Windows, multi-source UDP is collapsed
|
||||
# to one source IP at the WSL/Hyper-V boundary, so all-but-one node's
|
||||
# frames are silently dropped (issue #374, #386).
|
||||
#
|
||||
# Windows workaround: change this to "5006:5005/udp" and run the host
|
||||
# relay so every datagram arrives from the same loopback source:
|
||||
#
|
||||
# python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
#
|
||||
# See docs/TROUBLESHOOTING.md §9 for details.
|
||||
- "5005:5005/udp"
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
|
||||
@@ -109,3 +109,75 @@ ssh thyhack@100.90.238.87
|
||||
**Symptom:** Plugging into the right USB-C port (when facing the board with USB-C toward you) shows no serial device on the host.
|
||||
|
||||
**Fix:** Use the left USB-C port. On most ESP32-S3-DevKitC boards, the left port is the USB-to-UART bridge (CP2102/CH340) used for flashing and serial monitor. The right port is the native USB (USB-JTAG) which requires different drivers and isn't used by the RuView firmware.
|
||||
|
||||
---
|
||||
|
||||
## 9. Docker Desktop on Windows drops UDP from multiple ESP32 nodes
|
||||
|
||||
**Symptom:** Two or more ESP32 nodes are flashed, provisioned, and visibly transmit on the network — `tcpdump`/Wireshark on the Windows host shows datagrams from every node — but inside the Docker container only one source IP arrives. `/api/v1/sensing/latest` shows a single node and the live UI freezes or only tracks one body. Reported in #374 (4-node bench) and reproduced in #386 (6-node demo, RuView v0.7.0).
|
||||
|
||||
**Root cause:** Docker Desktop on Windows runs the engine inside a WSL2 / Hyper-V VM. Inbound UDP from the host LAN is forwarded through `vpnkit` / `vEthernet` and the multi-source-IP datagrams are demultiplexed onto a single virtual socket. The first source-IP "wins"; subsequent unique sources are silently dropped at the VM boundary. This is a Docker Desktop limitation, not a sensing-server bug — `host.docker.internal` and `--network host` do not help (host networking is not implemented for the Linux engine on Windows).
|
||||
|
||||
**Fix:** Run the bundled UDP relay on the host so every forwarded datagram arrives from the same loopback source IP, which Docker passes through unchanged.
|
||||
|
||||
```powershell
|
||||
# 1. Start the relay (PowerShell or any terminal)
|
||||
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
|
||||
# 2. Edit docker/docker-compose.yml — change the ESP32 UDP mapping from
|
||||
# - "5005:5005/udp"
|
||||
# to
|
||||
# - "5006:5005/udp"
|
||||
|
||||
# 3. Bring the stack up
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
ESP32 nodes still target the host on `--target-ip <host>:5005` — no firmware re-provisioning is needed. The relay is `scripts/udp-relay.py` (stdlib only, no extra deps). Verify with `--verbose` that each node's source IP appears at least once before forwarding stabilises on a single ephemeral relay port.
|
||||
|
||||
**Prevention:** Linux and macOS hosts are unaffected; the relay only needs to run on Docker Desktop for Windows. If Docker Desktop ships per-source UDP forwarding (tracked at [docker/for-win#1144](https://github.com/docker/for-win/issues/1144) and related), this workaround can be retired.
|
||||
|
||||
**Prior art:** PR #413 (`txhno`) proposed a docs-only writeup of the same workaround; this entry supersedes it.
|
||||
|
||||
---
|
||||
|
||||
## 10. `404` on the visualization page when running sensing-server
|
||||
|
||||
**Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188.
|
||||
|
||||
**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404.
|
||||
|
||||
**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`).
|
||||
|
||||
```bash
|
||||
# Option A — absolute path (recommended for production)
|
||||
sensing-server --source esp32 --udp-port 5005 --http-port 3000 \
|
||||
--ws-port 3001 --ui-path /absolute/path/to/ui
|
||||
|
||||
# Option B — run from the crate dir (works for local dev / cargo run)
|
||||
cd v2/crates/wifi-densepose-sensing-server
|
||||
cargo run -- --source esp32
|
||||
|
||||
# Option C — Docker (no path config needed)
|
||||
docker compose -f docker/docker-compose.yml up sensing-server
|
||||
```
|
||||
|
||||
**Prevention:** Track future work in #188 to fall back to a path resolved relative to the executable when the cwd-relative path doesn't exist, so the binary works regardless of where it's launched.
|
||||
|
||||
---
|
||||
|
||||
## 11. Boot loop on `--edge-tier 1` or `--edge-tier 2`
|
||||
|
||||
**Symptom:** ESP32-S3 boots normally with `--edge-tier 0`, but flashing the same firmware with `--edge-tier 1` or `2` produces a boot loop. Serial output reaches `cpu_start` and `heap_init`, then resets repeatedly. Reported in #438 against firmware `v0.4.3.1-esp32-3-g66e2fa083-dir`.
|
||||
|
||||
**Root cause:** Edge tiers 1 and 2 enable the on-device DSP pipeline on Core 1. In the affected build, the `edge_dsp` task ran a tight per-frame loop without yielding, so the FreeRTOS task watchdog tripped on Core 1 and panicked. Tier 0 is passthrough only and doesn't activate the pipeline, so the watchdog never fires there.
|
||||
|
||||
**Fix:** Flash the [v0.4.3.1-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) release or later — the DSP task yield fixes have shipped on `main` since the build in the report.
|
||||
|
||||
```bash
|
||||
# Verify what version you're on (look for "App version" in serial output on boot)
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
# Expect: "App version: v0.4.3.1-esp32" or higher
|
||||
```
|
||||
|
||||
If the boot loop persists on a release build, capture a full serial trace including the watchdog backtrace and reopen #438 with the new build hash.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# ADR-098: Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Rejected (with crate-level carve-outs for future evaluation) |
|
||||
| **Date** | 2026-05-13 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **midstream-in-RuView** |
|
||||
| **Relates to** | ADR-095 (rvCSI platform), ADR-096 (rvCSI crate topology), ADR-097 (adopt rvCSI as RuView's CSI runtime), ADR-012 (ESP32 CSI mesh), ADR-029 (RuvSense multistatic / TDM), ADR-031 (RuView sensing-first RF mode), ADR-043 (sensing-server UI API completion) |
|
||||
| **midstream repo** | [github.com/ruvnet/midstream](https://github.com/ruvnet/midstream) — vendored at `vendor/midstream`, currently pinned at [`30fe5eb`](https://github.com/ruvnet/midstream/commit/30fe5eb7a1f1494aa1ad00d54160088a565ec766) |
|
||||
| **Outcome** | Do **not** adopt as a system component. Two of midstream's six workspace crates (`temporal-compare`, `nanosecond-scheduler`) are plausible future-use building blocks; the rest do not fit. `vendor/midstream` is retained as a reference-only submodule. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`vendor/midstream` is a git submodule of RuView (`.gitmodules:1-4`) but, like `vendor/rvcsi` was before ADR-097, it is **vendored but not consumed**: no `v2/crates/*/Cargo.toml` depends on a `midstreamer-*` crate, no Rust source contains `use midstreamer_…`, and the ESP32 firmware and TypeScript dashboard have no midstream imports.
|
||||
|
||||
This ADR settles the standing question of *whether RuView should consume midstream at all*, and if so, where. The user-facing prompt enumerated four candidate seams to evaluate:
|
||||
|
||||
1. Streaming / pub-sub for the WebSocket fan-out (today: `tokio::sync::broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`).
|
||||
2. Stream processing for the CSI → DSP → event pipeline (today: synchronous `EventPipeline` at `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs`, freshly adopted via ADR-097).
|
||||
3. Multi-source merging / TDM coordination for the ESP32 mesh (ADR-029, ADR-073).
|
||||
4. Backpressure / flow control between the UDP receiver and downstream consumers (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638` `udp_receiver_task`; firmware-side `stream_sender` ENOMEM backoff at `firmware/esp32-csi-node/main/csi_collector.c:223-228`).
|
||||
|
||||
To evaluate each, we read midstream's workspace `Cargo.toml` (`vendor/midstream/Cargo.toml:1-99`), the `README.md` and `BENCHMARKS_SUMMARY.md`, and every crate's `lib.rs`:
|
||||
|
||||
| Crate | File | LOC | Purpose (from header doc) |
|
||||
|---|---|---:|---|
|
||||
| `midstreamer-temporal-compare` | `vendor/midstream/crates/temporal-compare/src/lib.rs:1-697` | 697 | DTW, LCS, Levenshtein, generic pattern matching on `Sequence<T>` of `TemporalElement<T>` |
|
||||
| `midstreamer-scheduler` | `vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:1-406` | 406 | Priority + deadline-aware task scheduler (RM, EDF, LLF) for low-latency real-time tasks |
|
||||
| `midstreamer-attractor` | `vendor/midstream/crates/temporal-attractor-studio/src/lib.rs:1-482` | 482 | Phase-space reconstruction, Lyapunov exponents, attractor classification |
|
||||
| `midstreamer-neural-solver` | `vendor/midstream/crates/temporal-neural-solver/src/lib.rs:1-509` | 509 | LTL / CTL / MTL temporal-logic verification with neural reasoning |
|
||||
| `midstreamer-strange-loop` | `vendor/midstream/crates/strange-loop/src/lib.rs:1-496` | 496 | Multi-level meta-learning, self-referential systems |
|
||||
| `midstreamer-quic` | `vendor/midstream/crates/quic-multistream/src/lib.rs:1-255`, `native.rs:1-303`, `wasm.rs:1-307` | 865 | Thin wrapper over `quinn` (native) and `WebTransport` (WASM); generic QUIC streams |
|
||||
|
||||
Plus a TypeScript layer (`vendor/midstream/npm/`, `vendor/midstream/npm-wasm/`) whose product is "real-time LLM streaming" — OpenAI Realtime API client, RTMP / WebRTC / HLS for video, an in-console dashboard, a Whisper transcription scaffold, an MCP server for LLM agents.
|
||||
|
||||
The top-level identity is unambiguous: `Cargo.toml:16` describes the package as **`"Real-time LLM streaming with inflight analysis"`**, and the README (`vendor/midstream/README.md:45-80`) frames midstream as a platform that "analyzes [LLM] responses **as they stream in real-time** — enabling instant insights, pattern detection, and intelligent decision-making" — i.e. the streaming domain is **LLM tokens and dashboard telemetry**, not RF signals. A search for any of `csi`, `wifi`, `sensing`, or `sensor` across `vendor/midstream/crates/*/src/*.rs` returns zero hits.
|
||||
|
||||
This shapes the conclusion: midstream's *abstractions* (DTW pattern matching, attractor analysis, LTL verification, meta-learning) were chosen for a fundamentally different problem domain than CSI, and its *transport* (QUIC) is a thin `quinn` wrapper rather than a sensing-aware backplane. The candidate seams enumerated above are either already filled by simpler primitives in RuView, or filled better by rvCSI under ADR-097.
|
||||
|
||||
### 1.1 What this ADR is *not*
|
||||
|
||||
- Not a judgment on midstream's quality. It has 139 passing tests and clean Rust; it is well-engineered for its target domain.
|
||||
- Not a decision to drop `vendor/midstream`. The submodule pin is cheap to keep, and the carve-outs in §3 may justify revisiting it.
|
||||
- Not a position on the *standalone* midstream product (LLM streaming, OpenAI Realtime, dashboards). That product is unaffected by this ADR.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
**Reject midstream as a system component of RuView.** The four candidate seams are either filled (well) by existing RuView primitives, or are filled by rvCSI's freshly-adopted `EventPipeline` and `RfMemoryStore`. The eight decisions below are the architectural contract.
|
||||
|
||||
### D1 — Streaming / pub-sub for the WebSocket fan-out: no change
|
||||
|
||||
RuView's sensing-server currently fans out updates to WebSocket clients via `tokio::sync::broadcast::channel::<String>(256)` (`v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`). midstream offers no equivalent in-process broadcast primitive — its TypeScript dashboard fan-out is HTTP-server based (`vendor/midstream/npm/src/dashboard.ts`), and its Rust `midstreamer-quic` crate is a generic point-to-point QUIC wrapper (`vendor/midstream/crates/quic-multistream/src/native.rs:31-69`), not a pub-sub bus.
|
||||
|
||||
Tokio's `broadcast` channel is the standard Rust idiom for this pattern, costs effectively nothing per subscriber, integrates with the rest of the Axum + Tokio stack already in use (`v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47`), and is what `rvcsi-runtime` itself uses for event distribution (`vendor/rvcsi/crates/rvcsi-runtime/src/lib.rs`). **Keep `tokio::sync::broadcast`.**
|
||||
*Consequences:* zero migration; zero new dependency surface; the WebSocket handlers at `main.rs:1989,2030` continue to work unchanged.
|
||||
|
||||
### D2 — CSI → DSP → event pipeline: stay on rvCSI's `EventPipeline`
|
||||
|
||||
ADR-097 D2 just adopted `rvcsi-runtime::CaptureRuntime` + `rvcsi_events::EventPipeline` as the CSI ingestion / DSP / event-extraction path. `EventPipeline` is **deterministic, synchronous, single-frame-at-a-time** (`vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`: *"Feed it frames with `EventPipeline::process_frame` and drain the tail with `EventPipeline::flush`"*) — and that determinism is load-bearing for ADR-095 D9 (replayability) and ADR-095 D13 (quality scoring against learned baselines).
|
||||
|
||||
midstream's stream-processing primitives are designed for the opposite shape: `temporal-attractor-studio` (phase-space reconstruction, Lyapunov exponents) and `temporal-neural-solver` (LTL formula verification) operate on **trajectories** of multi-dimensional states over hundreds-to-thousands of samples (`vendor/midstream/README.md:528-531`: *"Attractor detection: <5ms for 1000-point series"*) — that is closer to RuView's existing RuvSense modules (`v2/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs`, `intention.rs`) than to anything the runtime DSP layer needs.
|
||||
|
||||
Replacing rvCSI's event detectors with midstream constructs would (a) break determinism, (b) re-introduce a parallel CSI-processing implementation — exactly the duplication ADR-097 was opened to remove — and (c) force RuView to invent a `Sequence<T: temporal-compare::TemporalElement>` shim around `CsiFrame` for marginal benefit. **Stay on `rvcsi-events::EventPipeline`.**
|
||||
*Consequences:* the determinism / replay guarantees of ADR-095 D9 and ADR-097 D6 remain intact; the work to land `rvcsi-adapter-esp32` (ADR-097 D4, P3) is not duplicated.
|
||||
|
||||
### D3 — TDM / multi-source merging: stay on the existing aggregator
|
||||
|
||||
The ESP32 mesh's multi-source merging is in `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220` — a `UdpSocket`-backed aggregator (`mod.rs:74,85`) that receives parsed `CsiFrame`s from N nodes and forwards them on a `SyncSender<CsiFrame>` to the consumer. The TDM coordination (slot assignment, channel hopping, dwell time) lives in firmware (`firmware/esp32-csi-node/main/`) and is governed by ADR-029 and ADR-073. midstream offers nothing for either side: it has no UDP merger, no slot scheduler, and no firmware-side primitives.
|
||||
|
||||
`midstreamer-scheduler` is conceptually adjacent — it does priority + deadline-aware scheduling (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`: `RateMonotonic`, `EarliestDeadlineFirst`, `LeastLaxityFirst`, `FixedPriority`) — but its target is **in-process tokio tasks on a 4-thread executor** (`vendor/midstream/README.md:466-477`: *"4 worker threads"*, *"<50 ns scheduling latency"*), not the cross-device, wall-clock-anchored TDM that RuvSense needs. **Keep the existing `wifi-densepose-hardware` aggregator and firmware-side TDM.**
|
||||
*Consequences:* ADR-029 stays as-is; the work to migrate the parser to `rvcsi-adapter-esp32` (ADR-097 D4) is unaffected.
|
||||
|
||||
### D4 — UDP receiver backpressure / flow control: existing solutions are correct at each end
|
||||
|
||||
There are two distinct backpressure problems in RuView, and neither benefits from midstream:
|
||||
|
||||
- **Firmware side (`firmware/esp32-csi-node/main/csi_collector.c:64,223-228`):** lwIP pbuf exhaustion produces `ENOMEM` when the ESP32 tries to UDP-send faster than the network drains. The fix in code is a rate-limit on `stream_sender_send` *inside the CSI callback*. This is a C-level firmware concern with no Rust analogue — midstream cannot run on the ESP32.
|
||||
- **Host side (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638-3640`, `4769`):** `udp_receiver_task` reads from `UdpSocket` and pushes onto `broadcast::channel::<String>(256)`. The bounded channel is itself the backpressure mechanism: lagged subscribers see `RecvError::Lagged`, the buffer wraps, no producer ever blocks. The 256-slot capacity is sized to one second of frame envelopes at the target rate; the per-second packet-yield collapse symptom (`adaptive_controller_decide.c:26-28`) is detected and surfaced by ADR-039 / ADR-081's `pkt_yield_per_sec` accessor, not by transport-layer flow control.
|
||||
|
||||
midstream's `quic-multistream` provides per-stream prioritization (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`), which is a useful flow-control primitive *for QUIC* but not for the UDP-CSI / WS-fan-out topology RuView actually uses. Adopting QUIC end-to-end would mean (a) replacing the ESP32's UDP sender — which would need a QUIC stack on a memory-constrained Xtensa MCU and is out of scope for this project — or (b) terminating QUIC at the aggregator only, which provides no benefit the current bounded `broadcast` channel doesn't. **Keep the existing two-tier backpressure.**
|
||||
*Consequences:* the ENOMEM rate-limit at `csi_collector.c:223-228` and the bounded `broadcast::channel::<String>(256)` at `main.rs:4769` continue to be the load-bearing primitives.
|
||||
|
||||
### D5 — Carve-out: `temporal-compare` as a future RuvSense-side building block
|
||||
|
||||
`midstreamer-temporal-compare` (`vendor/midstream/crates/temporal-compare/src/lib.rs:1-697`) is a clean DTW / LCS / Levenshtein implementation with an LRU cache. RuView's gesture detector at `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs` already does DTW template matching, and the longitudinal analysis at `ruvsense/longitudinal.rs` could plausibly benefit from cached pattern matching. If we ever need a *separate* DTW implementation that is decoupled from RuvSense's internal types, `temporal-compare` is a reasonable starting point — but only if and when that need arises.
|
||||
|
||||
We **do not adopt it today** because RuvSense's gesture matcher already exists, works, and uses RuView-native types, and pulling in `dashmap`, `lru`, and a generic `TemporalElement<T>` abstraction would be net-negative right now. **Tracked as a future evaluation, not a decision.**
|
||||
*Consequences:* zero today; one named option for a future ADR if a "second" DTW pattern appears.
|
||||
|
||||
### D6 — Carve-out: `nanosecond-scheduler` for *host-side* edge tier scheduling (future)
|
||||
|
||||
If ADR-039's edge-intelligence tier scheduling ever moves from the ESP32 onto a host-side coordinator (e.g. a Raspberry Pi running the cluster aggregator), `nanosecond-scheduler`'s deadline-aware policies (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`) could plausibly host that scheduler. Today the scheduling is firmware-side and the C-level RTOS handles it; there is nothing to schedule in Rust at the granularity midstream offers.
|
||||
|
||||
Again: **not a current decision, just an option kept open.**
|
||||
*Consequences:* zero today.
|
||||
|
||||
### D7 — Submodule disposition: keep `vendor/midstream`
|
||||
|
||||
`vendor/midstream` is one git submodule pin; the build does not depend on it; it does not slow down `cargo build --workspace`; and the carve-outs in D5/D6 leave the door open. Removing the submodule would also remove the reference material that justified the carve-outs.
|
||||
|
||||
**Keep the submodule, no per-release pin advancement.** Unlike `vendor/rvcsi` (whose pin is bumped per RuView release under ADR-097 D7), `vendor/midstream` has no in-build consumer to validate against. If D5 or D6 ever activates, *that* ADR will start the per-release pin process. Until then the pin can drift freely.
|
||||
*Consequences:* one line of `.gitmodules` (`.gitmodules:1-4`) stays; `git submodule update --init` remains a no-op for normal RuView development.
|
||||
|
||||
### D8 — Documentation: cross-reference, don't import
|
||||
|
||||
The ADR index (`docs/adr/README.md`) gets ADR-098 added under "Architecture and infrastructure". No other docs are updated. The README on the RuView side is untouched; midstream is not part of the RuView platform story.
|
||||
*Consequences:* one row added to the ADR index; no churn elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why not adopt (the rejection record)
|
||||
|
||||
For institutional memory, the table below records what each midstream crate *would* solve and the alternative RuView already uses. This is the answer to "but we vendored midstream — what is it for?"
|
||||
|
||||
| midstream crate | Plausible RuView seam | Already filled by | Verdict |
|
||||
|---|---|---|---|
|
||||
| `midstreamer-temporal-compare` (DTW, LCS, Levenshtein) | Gesture template matching (`ruvsense/gesture.rs`); longitudinal biomechanics drift | RuvSense's existing DTW gesture matcher | Carve-out only (D5) — not adopted today |
|
||||
| `midstreamer-scheduler` (nanosecond priority + deadline) | ESP32 edge-tier scheduling (ADR-039); RuvSense TDM (ADR-029) | Firmware-side RTOS (ESP32); ADR-029's wall-clock-anchored TDM | Carve-out only (D6) — wrong scope today |
|
||||
| `midstreamer-attractor` (Lyapunov, phase-space) | RF-field stability detection in `ruvsense/field_model.rs`, `longitudinal.rs` | Welford stats + biomechanics drift (longitudinal.rs); SVD eigenstructure (field_model.rs) | Not adopted — RuvSense's approach is calibrated to RF signal scale and the project's existing dataset, not generic dynamical-systems theory |
|
||||
| `midstreamer-neural-solver` (LTL / CTL / MTL verification) | Adversarial signal detection (`ruvsense/adversarial.rs`); coherence-gate decisions | Multi-link consistency checks (adversarial.rs); `coherence_gate.rs` state machine | Not adopted — RuView's adversarial detector is not a formal-verification problem; it's a multi-link physical-consistency check |
|
||||
| `midstreamer-strange-loop` (meta-learning, self-modification) | None in RuView's scope | RuView is not a self-modifying learner; AETHER (ADR-024) is contrastive embedding, not meta-learning | Not adopted — out of scope |
|
||||
| `midstreamer-quic` (QUIC native + WASM) | Sensing-server → external client transport (alternative to WS) | `tokio::sync::broadcast` + Axum WebSocket + UDP (`main.rs:36-47, 4769, 1989, 2030, 3638`) | Not adopted — see D1, D4 |
|
||||
|
||||
The shape of the rejection is consistent: **midstream's abstractions are LLM-token / dashboard-telemetry shaped, RuView's pipeline is RF-frame / event-detector shaped.** Where the two share vocabulary ("streaming", "temporal", "real-time"), the implementations diverge sharply — and the case-by-case analysis above shows that the closer one looks at each seam, the worse the fit gets.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Zero net change to RuView's build, runtime, or surface area; ADR-097's phased rvCSI adoption proceeds unaffected.
|
||||
- The decision space around midstream is now bounded and documented; future contributors and AI agents see "ADR-098 already evaluated this; here is why not" before re-opening the question.
|
||||
- The two crate-level carve-outs (D5, D6) are explicit, so if the relevant seams appear later, the evaluation can pick up from this ADR rather than start over.
|
||||
- `vendor/midstream` (the submodule) remains as reference material, but is correctly marked as not part of the build path.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One more vendored repo with no in-build consumer — a small but non-zero cognitive load (mitigated by D7's explicit "do not bump the pin").
|
||||
- If midstream's published crates evolve materially (e.g. a CSI-aware feature lands), the reasoning in §3 needs revisiting; this is the standard "rejected ADRs go stale" risk and applies to every Rejected ADR in the index.
|
||||
|
||||
**Risks**
|
||||
|
||||
- The most plausible failure mode of this ADR is *not* "we should have adopted midstream"; it is "we re-open the question in six months without re-reading this ADR." Mitigated by indexing ADR-098 in `docs/adr/README.md` and by the per-crate table in §3 being precise enough to short-circuit the next evaluator.
|
||||
|
||||
---
|
||||
|
||||
## 5. Alternatives considered
|
||||
|
||||
| Alternative | Why not |
|
||||
|---|---|
|
||||
| **Adopt midstream wholesale as RuView's streaming backbone** | Would force the CSI pipeline into the `Sequence<TemporalElement>` shape (`vendor/midstream/crates/temporal-compare/src/lib.rs:42-70`) and the `quic-multistream` transport (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`) — both are designed for LLM tokens / arbitrary streams, not validated RF frames with quality scoring. Conflicts directly with ADR-095 D5 (one `CsiFrame` schema), D6 (validate before crossing boundaries), and D9 (deterministic replay). |
|
||||
| **Replace `tokio::sync::broadcast` with midstream's QUIC fan-out** | Solves no observed problem. `broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769` handles N WebSocket subscribers at zero per-subscriber cost; the lagged-subscriber semantics (`RecvError::Lagged`) are exactly what an event-feed wants. QUIC adds TLS + congestion control + per-stream priority — useful for *external* clients across a network, but the sensing-server's clients connect over WS on the same host or LAN. |
|
||||
| **Replace `EventPipeline` with `temporal-attractor-studio` / `temporal-neural-solver`** | `EventPipeline` is deterministic by contract (`vendor/rvcsi/crates/rvcsi-events/src/lib.rs:20`) and ADR-097 just made it RuView's event source of truth. Attractor analysis and LTL verification operate on entirely different abstractions; using them as event detectors would re-invent rvCSI's pipeline in a less-determined way. |
|
||||
| **Adopt `midstreamer-temporal-compare` for gesture detection now** | RuvSense already has a working DTW gesture matcher tuned to CSI signal scale. Swapping it for a generic `TemporalElement<T>` matcher buys cleanliness but costs a re-tune and a new dep tree (`dashmap`, `lru`). Tracked as D5 for if/when a *second* DTW use case shows up. |
|
||||
| **Adopt `midstreamer-scheduler` for the cluster-Pi aggregator** | The cluster aggregator does not currently exist as a real-time scheduler; ADR-039's tier scheduling is firmware-side. Until the host-side schedule appears, importing a deadline-aware scheduler is solution-looking-for-a-problem. Tracked as D6. |
|
||||
| **Drop the `vendor/midstream` submodule entirely** | Cheap to keep, useful as the reference material this ADR cites. D7 keeps it on the explicit understanding that the pin is not advanced. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions / re-evaluation triggers
|
||||
|
||||
This ADR is `Rejected` today on the strength of the §1.1 / §3 analysis. The following events would justify re-opening it:
|
||||
|
||||
1. **A second DTW / LCS / Levenshtein use case appears in RuView** (e.g. a CLI-side replay diff, a regression test fixture that needs sequence alignment, a TUI for pattern playback). Then re-evaluate `midstreamer-temporal-compare` per D5.
|
||||
2. **A host-side real-time scheduler enters RuView's scope** (e.g. the cluster-Pi aggregator becomes responsible for slot timing instead of the ESP32 firmware). Then re-evaluate `midstreamer-scheduler` per D6.
|
||||
3. **midstream ships a CSI-aware adapter or RF-scale `Sequence<T>` extension** — i.e. midstream's own scope grows to include sensing primitives. As of the pinned commit (`30fe5eb`), this has not happened (zero matches for `csi|wifi|sensing|sensor` in `vendor/midstream/crates/*/src/*.rs`).
|
||||
4. **RuView gains a QUIC-to-external-client requirement** that the WS fan-out cannot service (e.g. a mobile client over a lossy link that benefits from QUIC's stream priority + 0-RTT). Then re-evaluate `midstreamer-quic` per D1 / D4.
|
||||
|
||||
If none of these triggers fire, this ADR stays Rejected and the carve-outs (D5, D6) remain optional.
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- [ADR-095 — rvCSI Edge RF Sensing Platform](ADR-095-rvcsi-edge-rf-sensing-platform.md) — sets the single-`CsiFrame` schema, deterministic replay, and quality-scoring constraints that midstream's abstractions conflict with.
|
||||
- [ADR-096 — rvCSI Crate Topology, the napi-c Shim, the napi-rs Surface](ADR-096-rvcsi-ffi-crate-layout.md) — the crate topology that rvCSI fills the candidate seams with.
|
||||
- [ADR-097 — Adopt rvCSI as RuView's primary CSI runtime](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) — phased adoption (P1-P5) that this ADR explicitly does not duplicate.
|
||||
- [ADR-012 — ESP32 CSI Sensor Mesh](ADR-012-esp32-csi-sensor-mesh.md) — the multi-source TDM context for D3.
|
||||
- [ADR-029 — RuvSense Multistatic Sensing Mode](ADR-029-ruvsense-multistatic-sensing-mode.md) — the wall-clock-anchored TDM that `midstreamer-scheduler` is the wrong shape for.
|
||||
- [ADR-039 — ESP32 Edge Intelligence Pipeline](ADR-039-esp32-edge-intelligence.md) — the firmware-side tier scheduling that would need to move host-side before D6 activates.
|
||||
- [`github.com/ruvnet/midstream`](https://github.com/ruvnet/midstream) — 5 published crates on crates.io (`temporal-compare`, `nanosecond-scheduler`, `temporal-attractor-studio`, `temporal-neural-solver`, `strange-loop`) + 1 local crate (`quic-multistream`); 139 passing tests.
|
||||
- `vendor/midstream` (submodule) — pinned at `30fe5eb` (`vendor/midstream/Cargo.toml:16` describes the package as *"Real-time LLM streaming with inflight analysis"*).
|
||||
- RuView code paths cited in §1: `v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47,1989,2030,3638-3640,4769`; `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220`; `firmware/esp32-csi-node/main/csi_collector.c:64,223-228`; `firmware/esp32-csi-node/main/adaptive_controller_decide.c:26-28`.
|
||||
- RuvSense code paths cited in §3: `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs`, `longitudinal.rs`, `field_model.rs`, `adversarial.rs`, `coherence_gate.rs`.
|
||||
- rvCSI code paths cited in §2: `vendor/rvcsi/crates/rvcsi-events/src/lib.rs:1-37`, `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`.
|
||||
@@ -108,6 +108,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) | rvCSI — Edge RF Sensing Runtime Platform | Proposed |
|
||||
| [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) | rvCSI — Crate Topology, the napi-c Shim, and the napi-rs Node Surface | Proposed |
|
||||
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
|
||||
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
|
||||
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,466 @@
|
||||
# Pi 5 + Hailo Cluster: Building a Cognitive RF Observer with rvcsi
|
||||
|
||||
A field-tested tutorial for turning a 4-node Raspberry Pi 5 cluster into a
|
||||
multistatic Wi-Fi CSI cognitive RF observer that learns room states,
|
||||
predicts the next one, and flags anomalies — entirely from radio.
|
||||
|
||||
**Estimated time:** 4–6 hours (hardware 1h, firmware 1h, software 1h, calibration 1–3h)
|
||||
|
||||
**What you will build:** A self-learning 4-node cluster that captures Wi-Fi
|
||||
Channel State Information from a stable RF beacon, encodes each frame into a
|
||||
128-dimensional fingerprint on an on-device Hailo-8 NPU, clusters those
|
||||
fingerprints into discrete room states with stable IDs across runs, models
|
||||
state transitions with a 2nd-order Markov chain (with measurable predictive
|
||||
skill above chance), and persists everything to a queryable brain corpus on
|
||||
a workstation. The whole thing runs over Tailscale and is operated through
|
||||
a single CLI with **34 subcommands**.
|
||||
|
||||
**Who this is for:** RF engineers, smart-home hackers, security researchers,
|
||||
and ML/embedded folks comfortable with Linux + systemd. No specific signal-
|
||||
processing background required — but you do need patience for hardware
|
||||
quirks (nexmon_csi cross-compile is a known dead end; see step 3).
|
||||
|
||||
> **The TL;DR**: 4× Pi 5 + 2× Hailo-8 → CSI → 128-d embeddings → cosine
|
||||
> k-means with warm-start → 2nd-order Markov → SQLite brain → 34-subcommand
|
||||
> operator CLI. Production-grade signal: 39% top-1 ceiling on next-state
|
||||
> prediction (16× chance baseline), continuous fleet/drift/anomaly
|
||||
> monitoring, and a 12-category time-series corpus.
|
||||
|
||||
> **About the name "rvcsi" in this tutorial.** When this tutorial was
|
||||
> first written, the cluster's per-Pi capture services were named with
|
||||
> an `rvcsi` prefix (`cog-rvcsi-stream`, `cog-rvcsi-correlator`) as
|
||||
> branding only — the actual code was Python and didn't depend on the
|
||||
> upstream [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) Rust
|
||||
> runtime. **As of 2026-05-13**, the v0-appliance project has accepted
|
||||
> [ADR-207](https://github.com/ruvnet/v0-appliance/blob/main/docs/adr/ADR-207-rvcsi-library-integration.md)
|
||||
> (rvCSI library integration — Option D) and shipped a Rust binary
|
||||
> `cog-rvcsi-pi` built on rvcsi-runtime 0.3 that replaces the three
|
||||
> Python services. The cutover is per-Pi, operator-driven, with
|
||||
> one-command rollback (`scripts/rvcsi-pi/install-rvcsi-pi.sh` and
|
||||
> `uninstall-rvcsi-pi.sh`). A given cluster may be running either
|
||||
> stack while migration is in progress; the schema and operator
|
||||
> surface are unchanged across the cutover. See ADR-207's
|
||||
> Implementation log for the current state.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Architecture overview](#2-architecture-overview)
|
||||
3. [Per-node firmware: nexmon_csi on Pi 5](#3-per-node-firmware-nexmon_csi-on-pi-5)
|
||||
4. [Per-node services](#4-per-node-services)
|
||||
5. [Workstation pipeline](#5-workstation-pipeline)
|
||||
6. [Calibration: getting from raw CSI to room states](#6-calibration-getting-from-raw-csi-to-room-states)
|
||||
7. [Operating the cluster: the cog-query CLI](#7-operating-the-cluster-the-cog-query-cli)
|
||||
8. [What you can measure](#8-what-you-can-measure)
|
||||
9. [Troubleshooting](#9-troubleshooting)
|
||||
10. [Next steps](#10-next-steps)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Hardware
|
||||
|
||||
| Item | Quantity | Approx. cost | Notes |
|
||||
|------|----------|--------------|-------|
|
||||
| Raspberry Pi 5 (8GB) | 4 | ~$80 each | 4GB works but tight under sustained load |
|
||||
| Hailo-8 M.2 HAT (AI Kit) | 2 | ~$110 each | Only 2 needed — encoder is split across cluster-1 + cluster-2 |
|
||||
| MicroSD (64GB, A2) | 4 | ~$10 each | A2 class strongly recommended for sustained writes |
|
||||
| USB-C PD power supply (27W) | 4 | ~$12 each | Pi 5 draws 5A at full Hailo load |
|
||||
| Active cooler | 4 | ~$5 each | Cluster-2 sustains thermal load — passive will throttle |
|
||||
| Workstation (≥16GB RAM, Linux) | 1 | — | Hosts the brain HTTP service + clusterer + anomaly daemon |
|
||||
| Stable Wi-Fi beacon | 1 | — | Any AP on the same 5 GHz channel. We use ch.149/80MHz. Stability matters more than identity. |
|
||||
|
||||
**Total parts cost:** ~$580 plus workstation.
|
||||
|
||||
> **Important:** All 4 Pi 5s must use the on-board `bcm43455c0` radio. USB
|
||||
> Wi-Fi adapters with otherwise-similar chipsets **will not** work — nexmon's
|
||||
> firmware patches are silicon-specific. See ADR-206 § "USB Wi-Fi dongle
|
||||
> rabbit-hole" for the painful version of that lesson.
|
||||
|
||||
### Software prerequisites
|
||||
|
||||
| Component | Version | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Pi OS Bookworm (Lite) | 64-bit, kernel 6.6+ | Use the Lite image — Desktop slows boot and burns SD writes |
|
||||
| Tailscale | ≥1.60 | Mesh networking across the cluster |
|
||||
| Rust toolchain | 1.78+ on workstation, 1.78+ on each Pi | For ruvector + adapter binaries |
|
||||
| Python 3.11+ | system Python on workstation | numpy required |
|
||||
| systemd-user | already present | Workstation timers run as user units |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture overview
|
||||
|
||||
```
|
||||
┌─ workstation (Linux, ≥16GB) ──────────────────┐
|
||||
│ │
|
||||
│ brain HTTP (SQLite, port 9876) │
|
||||
│ ↑↑ │
|
||||
│ ┌──┴┴──────────────────────────────────┐ │
|
||||
│ │ rfmem-tail ← ingests live brain │ │
|
||||
│ │ rfmem-recall → posts category= │ │
|
||||
│ │ rfmem-recall when │ │
|
||||
│ │ current state ≈ past │ │
|
||||
│ │ rfmem-anomaly → 13-axis detector, │ │
|
||||
│ │ posts rfmem-anomaly & │ │
|
||||
│ │ rfmem-state-transition │ │
|
||||
│ │ cog-rfmem-states (timer, hourly) │ │
|
||||
│ │ re-clusters w/ warm-start│ │
|
||||
│ │ cog-rfmem-insights (timer, nightly) │ │
|
||||
│ │ writes rfmem-insights │ │
|
||||
│ │ cog-rfmem-drift-check (timer, 05:00) │ │
|
||||
│ │ audits cluster file state│ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ cog-query (CLI, 34 subcommands, 4 JSON modes)│
|
||||
└────────────────────────────────────────────────┘
|
||||
↑
|
||||
Tailscale mesh ──────────┴───────────────────────────────┐
|
||||
↓ ↓ ↓
|
||||
┌─ cluster-1 (Hailo) ┐ ┌─ cluster-2 (Hailo + fusion) ┐ ┌─ cluster-3 ┐ ┌─ v0 ┐
|
||||
│ cog-csi-emitter │ │ cog-csi-emitter │ │ same as │ │ same│
|
||||
│ cog-csi-adapter │ │ cog-csi-adapter │ │ cluster-1 │ │ as │
|
||||
│ cog-rvcsi-stream │ │ cog-rvcsi-stream │ │ minus │ │ c-3 │
|
||||
│ cog-hailo-encoder │ │ cog-hailo-encoder │ │ Hailo & │ │ │
|
||||
│ │ │ cog-rvcsi-correlator (fusion)│ │ correlator │ │ │
|
||||
└────────────────────┘ └─────────────────────────────┘ └────────────┘ └─────┘
|
||||
4 svc 5 svc 3 svc 3 svc
|
||||
└─────────────────────── 15 expected services total ──────────────────────┘
|
||||
```
|
||||
|
||||
**Why this split?** Multistatic fusion (combining CSI from 4 spatial vantage
|
||||
points into a single weighted observation) is computationally cheap but
|
||||
benefits from being on **one** node so the other three only do capture +
|
||||
encode. Hailo-8 is the bottleneck cost, so we put two on the cluster
|
||||
(one for redundancy, one for the fusion node) and let `cluster-3` + `v0`
|
||||
run as pure capture sensors.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-node firmware: nexmon_csi on Pi 5
|
||||
|
||||
**Critical lesson learned (saved you a week):** the workstation x86_64
|
||||
cross-compile path for nexmon_csi on Pi 5 **does not work**. The 39-hunk
|
||||
patch series applies cleanly on a native Pi 5 ARM build, and fails in
|
||||
subtle ways elsewhere.
|
||||
|
||||
The recipe that works:
|
||||
|
||||
```bash
|
||||
# On each Pi 5 (not the workstation):
|
||||
sudo apt update && sudo apt install -y \
|
||||
raspberrypi-kernel-headers bc bison flex libssl-dev make \
|
||||
gcc gawk qpdf cmake build-essential libpcap-dev clang gcc-arm-none-eabi
|
||||
|
||||
git clone https://github.com/seemoo-lab/nexmon.git ~/nexmon
|
||||
cd ~/nexmon
|
||||
source setup_env.sh
|
||||
make
|
||||
|
||||
cd patches
|
||||
git clone https://github.com/seemoo-lab/nexmon_csi.git
|
||||
cd nexmon_csi
|
||||
|
||||
# Apply the Pi-5-friendly patch series — all 39 hunks should apply clean
|
||||
# on native ARM. If you see "Hunk #N FAILED", you are almost certainly
|
||||
# cross-compiling from x86_64. Stop. Build on the Pi.
|
||||
./install.sh
|
||||
|
||||
# Switch on:
|
||||
sudo mcp # 'monitor capability provisioning' — enable
|
||||
sudo nexutil -Iwlan0 -s500 -b -l34 -v<86-char base64 capture filter>
|
||||
```
|
||||
|
||||
> **Pi 5 kernel gotcha:** Pi OS Bookworm ships two kernels — `kernel8.img`
|
||||
> (4K pages) and `kernel_2712.img` (16K pages, Pi 5 only). nexmon_csi
|
||||
> currently builds clean against `kernel8.img`. Add `kernel=kernel8.img`
|
||||
> to `/boot/firmware/config.txt` if you've switched. **After the switch,
|
||||
> SSH by hostname via Tailscale** — host keys + DHCP gotchas otherwise.
|
||||
|
||||
> **Clock-skew first-boot trap:** Pi 5 has no RTC. First-boot apt will
|
||||
> reject "future-dated" `Release` files. Patch your firstboot to wait for
|
||||
> `systemd-timesyncd` before running `apt-get`.
|
||||
|
||||
The complete commands + full troubleshooting matrix is in the
|
||||
[detailed gist](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017) — section "Firmware: nexmon_csi on Pi 5".
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-node services
|
||||
|
||||
Each cluster Pi runs a small fixed set of systemd services. Per-host
|
||||
topology:
|
||||
|
||||
| Service | cluster-1 | cluster-2 | cluster-3 | v0 |
|
||||
|---|:--:|:--:|:--:|:--:|
|
||||
| `cog-csi-emitter` (raw CSI capture from nexmon) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-csi-adapter` (Rust binary; CSI → 256-byte float frames) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-rvcsi-stream` (publishes frames to rvcsi-correlator) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-hailo-encoder` (frames → 128-d fingerprints on Hailo-8) | ✓ | ✓ | — | — |
|
||||
| `cog-rvcsi-correlator` (multistatic fusion across 4 nodes) | — | ✓ | — | — |
|
||||
| **Expected service count** | **4** | **5** | **3** | **3** |
|
||||
|
||||
The topology is encoded in the workstation's `cog-query fleet-status`
|
||||
subcommand, which compares per-host expected services against live
|
||||
`systemctl is-active` results. A flat-service check would falsely flag
|
||||
cluster-3 and v0 as degraded (they have neither Hailo nor the correlator
|
||||
— that's by design).
|
||||
|
||||
> **rvcsi cutover (ADR-207 Option D, 2026-05-13).** The three services
|
||||
> `cog-csi-emitter`, `cog-csi-adapter`, and `cog-rvcsi-stream` are
|
||||
> being consolidated into one Rust binary `cog-rvcsi-pi` built on
|
||||
> [rvcsi-runtime](https://crates.io/crates/rvcsi-runtime). The new
|
||||
> binary holds the same per-Pi role and the same expected-service
|
||||
> count from the operator's view (`fleet-status` already understands
|
||||
> both layouts). Deploy with
|
||||
> `bash scripts/rvcsi-pi/install-rvcsi-pi.sh <pi-host>`; revert with
|
||||
> `scripts/rvcsi-pi/uninstall-rvcsi-pi.sh`. The cutover is per-Pi,
|
||||
> not flag-day — mixed Python/Rust clusters are supported. The Hailo
|
||||
> encoder + correlator stay Python in this phase; their Rust ports
|
||||
> are tracked as follow-on ADRs.
|
||||
|
||||
All unit files + the install script are in the
|
||||
[detailed gist](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017) — section "Per-node systemd units".
|
||||
|
||||
---
|
||||
|
||||
## 5. Workstation pipeline
|
||||
|
||||
The workstation runs ten user-mode units (3 daemons, 7 timers):
|
||||
|
||||
| Unit | Type | Cadence | Purpose |
|
||||
|---|---|---|---|
|
||||
| `cog-rfmem-tail` | daemon | continuous | Ingests live brain entries into the workstation mirror |
|
||||
| `cog-rfmem-recall` | daemon | continuous | kNN-matches current fingerprint vs persisted ones, posts `rfmem-recall` |
|
||||
| `cog-rfmem-anomaly` | daemon | continuous | 13-axis anomaly detector, posts `rfmem-anomaly` + `rfmem-state-transition` |
|
||||
| `cog-rfmem-indexer` | timer | every 5 min | Updates HNSW index for kNN |
|
||||
| `cog-rfmem-compress` | timer | hourly | Compresses old brain entries |
|
||||
| `cog-rfmem-daily` | timer | nightly 04:00 | Per-day stats roll-up (`rfmem-daily`) |
|
||||
| `cog-rfmem-states` | timer | hourly | Re-runs cosine k-means w/ warm-start (`rfmem-state-summary`) |
|
||||
| `cog-rfmem-insights` | timer | nightly 04:55 | NL synthesis, posts `rfmem-insights` |
|
||||
| `cog-rfmem-drift-check` | timer | nightly 05:00 | Audits cluster file/unit drift, posts `rfmem-drift` |
|
||||
| `cog-rfmem-mirror` | timer | hourly | Mirrors cluster-2 brain → workstation read-replica |
|
||||
|
||||
Install in one shot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-fork>/v0-appliance.git
|
||||
cd v0-appliance
|
||||
bash scripts/rfmem/install-workstation.sh
|
||||
```
|
||||
|
||||
The installer is **idempotent** — rerunning is safe and only enables
|
||||
units that aren't yet enabled. It also wires a git post-commit hook
|
||||
that auto-deploys + auto-smoke-tests on every commit touching
|
||||
`scripts/rfmem/`. That closes the "I edited the repo but forgot to
|
||||
deploy" gap that bit us repeatedly in early development.
|
||||
|
||||
---
|
||||
|
||||
## 6. Calibration: getting from raw CSI to room states
|
||||
|
||||
This is the longest step but largely passive — let it run.
|
||||
|
||||
### 6.1 Walk the room
|
||||
|
||||
For 30–60 minutes after the cluster is live, walk through every room you
|
||||
want recognized. Sit, stand, move between rooms, repeat. The encoder is
|
||||
learning to map "what the room looks like in CSI" into 128-d vectors;
|
||||
diversity here matters more than total time.
|
||||
|
||||
### 6.2 First clustering pass
|
||||
|
||||
```bash
|
||||
# Force-trigger the clusterer (it normally fires hourly):
|
||||
systemctl --user start cog-rfmem-states.service
|
||||
python3 scripts/rfmem/cog-query.py states
|
||||
```
|
||||
|
||||
Output looks like:
|
||||
|
||||
```
|
||||
=== rfmem-states — k=16, n=12,847 ===
|
||||
state #0 π=0.184 dwell=42.3s centroid_drift=0.012 (default)
|
||||
state #1 π=0.121 dwell=18.1s centroid_drift=0.003
|
||||
state #4 π=0.087 dwell=29.6s centroid_drift=0.041
|
||||
...
|
||||
```
|
||||
|
||||
**Stable IDs across runs.** The warm-start k-means recipe matches new
|
||||
centroids to the prior run's centroids by cosine similarity before
|
||||
assigning IDs. This means state #4 stays state #4 between hourly runs —
|
||||
otherwise downstream Markov transitions would scramble after every
|
||||
re-cluster.
|
||||
|
||||
### 6.3 Let the Markov chain build
|
||||
|
||||
After a few thousand transitions (a few hours of activity), check:
|
||||
|
||||
```bash
|
||||
python3 scripts/rfmem/cog-query.py prediction-accuracy
|
||||
```
|
||||
|
||||
You should see something like:
|
||||
|
||||
```
|
||||
=== prediction-accuracy — training-set top-1 ceilings ===
|
||||
1st-order: 37.1% (16x chance baseline of 6.25%)
|
||||
2nd-order: 39.4% (16x chance baseline of 6.25%, 1.06x gain over 1st)
|
||||
```
|
||||
|
||||
The 2nd-order chain beats 1st-order because it conditions on the
|
||||
**previous** state as well as the current one. Self-loops are excluded
|
||||
from the argmax (a transition is by definition a state change).
|
||||
|
||||
### 6.4 Verify the room learned itself
|
||||
|
||||
```bash
|
||||
python3 scripts/rfmem/cog-query.py insights
|
||||
```
|
||||
|
||||
Reads like:
|
||||
|
||||
```
|
||||
The cluster has observed 446,231 fingerprints, clustering them into
|
||||
16 discrete RF states. The room exhibits moderately diverse (stationary
|
||||
entropy 0.82/1.0). State #4 is the dominant 'default' state (π=0.214);
|
||||
state #13 is the rarest baseline (π=0.018).
|
||||
Prediction skill (last hour, 2nd-order): top-1 12.4% (1.98x chance),
|
||||
top-3 31.0% (1.65x chance, 412 transitions) (training-set ceiling
|
||||
39.4% — operating @ 31% of capacity).
|
||||
```
|
||||
|
||||
That "operating @ 31% of capacity" line is the operational efficiency:
|
||||
how close live performance is to the model's theoretical ceiling. Big
|
||||
gap = the room is being noisy in ways the static cluster model doesn't
|
||||
capture. Small gap = you're near SOTA for this static model.
|
||||
|
||||
---
|
||||
|
||||
## 7. Operating the cluster: the cog-query CLI
|
||||
|
||||
A single CLI binary with **34 subcommands** + 4 machine-readable JSON
|
||||
modes. Practical ones (full list in the gist):
|
||||
|
||||
| Subcommand | What it does |
|
||||
|---|---|
|
||||
| `summary --hours 1` | Bird's-eye view of last hour: anomalies, transitions, recall hits |
|
||||
| `top-events --hours 24 --limit 5` | Highest-info events in window (combines novelty + tier + recency) |
|
||||
| `top-events --json` | Same, agent-consumable |
|
||||
| `insights` | Natural-language synthesis (paragraph) — what the cluster thinks |
|
||||
| `insights --json` | Same, structured |
|
||||
| `insights --post` | Same, persisted to brain as `rfmem-insights` |
|
||||
| `stats` | Corpus: per-category counts, dimensions, vector counts |
|
||||
| `motion` | Recent motion events |
|
||||
| `anomalies --sort info` | Anomalies sorted by composite info score (1.0–8.0) |
|
||||
| `circadian` | 24-hour bin of activity — does the room have a daily rhythm? |
|
||||
| `by-state` | Per-state metrics (dwell, σ-baseline, novelty distribution) |
|
||||
| `markov` | Top transitions by frequency, both 1st + 2nd-order |
|
||||
| `transitions --sort novelty` | Rare/surprising transitions |
|
||||
| `dwell-times` | How long the room stays in each state |
|
||||
| `prediction-accuracy` | 1st + 2nd-order top-1 ceilings |
|
||||
| `baseline-drift` | Has the noise floor shifted? (slow change) |
|
||||
| `centroid-drift` | Has any state's RF signature materially changed? |
|
||||
| `fleet-status` | Per-host expected-service liveness check |
|
||||
| `fleet-status --json` | Same, agent-consumable |
|
||||
| `fleet-status --post` | Same, persisted to brain as `rfmem-fleet` (heartbeat) |
|
||||
| `check-drift` | Workstation/cluster file + unit drift audit |
|
||||
| `replica-status` | Hourly cluster-2 → workstation mirror health |
|
||||
|
||||
### The fleet-health triad
|
||||
|
||||
Three subcommands cover the operator's full health picture:
|
||||
|
||||
- `check-drift` — file content drift (what's deployed vs what's in git)
|
||||
- `replica-status` — workstation mirror lag (last successful sync)
|
||||
- `fleet-status` — service liveness across the 4 Pis (topology-aware)
|
||||
|
||||
If all three are green, the cluster is healthy. If any one fires, you
|
||||
have a concrete starting point.
|
||||
|
||||
---
|
||||
|
||||
## 8. What you can measure
|
||||
|
||||
After a week of runtime, you can answer questions like:
|
||||
|
||||
- **"What's the room's most common 'baseline' state?"** → `states` shows
|
||||
the π-dominant cluster ID.
|
||||
- **"Did anything weird happen last night?"** → `anomalies --sort info
|
||||
--hours 12` sorts by combined-information score (novelty × tier × state-
|
||||
rarity × calmness).
|
||||
- **"How predictable is the room?"** → `insights` reports stationary
|
||||
entropy (0.0 = single state, 1.0 = uniform). Most rooms land 0.6–0.9.
|
||||
- **"What's the most novel transition ever observed?"** → `transitions
|
||||
--sort novelty --limit 1`. We've seen transitions with
|
||||
`transition_p=0.0000` — never observed before in 446k+ embeddings.
|
||||
- **"Is the room changing slowly?"** → `centroid-drift` flags states
|
||||
whose 128-d signature has moved > 0.05 cosine distance since the prior
|
||||
clusterer run. Common cause: a piece of furniture moved.
|
||||
- **"What's the daily rhythm?"** → `circadian` bins activity by hour.
|
||||
Most rooms show clear morning/evening peaks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `nexmon_csi` build fails with FAILED hunks | Cross-compiling from x86_64 | Build on the Pi natively |
|
||||
| Pi 5 stops booting after kernel switch | Wrong `kernel=` in `/boot/firmware/config.txt` | Use `kernel=kernel8.img` |
|
||||
| First boot fails on `apt update` | No RTC → clock skew, apt rejects "future-dated" Release files | Wait for `systemd-timesyncd` in firstboot |
|
||||
| `cog-rfmem-now` times out | Workstation daemon swap-thrashing | Bump `MemoryMax=` in unit file (we run 1G) |
|
||||
| `fleet-status` shows DEGRADED on cluster-3 / v0 | Topology unaware (old version) | Update to latest — per-host expected-services |
|
||||
| Cluster-2 Hailo encoder silent | `cp -r` made encoder a directory, not a file | `install -m 0755` instead |
|
||||
| 2nd-order Markov top-1 = 0% | Self-loop dominates argmax | Zero out self-loop before `.argmax()` |
|
||||
| State IDs change between runs | No warm-start k-means | Update clusterer to match new centroids to prior run by cosine |
|
||||
| HardFaults during embedded N6 bring-up | (Different topic, see [ADR-027](../adr/) for STM32N6 startup notes) | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. Next steps
|
||||
|
||||
Once your cluster is producing stable predictions and clean fleet health,
|
||||
the natural directions are:
|
||||
|
||||
1. **Cross-room correlation** — train a second cluster in another room
|
||||
and feed both into the workstation. The brain already supports
|
||||
multiple namespaces.
|
||||
2. **Active sensing** — instead of passively observing whatever beacon is
|
||||
present, drive your own (e.g., dedicated 5 GHz beacon AP at fixed
|
||||
power). Eliminates upstream variability.
|
||||
3. **Vital signs** — the RuView project has companion code for extracting
|
||||
heart-rate and breathing from CSI; the 128-d encoder output is a
|
||||
reasonable input feature.
|
||||
4. **Federated training** — multiple physical sites publishing to a shared
|
||||
brain. Each site keeps its own clusters; transitions are the shared
|
||||
vocabulary.
|
||||
5. **Push to upstream RuView** — if your cluster develops capabilities not
|
||||
in this tutorial (you'll know by the time you've written the README),
|
||||
send a PR.
|
||||
|
||||
---
|
||||
|
||||
## Reference material
|
||||
|
||||
- **[Detailed cookbook gist (all commands, configs, unit files)](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017)**
|
||||
- **[ADR-206: nexmon_csi on Pi 5 cluster](https://github.com/ruvnet/v0-appliance/blob/main/docs/adr/ADR-206-nexmon-csi-on-pi-5-cluster.md)** — the engineering decision record
|
||||
with full rationale, including the painful-but-instructive failures
|
||||
- **[v0-appliance repo](https://github.com/ruvnet/v0-appliance)** — the
|
||||
source of truth for `scripts/rfmem/` operator tooling
|
||||
- **[seemoo-lab/nexmon_csi](https://github.com/seemoo-lab/nexmon_csi)** —
|
||||
upstream CSI capture firmware
|
||||
- **[Hailo-8 documentation](https://hailo.ai/products/hailo-8/)** — NPU
|
||||
reference
|
||||
|
||||
---
|
||||
|
||||
*This tutorial was built against the v0.5.0-cognitive-rf-observer milestone
|
||||
of `v0-appliance`. The cluster has been running continuously for 6+ weeks
|
||||
of development with 446k+ fingerprints observed, 16 stable RF states, and
|
||||
a 2nd-order Markov model operating at 31% of its 39.4% theoretical
|
||||
top-1 ceiling. SOTA is a moving target — but this is a real, working
|
||||
cognitive RF observer that you can reproduce.*
|
||||
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
@@ -28,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)
|
||||
@@ -331,6 +333,46 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
|
||||
|
||||
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
|
||||
|
||||
### Connect Mesh Data to the Dashboard and Observatory
|
||||
|
||||
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
|
||||
|
||||
```bash
|
||||
# From a source build
|
||||
cd v2
|
||||
cargo run -p wifi-densepose-sensing-server -- \
|
||||
--source esp32 \
|
||||
--udp-port 5005 \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--ui-path ../../ui
|
||||
|
||||
# Docker
|
||||
docker run --rm \
|
||||
-e CSI_SOURCE=esp32 \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3001 \
|
||||
-p 5005:5005/udp \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
Open the UI from the sensing server, not from a local file:
|
||||
|
||||
| View | URL |
|
||||
|------|-----|
|
||||
| Dashboard | `http://localhost:3000/ui/index.html` |
|
||||
| Observatory | `http://localhost:3000/ui/observatory.html` |
|
||||
|
||||
Use these checks before debugging the browser:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
curl http://localhost:3000/api/v1/nodes
|
||||
curl http://localhost:3000/api/v1/sensing/latest
|
||||
```
|
||||
|
||||
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
|
||||
|
||||
### Cognitum Seed Integration (ADR-069)
|
||||
|
||||
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
|
||||
@@ -752,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).
|
||||
@@ -1744,6 +1847,8 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still
|
||||
|
||||
- Verify the sensing server is running: `curl http://localhost:3000/health`
|
||||
- Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL)
|
||||
- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005` instead; the Observatory reads the server WebSocket, not the standalone aggregator output
|
||||
- Verify the ESP32 nodes are provisioned to the IP address of the machine running `sensing-server`
|
||||
- Hard refresh with Ctrl+Shift+R to clear cached settings
|
||||
- The auto-detect probes `/health` on the same origin — cross-origin won't work
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Mixamo FBX downloads — too large + license boundary. Get your own from
|
||||
# mixamo.com (FBX Binary + T-Pose / Without Skin), drop into assets/.
|
||||
*.fbx
|
||||
|
||||
# Diagnostic / debug screenshots from a dev session. Official screenshots
|
||||
# live in screenshots/ and are committed; these underscore-prefixed ones
|
||||
# are scratch.
|
||||
_diag-*.png
|
||||
_demo-mode-shot*.png
|
||||
_PROOF-*.png
|
||||
@@ -0,0 +1,77 @@
|
||||
# three.js demos
|
||||
|
||||
Five progressively richer browser demos of the ADR-097 sensing-helpers scene,
|
||||
ending with a live MediaPipe-Pose → Mixamo X Bot retargeting pipeline driven
|
||||
by a real ESP32 CSI feed.
|
||||
|
||||
## Run them
|
||||
|
||||
```bash
|
||||
python examples/three.js/server/serve-demo.py
|
||||
# then open one of the URLs the script prints
|
||||
```
|
||||
|
||||
`server/serve-demo.py` is a tiny `ThreadingHTTPServer` with aggressive
|
||||
no-cache headers — the stdlib `http.server` is single-threaded and times out
|
||||
on the parallel script + FBX fetches the demos make.
|
||||
|
||||
## Demos
|
||||
|
||||
| # | File | What it shows |
|
||||
|---|------|---------------|
|
||||
| 01 | [`demos/01-helpers.html`](demos/01-helpers.html) | Plain ADR-097 helpers in the point-cloud viewer |
|
||||
| 02 | [`demos/02-cinematic.html`](demos/02-cinematic.html) | Cinematic camera + pseudo-CSI visualization on top of #01 |
|
||||
| 03 | [`demos/03-skinned.html`](demos/03-skinned.html) | GLTF skinned mesh + additive animation blending |
|
||||
| 04 | [`demos/04-skinned-fbx.html`](demos/04-skinned-fbx.html) | Mixamo X Bot loaded from FBX in the ADR-097 scene |
|
||||
| 05 | [`demos/05-skinned-realtime.html`](demos/05-skinned-realtime.html) | Webcam → MediaPipe Pose Heavy → Mixamo IK retarget, live ESP32 CSI overlay |
|
||||
|
||||
| Screenshot | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
examples/three.js/
|
||||
├── README.md
|
||||
├── .gitignore
|
||||
├── demos/ # 5 self-contained HTML demos
|
||||
│ ├── 01-helpers.html
|
||||
│ ├── 02-cinematic.html
|
||||
│ ├── 03-skinned.html
|
||||
│ ├── 04-skinned-fbx.html
|
||||
│ └── 05-skinned-realtime.html
|
||||
├── screenshots/ # one PNG per demo
|
||||
│ └── 0N-*.png
|
||||
├── server/
|
||||
│ ├── serve-demo.py # local HTTP server with no-cache headers
|
||||
│ └── ruvultra-csi-bridge.py # ESP32 CSI WebSocket bridge (ruvultra:8766)
|
||||
└── assets/
|
||||
└── X Bot.fbx # gitignored — get your own from mixamo.com
|
||||
# (FBX Binary, T-Pose, Without Skin)
|
||||
# used by demos 04 and 05
|
||||
```
|
||||
|
||||
## Mixamo X Bot
|
||||
|
||||
Demos 04 and 05 expect `assets/X Bot.fbx`. It's gitignored (size + license
|
||||
boundary). Download yours from [mixamo.com](https://mixamo.com): pick the
|
||||
"X Bot" character, export as **FBX Binary**, **T-Pose**, **Without Skin**,
|
||||
and drop it into `assets/`.
|
||||
|
||||
## Live ESP32 CSI overlay (demo 05 only)
|
||||
|
||||
`server/ruvultra-csi-bridge.py` is the systemd-deployable bridge that runs on
|
||||
the `ruvultra` host (over Tailscale). It listens for ESP32-S3 CSI on UDP and
|
||||
re-broadcasts it as WebSocket frames at `ws://ruvultra:8766/csi`. Demo 05
|
||||
auto-connects; if the socket is down, it falls back to the bundled idle clip
|
||||
plus a synthetic CSI driver.
|
||||
|
||||
## Open issues
|
||||
|
||||
- [#583](https://github.com/ruvnet/RuView/issues/583) — head/face tracking
|
||||
fidelity in `05-skinned-realtime.html`. Recommended fix: swap MediaPipe
|
||||
Pose Heavy for MediaPipe Holistic (same API, adds 468-point face mesh +
|
||||
hand landmarks for proper PnP head pose and finger curl tracking).
|
||||
@@ -0,0 +1,587 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · ADR-097 · three.js helpers in the point cloud viewer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-panel: rgba(0, 0, 0, 0.88);
|
||||
--amber: #e8a634;
|
||||
--amber-dim: #4a3a1a;
|
||||
--amber-hot: #ffc04d;
|
||||
--grid-major: #444444;
|
||||
--grid-minor: #222222;
|
||||
--green: #4f4;
|
||||
--blue: #4cf;
|
||||
--text-mute: #888;
|
||||
--border: #2a2a2a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
/* Top-left HUD */
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
max-width: 340px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
|
||||
}
|
||||
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
|
||||
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); }
|
||||
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
#info .row .v.live { color: var(--green); }
|
||||
|
||||
/* Bottom-left helper toggle panel */
|
||||
#controls {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
min-width: 220px;
|
||||
}
|
||||
#controls h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
}
|
||||
#controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#controls label:hover { color: var(--amber-hot); }
|
||||
#controls input[type=checkbox] {
|
||||
accent-color: var(--amber);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#controls .helper-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Bottom-right ADR badge */
|
||||
#adr-badge {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
#adr-badge a { color: var(--amber); text-decoration: none; }
|
||||
#adr-badge a:hover { color: var(--amber-hot); }
|
||||
|
||||
/* Top-right legend */
|
||||
#legend {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
min-width: 200px;
|
||||
}
|
||||
#legend h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
}
|
||||
#legend .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
#legend .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#legend .label { font-size: 11px; line-height: 1.3; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<h1>RuView · Helpers Demo</h1>
|
||||
<div class="sub">ADR-097 · three.js helpers for the point cloud viewer</div>
|
||||
<div class="row"><span class="k">Scene</span><span class="v live">● SYNTHETIC</span></div>
|
||||
<div class="row"><span class="k">Skeleton</span><span class="v">17 kpts · COCO</span></div>
|
||||
<div class="row"><span class="k">Point cloud</span><span class="v" id="pc-count">— pts</span></div>
|
||||
<div class="row"><span class="k">Sensor nodes</span><span class="v">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Frame rate</span><span class="v" id="fps">— fps</span></div>
|
||||
<div class="row"><span class="k">Bbox volume</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<h2>Helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="helper-swatch" style="background:#444"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="helper-swatch" style="background:#4a3a1a"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper<span class="helper-swatch" style="background:#e8a634"></span></label>
|
||||
<label><input type="checkbox" id="t-axes" checked>AxesHelper<span class="helper-swatch" style="background:linear-gradient(90deg,#f44,#4f4,#4cf)"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="helper-swatch" style="background:#4cf"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h2>Scene</h2>
|
||||
<div class="item"><span class="dot" style="background:#ffff00"></span><span class="label">COCO-17 keypoints (yellow)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#ffffff"></span><span class="label">Bones (white lines)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#4cf"></span><span class="label">Face point cloud (cyan→white)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#e8a634"></span><span class="label">ESP32 sensor nodes</span></div>
|
||||
</div>
|
||||
|
||||
<div id="adr-badge">
|
||||
ADR-097 · <a href="https://threejs.org/examples/#webgl_helpers" target="_blank" rel="noopener">three.js helpers</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · ADR-097 · three.js helpers demo
|
||||
// --------------------------------------------------------------------
|
||||
// Self-contained, no backend. Demonstrates how `GridHelper`,
|
||||
// `PolarGridHelper`, `BoxHelper`, and `AxesHelper` slot into the
|
||||
// RuView point cloud viewer (`v2/crates/wifi-densepose-pointcloud
|
||||
// /src/viewer.html`). Open this file in a browser — no build step.
|
||||
//
|
||||
// The scene contains:
|
||||
// 1. A synthetic walking, breathing 17-keypoint skeleton.
|
||||
// 2. A face-shaped point cloud attached to the skeleton head.
|
||||
// 3. Four multistatic sensor-node markers arranged around the room.
|
||||
// 4. All four ADR-097 helpers, toggleable from the bottom-left panel.
|
||||
//
|
||||
// Coordinate frame matches the production viewer:
|
||||
// +X = right, +Y = up, +Z = away from camera.
|
||||
// Floor at y = -1.5, person hip at y = 0, head reaches ~ y = 0.7.
|
||||
// =====================================================================
|
||||
|
||||
const COCO_BONES = [
|
||||
// head
|
||||
[0, 1], [0, 2], [1, 3], [2, 4],
|
||||
// torso
|
||||
[5, 6], [5, 11], [6, 12], [11, 12],
|
||||
// left arm
|
||||
[5, 7], [7, 9],
|
||||
// right arm
|
||||
[6, 8], [8, 10],
|
||||
// left leg
|
||||
[11, 13], [13, 15],
|
||||
// right leg
|
||||
[12, 14], [14, 16],
|
||||
];
|
||||
|
||||
// Static "T-pose" skeleton in local frame, animated each frame.
|
||||
// 17 keypoints in COCO order. Units: meters.
|
||||
const SKELETON_BASE = {
|
||||
0: [ 0.00, 0.65, 0.00], // nose
|
||||
1: [-0.04, 0.68, 0.04], // L eye
|
||||
2: [ 0.04, 0.68, 0.04], // R eye
|
||||
3: [-0.08, 0.64, 0.00], // L ear
|
||||
4: [ 0.08, 0.64, 0.00], // R ear
|
||||
5: [-0.18, 0.45, 0.00], // L shoulder
|
||||
6: [ 0.18, 0.45, 0.00], // R shoulder
|
||||
7: [-0.22, 0.20, 0.00], // L elbow
|
||||
8: [ 0.22, 0.20, 0.00], // R elbow
|
||||
9: [-0.26, -0.05, 0.00], // L wrist
|
||||
10: [ 0.26, -0.05, 0.00], // R wrist
|
||||
11: [-0.10, 0.00, 0.00], // L hip
|
||||
12: [ 0.10, 0.00, 0.00], // R hip
|
||||
13: [-0.12, -0.40, 0.00], // L knee
|
||||
14: [ 0.12, -0.40, 0.00], // R knee
|
||||
15: [-0.12, -0.80, 0.00], // L ankle
|
||||
16: [ 0.12, -0.80, 0.00], // R ankle
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene + camera + renderer
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
scene.fog = new THREE.Fog(0x0a0a0a, 6, 14);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.0, 1.4, 4.2);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.minDistance = 1.5;
|
||||
controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers — wired to checkbox toggles
|
||||
// ---------------------------------------------------------------------
|
||||
// GridHelper — Cartesian floor reference. Establishes "down" and
|
||||
// scale: 4 m × 4 m floor, 20 divisions = 0.2 m grid spacing.
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x444444, 0x222222);
|
||||
gridHelper.position.y = -1.5;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// PolarGridHelper — multistatic geometry reference. 16 radial
|
||||
// divisions (angular bins) × 4 concentric circles, centered on
|
||||
// the fusion target. Matches the bin count in
|
||||
// signal/src/ruvsense/multistatic.rs:attention_weight().
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0x4a3a1a, 0x2a1f10);
|
||||
polarHelper.position.y = -1.499; // a hair above grid to avoid z-fight
|
||||
scene.add(polarHelper);
|
||||
|
||||
// AxesHelper — XYZ tripod at origin. Red = X, green = Y, blue = Z.
|
||||
const axesHelper = new THREE.AxesHelper(0.5);
|
||||
axesHelper.position.set(0, -1.49, 0);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// BoxHelper — per-person bounding volume. Refreshed each frame
|
||||
// after the skeleton is updated. Color = RuView amber.
|
||||
let bboxHelper = null;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Skeleton — joint spheres + bone lines, animated
|
||||
// ---------------------------------------------------------------------
|
||||
const skeletonGroup = new THREE.Group();
|
||||
scene.add(skeletonGroup);
|
||||
|
||||
const jointGeo = new THREE.SphereGeometry(0.025, 12, 12);
|
||||
const jointMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||
const joints = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const sphere = new THREE.Mesh(jointGeo, jointMat);
|
||||
const p = SKELETON_BASE[i];
|
||||
sphere.position.set(p[0], p[1], p[2]);
|
||||
sphere.userData.baseY = p[1];
|
||||
sphere.userData.baseX = p[0];
|
||||
sphere.userData.idx = i;
|
||||
skeletonGroup.add(sphere);
|
||||
joints.push(sphere);
|
||||
}
|
||||
|
||||
const boneMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
|
||||
const bones = [];
|
||||
for (const [a, b] of COCO_BONES) {
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
|
||||
const line = new THREE.Line(geom, boneMat);
|
||||
line.userData = { a, b };
|
||||
skeletonGroup.add(line);
|
||||
bones.push(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — synthetic ellipsoid attached to head keypoint
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 600;
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceColors = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3); // canonical face shape, relative to nose
|
||||
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
// Sample points roughly on a face-shaped ellipsoid (taller than wide).
|
||||
const u = Math.random() * Math.PI * 2;
|
||||
const v = (Math.random() - 0.5) * Math.PI;
|
||||
const cu = Math.cos(u), su = Math.sin(u);
|
||||
const cv = Math.cos(v), sv = Math.sin(v);
|
||||
// ellipsoid radii (head-like proportions)
|
||||
const rx = 0.085, ry = 0.105, rz = 0.075;
|
||||
faceOffsets[i * 3 + 0] = rx * cv * cu;
|
||||
faceOffsets[i * 3 + 1] = ry * sv;
|
||||
faceOffsets[i * 3 + 2] = rz * cv * su;
|
||||
// depth-encoded color: cyan at back, near-white at front (toward +Z = away from camera)
|
||||
const depthT = (sv + 1) * 0.5;
|
||||
faceColors[i * 3 + 0] = 0.30 + 0.70 * depthT; // R
|
||||
faceColors[i * 3 + 1] = 0.80 + 0.20 * depthT; // G
|
||||
faceColors[i * 3 + 2] = 1.00; // B
|
||||
}
|
||||
const faceGeom = new THREE.BufferGeometry();
|
||||
faceGeom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
faceGeom.setAttribute('color', new THREE.BufferAttribute(faceColors, 3));
|
||||
const faceMat = new THREE.PointsMaterial({
|
||||
size: 0.012,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const facePoints = new THREE.Points(faceGeom, faceMat);
|
||||
skeletonGroup.add(facePoints);
|
||||
document.getElementById('pc-count').textContent = FACE_POINTS + ' pts';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Multistatic sensor nodes — 4 ESP32 markers around the room
|
||||
// ---------------------------------------------------------------------
|
||||
const nodeGroup = new THREE.Group();
|
||||
scene.add(nodeGroup);
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9], // back-left high
|
||||
[ 1.9, 1.3, 1.9], // back-right high
|
||||
[-1.9, 1.3, -1.9], // front-left high
|
||||
[ 1.9, 1.3, -1.9], // front-right high
|
||||
];
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeGeo = new THREE.BoxGeometry(0.12, 0.06, 0.18);
|
||||
const nodeMat = new THREE.MeshBasicMaterial({ color: 0xe8a634 });
|
||||
const nodeAntennaGeo = new THREE.ConeGeometry(0.018, 0.08, 8);
|
||||
const nodeAntennaMat = new THREE.MeshBasicMaterial({ color: 0xffc04d });
|
||||
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
const body = new THREE.Mesh(nodeGeo, nodeMat);
|
||||
group.add(body);
|
||||
|
||||
// little antenna sticking up
|
||||
const antenna = new THREE.Mesh(nodeAntennaGeo, nodeAntennaMat);
|
||||
antenna.position.y = 0.07;
|
||||
group.add(antenna);
|
||||
|
||||
// pulsing emissive ring (visualizes RX activity)
|
||||
const ringGeo = new THREE.RingGeometry(0.10, 0.13, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0xe8a634, side: THREE.DoubleSide, transparent: true, opacity: 0.4 });
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -0.04;
|
||||
ring.userData.phase = i * 0.5;
|
||||
group.add(ring);
|
||||
group.userData.ring = ring;
|
||||
|
||||
// sight-line from node to scene origin (visualizes multistatic geometry)
|
||||
const sightGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(-pos[0], -pos[1], -pos[2]),
|
||||
]);
|
||||
const sightMat = new THREE.LineDashedMaterial({
|
||||
color: 0xe8a634, transparent: true, opacity: 0.18,
|
||||
dashSize: 0.1, gapSize: 0.06,
|
||||
});
|
||||
const sightLine = new THREE.Line(sightGeo, sightMat);
|
||||
sightLine.computeLineDistances();
|
||||
group.add(sightLine);
|
||||
|
||||
nodeGroup.add(group);
|
||||
|
||||
// ADR-097 §3.3 — per-node BoxHelper. Demonstrates that helpers
|
||||
// compose naturally: one box per detected object.
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
scene.add(bbox);
|
||||
nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Animation — synthetic motion model
|
||||
// ---------------------------------------------------------------------
|
||||
let frameStart = performance.now();
|
||||
let frameCount = 0;
|
||||
let fpsAvg = 0;
|
||||
|
||||
function applyPose(t) {
|
||||
// Body sway (slow), breathing (chest expansion), arm/leg swing (walking).
|
||||
const swayX = Math.sin(t * 0.35) * 0.05;
|
||||
const swayZ = Math.cos(t * 0.27) * 0.04;
|
||||
const breathe = Math.sin(t * 1.4) * 0.012; // chest in/out
|
||||
const walkPhase = t * 1.9; // walk cycle
|
||||
|
||||
skeletonGroup.position.set(swayX, 0, swayZ);
|
||||
skeletonGroup.rotation.y = Math.sin(t * 0.22) * 0.18;
|
||||
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const base = SKELETON_BASE[i];
|
||||
let dx = 0, dy = 0, dz = 0;
|
||||
|
||||
// breathing — shoulders + nose rise a little
|
||||
if (i === 0 || i === 1 || i === 2) dy = breathe * 0.6;
|
||||
if (i === 5 || i === 6) dy = breathe;
|
||||
|
||||
// arm swing (opposite of legs)
|
||||
if (i === 7) { dz = Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
|
||||
if (i === 9) { dz = Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
|
||||
if (i === 8) { dz = -Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
|
||||
if (i === 10){ dz = -Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
|
||||
|
||||
// leg swing
|
||||
if (i === 13){ dz = -Math.sin(walkPhase) * 0.08; }
|
||||
if (i === 15){ dz = -Math.sin(walkPhase) * 0.15; dy = Math.max(0, Math.cos(walkPhase)) * 0.04; }
|
||||
if (i === 14){ dz = Math.sin(walkPhase) * 0.08; }
|
||||
if (i === 16){ dz = Math.sin(walkPhase) * 0.15; dy = Math.max(0, -Math.cos(walkPhase)) * 0.04; }
|
||||
|
||||
joints[i].position.set(base[0] + dx, base[1] + dy, base[2] + dz);
|
||||
}
|
||||
|
||||
// update bone line vertices from current joint positions
|
||||
for (const line of bones) {
|
||||
const { a, b } = line.userData;
|
||||
const pa = joints[a].position;
|
||||
const pb = joints[b].position;
|
||||
const pos = line.geometry.attributes.position;
|
||||
pos.array[0] = pa.x; pos.array[1] = pa.y; pos.array[2] = pa.z;
|
||||
pos.array[3] = pb.x; pos.array[4] = pb.y; pos.array[5] = pb.z;
|
||||
pos.needsUpdate = true;
|
||||
}
|
||||
|
||||
// attach face point cloud to the nose keypoint (kpt 0)
|
||||
const nose = joints[0].position;
|
||||
const positions = faceGeom.attributes.position;
|
||||
const headTurn = Math.sin(t * 0.6) * 0.35; // y-axis nod
|
||||
const cosH = Math.cos(headTurn), sinH = Math.sin(headTurn);
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const ox = faceOffsets[i * 3 + 0];
|
||||
const oy = faceOffsets[i * 3 + 1];
|
||||
const oz = faceOffsets[i * 3 + 2];
|
||||
// rotate offset around Y axis by headTurn
|
||||
const rx = cosH * ox + sinH * oz;
|
||||
const rz = -sinH * ox + cosH * oz;
|
||||
positions.array[i * 3 + 0] = nose.x + rx;
|
||||
positions.array[i * 3 + 1] = nose.y + oy;
|
||||
positions.array[i * 3 + 2] = nose.z + rz;
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
}
|
||||
|
||||
function updateNodes(t) {
|
||||
nodeGroup.children.forEach((node, i) => {
|
||||
const ring = node.userData.ring;
|
||||
const phase = (t * 1.8 + ring.userData.phase) % (Math.PI * 2);
|
||||
ring.material.opacity = 0.18 + 0.42 * Math.max(0, Math.cos(phase));
|
||||
ring.scale.setScalar(1 + 0.18 * Math.max(0, Math.cos(phase)));
|
||||
});
|
||||
}
|
||||
|
||||
function updateBboxHelper() {
|
||||
const want = document.getElementById('t-bbox').checked;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
return;
|
||||
}
|
||||
skeletonGroup.updateMatrixWorld(true);
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(skeletonGroup, 0xe8a634);
|
||||
scene.add(bboxHelper);
|
||||
} else {
|
||||
bboxHelper.setFromObject(skeletonGroup);
|
||||
}
|
||||
// compute volume for the HUD
|
||||
const box = new THREE.Box3().setFromObject(skeletonGroup);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent =
|
||||
(size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const t = now * 0.001;
|
||||
const dt = now - frameStart;
|
||||
frameStart = now;
|
||||
frameCount++;
|
||||
if (frameCount % 30 === 0) {
|
||||
fpsAvg = 1000 / dt;
|
||||
document.getElementById('fps').textContent = fpsAvg.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
applyPose(t);
|
||||
updateNodes(t);
|
||||
updateBboxHelper();
|
||||
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Controls wiring — checkbox toggles attach/detach helpers from scene
|
||||
// ---------------------------------------------------------------------
|
||||
function bindToggle(id, obj) {
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener('change', () => {
|
||||
if (el.checked) {
|
||||
if (!scene.children.includes(obj)) scene.add(obj);
|
||||
} else {
|
||||
scene.remove(obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
bindToggle('t-axes', axesHelper);
|
||||
|
||||
// per-node bbox toggle (group of 4)
|
||||
document.getElementById('t-nodebox').addEventListener('change', (e) => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked) {
|
||||
if (!scene.children.includes(bb)) scene.add(bb);
|
||||
} else {
|
||||
scene.remove(bb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Resize
|
||||
// ---------------------------------------------------------------------
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,854 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation blending</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #050507;
|
||||
--bg-panel: rgba(8, 10, 14, 0.78);
|
||||
--amber: #ffb840;
|
||||
--amber-hot: #ffe09f;
|
||||
--cyan: #4cf;
|
||||
--magenta: #ff4cc8;
|
||||
--text: #d8c69a;
|
||||
--text-mute: #6b6155;
|
||||
--border: rgba(255, 184, 64, 0.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
.overlay-frame {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||
}
|
||||
.scanlines {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||
mix-blend-mode: overlay; opacity: 0.5;
|
||||
}
|
||||
.panel {
|
||||
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 0 rgba(255, 184, 64, 0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||
}
|
||||
|
||||
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||
#info .row .v.amber { color: var(--amber); }
|
||||
#info .row .v.cyan { color: var(--cyan); }
|
||||
#info .row .v.mag { color: var(--magenta); }
|
||||
|
||||
#anim {
|
||||
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#anim .group { padding: 6px 0; border-bottom: 1px solid rgba(255,184,64,0.08); }
|
||||
#anim .group:last-child { border-bottom: none; }
|
||||
#anim .group-label { font-size: 10px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
#anim button {
|
||||
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#anim .slider-row .label { width: 90px; color: var(--text-mute); }
|
||||
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||
#csi .bar-row .bar-fill {
|
||||
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||
}
|
||||
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#helpers {
|
||||
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#helpers label {
|
||||
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||
}
|
||||
#helpers label:hover { color: var(--amber-hot); }
|
||||
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||
|
||||
#loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(5, 5, 7, 0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||
letter-spacing: 2px; text-transform: uppercase;
|
||||
}
|
||||
#loading.hidden { display: none; }
|
||||
#loading .text {
|
||||
text-shadow: 0 0 12px var(--amber);
|
||||
animation: loadPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||
|
||||
@keyframes scanFlash {
|
||||
0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; }
|
||||
}
|
||||
.scan-flash {
|
||||
position: fixed; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||
}
|
||||
|
||||
#titlecard {
|
||||
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||
}
|
||||
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||
|
||||
#adr-badge {
|
||||
position: absolute; top: 50%; right: 20px; transform: translateY(-50%);
|
||||
padding: 6px 10px; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; font-size: 9px; color: var(--text-mute); z-index: 10;
|
||||
backdrop-filter: blur(8px); letter-spacing: 0.5px; max-width: 70px; text-align: center; line-height: 1.5;
|
||||
}
|
||||
#adr-badge a { color: var(--amber); text-decoration: none; display: block; }
|
||||
#adr-badge a:hover { color: var(--amber-hot); }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-frame"></div>
|
||||
<div class="scanlines"></div>
|
||||
<div class="scan-flash" id="scan-flash"></div>
|
||||
|
||||
<div id="loading"><div class="text">▸ Loading skinned subject · Xbot.glb · 2.9 MB</div></div>
|
||||
|
||||
<div class="panel" id="info">
|
||||
<h1>RuView · Skinned</h1>
|
||||
<div class="sub">ADR-097 · GLTF skinned mesh · additive animation blending</div>
|
||||
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||
<div class="row"><span class="k">Model</span><span class="v">Xbot.glb · 14k tris</span></div>
|
||||
<div class="row"><span class="k">Base anim</span><span class="v amber" id="base-name">walk</span></div>
|
||||
<div class="row"><span class="k">Additive</span><span class="v mag" id="add-name">headShake · 0.40</span></div>
|
||||
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||
</div>
|
||||
|
||||
<div id="anim">
|
||||
<h2>AnimationMixer</h2>
|
||||
<div class="group">
|
||||
<div class="group-label">Base · loops</div>
|
||||
<button data-base="idle">idle</button>
|
||||
<button data-base="walk" class="active">walk</button>
|
||||
<button data-base="run">run</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="group-label">Additive · layered</div>
|
||||
<button data-add="agree">agree</button>
|
||||
<button data-add="headShake" class="active">headShake</button>
|
||||
<button data-add="sad_pose">sad</button>
|
||||
<button data-add="sneak_pose">sneak</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="slider-row">
|
||||
<span class="label">add weight</span>
|
||||
<input type="range" id="add-weight" min="0" max="1" step="0.01" value="0.40">
|
||||
<span class="val" id="add-weight-val">0.40</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="label">time scale</span>
|
||||
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||
<span class="val" id="time-scale-val">1.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="csi">
|
||||
<h2>Per-node CSI</h2>
|
||||
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0" style="width:0"></div></div><span class="val" id="val-0">—</span></div>
|
||||
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1" style="width:0"></div></div><span class="val" id="val-1">—</span></div>
|
||||
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2" style="width:0"></div></div><span class="val" id="val-2">—</span></div>
|
||||
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3" style="width:0"></div></div><span class="val" id="val-3">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="helpers">
|
||||
<h2>ADR-097 helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="titlecard">
|
||||
RuView · Seldon Vault
|
||||
<div class="sub">skinned · ADR-097 · CCDIKSolver next</div>
|
||||
</div>
|
||||
|
||||
<div id="adr-badge">
|
||||
<a href="https://threejs.org/examples/#webgl_animation_skinning_additive_blending" target="_blank" rel="noopener">additive blend</a>
|
||||
<a href="https://threejs.org/examples/#webgl_animation_skinning_ik" target="_blank" rel="noopener" style="margin-top:4px;">skinning IK</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation
|
||||
// --------------------------------------------------------------------
|
||||
// Replaces the procedural sphere-skeleton of helpers-cinematic.html
|
||||
// with a real rigged + skinned humanoid loaded from Xbot.glb. Plays
|
||||
// a base loop (walk / run / idle) and layers an additive pose on
|
||||
// top (headShake / agree / sneak / sad) — mirrors the upstream
|
||||
// three.js webgl_animation_skinning_additive_blending example.
|
||||
//
|
||||
// All ADR-097 helpers still wrap the loaded mesh — BoxHelper picks
|
||||
// up the live AABB of the SkinnedMesh, the polar grid sits under
|
||||
// the rig, and per-node BoxHelpers wrap the four ESP32 markers.
|
||||
//
|
||||
// Production path (next): swap canned GLTF animations for live
|
||||
// COCO-17 keypoint output → CCDIKSolver targets on hands/feet/head.
|
||||
// Reference: three.js webgl_animation_skinning_ik example.
|
||||
// =====================================================================
|
||||
|
||||
const MODEL_URL = 'https://threejs.org/examples/models/gltf/Xbot.glb';
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050507);
|
||||
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.2, 1.55, 4.0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.80;
|
||||
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0.9, 0);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.06;
|
||||
controls.minDistance = 2; controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||
controls.autoRotateSpeed = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lights — the GLTF uses PBR materials so we actually need lighting
|
||||
// here (unlike the all-emissive cinematic.html). Tuned to keep the
|
||||
// amber/cyan mood: amber hemi + amber key + cyan rim lights from
|
||||
// each node direction (visualizes "the nodes illuminate the subject").
|
||||
// ---------------------------------------------------------------------
|
||||
const hemiLight = new THREE.HemisphereLight(0x553a18, 0x080606, 0.7);
|
||||
hemiLight.position.set(0, 4, 0);
|
||||
scene.add(hemiLight);
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffc070, 0.95);
|
||||
keyLight.position.set(2.5, 3.8, 2.5);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.bias = -0.0008;
|
||||
scene.add(keyLight);
|
||||
|
||||
// cyan rim lights, one per ESP32 node — keeps the "sensed by the mesh" mood
|
||||
const rimLights = [];
|
||||
NODE_POSITIONS.forEach(pos => {
|
||||
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||
scene.add(rim);
|
||||
rimLights.push(rim);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Post-processing — same composer as cinematic.html
|
||||
// ---------------------------------------------------------------------
|
||||
const composer = new THREE.EffectComposer(renderer);
|
||||
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||
const bloom = new THREE.UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
0.45, 0.40, 0.78,
|
||||
);
|
||||
composer.addPass(bloom);
|
||||
|
||||
const filmShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
time: { value: 0 }, grain: { value: 0.04 },
|
||||
vignette: { value: 0.32 }, aberration: { value: 0.0018 },
|
||||
},
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||
varying vec2 vUv;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
vec2 off = (vUv - 0.5) * aberration;
|
||||
float r = texture2D(tDiffuse, vUv + off).r;
|
||||
float g = texture2D(tDiffuse, vUv).g;
|
||||
float b = texture2D(tDiffuse, vUv - off).b;
|
||||
vec3 col = vec3(r, g, b);
|
||||
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||
col *= mix(1.0 - vignette, 1.0, v);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
};
|
||||
const filmPass = new THREE.ShaderPass(filmShader);
|
||||
composer.addPass(filmPass);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Floor — same procedural cyber grid (toned down for skinned scene)
|
||||
// ---------------------------------------------------------------------
|
||||
const floorMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||
void main() {
|
||||
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||
scan = pow(scan, 14.0);
|
||||
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = 0;
|
||||
scene.add(floor);
|
||||
|
||||
// shadow-receiving ground (invisible, just catches the shadow)
|
||||
const shadowGround = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||
);
|
||||
shadowGround.rotation.x = -Math.PI / 2;
|
||||
shadowGround.position.y = 0.001;
|
||||
shadowGround.receiveShadow = true;
|
||||
scene.add(shadowGround);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers
|
||||
// ---------------------------------------------------------------------
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||
polarHelper.position.y = 0.002;
|
||||
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||
scene.add(polarHelper);
|
||||
|
||||
let bboxHelper = null;
|
||||
let skeletonHelper = null;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Multistatic sensor nodes — same as cinematic
|
||||
// ---------------------------------------------------------------------
|
||||
const nodeGroup = new THREE.Group();
|
||||
scene.add(nodeGroup);
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeRings = [];
|
||||
const nodeAnchors = [];
|
||||
const nodeBodyGeo = new THREE.BoxGeometry(0.14, 0.06, 0.20);
|
||||
const nodeBodyMat = new THREE.MeshBasicMaterial({ color: 0xffb840 });
|
||||
const antennaGeo = new THREE.ConeGeometry(0.018, 0.10, 8);
|
||||
const antennaMat = new THREE.MeshBasicMaterial({ color: 0xffe09f });
|
||||
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
const body = new THREE.Mesh(nodeBodyGeo, nodeBodyMat);
|
||||
group.add(body);
|
||||
const antenna = new THREE.Mesh(antennaGeo, antennaMat);
|
||||
antenna.position.y = 0.08; group.add(antenna);
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide, transparent: true,
|
||||
opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||
ring.userData.phase = i * 0.7;
|
||||
group.add(ring); nodeRings.push(ring);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.025, 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
core.position.y = 0.04; group.add(core);
|
||||
|
||||
nodeGroup.add(group); nodeAnchors.push(group);
|
||||
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GLTF — load the rigged Xbot model
|
||||
// ---------------------------------------------------------------------
|
||||
let model = null;
|
||||
let mixer = null;
|
||||
let headBone = null;
|
||||
const baseActions = {}; // idle / walk / run
|
||||
const additiveActions = {}; // sneak_pose / sad_pose / agree / headShake
|
||||
let currentBase = 'walk';
|
||||
let currentAddName = 'headShake';
|
||||
let addWeight = 0.40;
|
||||
|
||||
const loader = new THREE.GLTFLoader();
|
||||
loader.load(MODEL_URL, (gltf) => {
|
||||
model = gltf.scene;
|
||||
model.position.y = 0;
|
||||
model.traverse(obj => {
|
||||
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
|
||||
if (obj.isBone && /head/i.test(obj.name) && !headBone) headBone = obj;
|
||||
});
|
||||
scene.add(model);
|
||||
|
||||
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||
skeletonHelper.visible = false;
|
||||
scene.add(skeletonHelper);
|
||||
|
||||
mixer = new THREE.AnimationMixer(model);
|
||||
const baseNames = new Set(['idle', 'walk', 'run']);
|
||||
const additiveNames = new Set(['sneak_pose', 'sad_pose', 'agree', 'headShake']);
|
||||
|
||||
for (let i = 0; i < gltf.animations.length; i++) {
|
||||
let clip = gltf.animations[i];
|
||||
const name = clip.name;
|
||||
if (baseNames.has(name)) {
|
||||
const action = mixer.clipAction(clip);
|
||||
action.enabled = true;
|
||||
action.setEffectiveTimeScale(1);
|
||||
action.setEffectiveWeight(name === currentBase ? 1 : 0);
|
||||
action.play();
|
||||
baseActions[name] = action;
|
||||
} else if (additiveNames.has(name)) {
|
||||
THREE.AnimationUtils.makeClipAdditive(clip);
|
||||
if (name.endsWith('_pose')) {
|
||||
clip = THREE.AnimationUtils.subclip(clip, name, 2, 3, 30);
|
||||
}
|
||||
const action = mixer.clipAction(clip);
|
||||
action.enabled = true;
|
||||
action.setEffectiveTimeScale(1);
|
||||
action.setEffectiveWeight(name === currentAddName ? addWeight : 0);
|
||||
action.play();
|
||||
additiveActions[name] = action;
|
||||
}
|
||||
}
|
||||
|
||||
// build the face point cloud anchored to head bone
|
||||
buildFacePointCloud();
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}, (xhr) => {
|
||||
const pct = xhr.loaded / (xhr.total || 2930032) * 100;
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = `▸ Loading skinned subject · Xbot.glb · ${pct.toFixed(0)} %`;
|
||||
}, (err) => {
|
||||
console.error('GLTF load failed', err);
|
||||
document.querySelector('#loading .text').textContent = '⚠ Load failed — see console';
|
||||
});
|
||||
|
||||
function setBase(name) {
|
||||
if (!baseActions[name]) return;
|
||||
for (const k in baseActions) {
|
||||
const a = baseActions[k];
|
||||
const target = (k === name) ? 1 : 0;
|
||||
a.crossFadeTo ? null : null; // (no-op — using simple weight crossfade)
|
||||
a.setEffectiveWeight(target);
|
||||
}
|
||||
currentBase = name;
|
||||
document.getElementById('base-name').textContent = name;
|
||||
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||
btn.classList.toggle('active', btn.dataset.base === name);
|
||||
}
|
||||
}
|
||||
function setAdditive(name) {
|
||||
for (const k in additiveActions) {
|
||||
additiveActions[k].setEffectiveWeight(k === name ? addWeight : 0);
|
||||
}
|
||||
currentAddName = name;
|
||||
document.getElementById('add-name').textContent = name + ' · ' + addWeight.toFixed(2);
|
||||
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||
btn.classList.toggle('active', btn.dataset.add === name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — anchored to head bone via getWorldPosition each frame
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 480;
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||
const facePhases = new Float32Array(FACE_POINTS);
|
||||
let facePoints = null;
|
||||
function buildFacePointCloud() {
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const u = Math.random() * Math.PI * 2;
|
||||
const v = (Math.random() - 0.5) * Math.PI * 0.95;
|
||||
const cu = Math.cos(u), su = Math.sin(u);
|
||||
const cv = Math.cos(v), sv = Math.sin(v);
|
||||
faceOffsets[i*3+0] = 0.085 * cv * cu;
|
||||
faceOffsets[i*3+1] = 0.108 * sv;
|
||||
faceOffsets[i*3+2] = 0.072 * cv * su;
|
||||
facePhases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 } },
|
||||
vertexShader: `
|
||||
attribute float aPhase; uniform float time;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||
vAlpha = 0.18 + 0.30 * shimmer;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
gl_PointSize = (1.6 + shimmer * 1.0) * (200.0 / -mv.z);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
if (d > 0.5) discard;
|
||||
float falloff = smoothstep(0.5, 0.0, d);
|
||||
vec3 col = mix(vec3(0.18, 0.52, 0.72), vec3(0.55, 0.62, 0.72), 0.5);
|
||||
gl_FragColor = vec4(col * (1.0 + falloff * 0.3), vAlpha * falloff);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
facePoints = new THREE.Points(geom, mat);
|
||||
scene.add(facePoints);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Sonar pings + tomography sweep — same as cinematic.html
|
||||
// ---------------------------------------------------------------------
|
||||
const PING_POOL = 24;
|
||||
const pings = [];
|
||||
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||
for (let i = 0; i < PING_POOL; i++) {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(pingGeo, mat);
|
||||
mesh.visible = false; scene.add(mesh);
|
||||
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||
}
|
||||
let pingIndex = 0;
|
||||
function emitPing(origin, target) {
|
||||
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||
p.active = true; p.t0 = performance.now() * 0.001;
|
||||
p.duration = 0.55 + Math.random() * 0.20;
|
||||
p.origin.copy(origin); p.target.copy(target);
|
||||
p.mesh.position.copy(origin); p.mesh.visible = true;
|
||||
p.mesh.material.opacity = 0;
|
||||
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||
}
|
||||
|
||||
const tomoMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity; varying vec2 vUv;
|
||||
void main() {
|
||||
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||
}`,
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||
});
|
||||
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||
tomoPlane.rotation.y = Math.PI / 2;
|
||||
tomoPlane.position.set(-2, 1.0, 0);
|
||||
tomoPlane.visible = false;
|
||||
scene.add(tomoPlane);
|
||||
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pseudo-CSI driver — same as cinematic
|
||||
// ---------------------------------------------------------------------
|
||||
const csiAmp = [0, 0, 0, 0];
|
||||
let csiCoherence = 0.5;
|
||||
const csiNoise = [0, 0, 0, 0];
|
||||
|
||||
function tickCsi(t, targetWorld) {
|
||||
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||
let mean = 0; const amps = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const np = NODE_POSITIONS[i];
|
||||
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||
const r2 = dx*dx + dy*dy + dz*dz;
|
||||
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||
amps.push(a);
|
||||
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||
mean += a;
|
||||
}
|
||||
mean /= 4;
|
||||
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||
v = Math.sqrt(v / 4);
|
||||
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Per-frame updates
|
||||
// ---------------------------------------------------------------------
|
||||
const tmpVec = new THREE.Vector3();
|
||||
let lastPingT = [0, 0, 0, 0];
|
||||
|
||||
function updateNodes() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ring = nodeRings[i];
|
||||
const amp = csiAmp[i];
|
||||
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||
ring.scale.setScalar(1 + 0.30 * amp);
|
||||
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||
}
|
||||
}
|
||||
function maybeEmitPings(t, modelCenter) {
|
||||
if (!document.getElementById('t-pings').checked || !model) return;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||
if (t - lastPingT[i] > interval) {
|
||||
lastPingT[i] = t;
|
||||
const target = modelCenter.clone();
|
||||
target.y += (Math.random() - 0.3) * 0.8;
|
||||
target.x += (Math.random() - 0.5) * 0.2;
|
||||
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||
emitPing(origin, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
function updatePings(t) {
|
||||
for (const p of pings) {
|
||||
if (!p.active) continue;
|
||||
const u = (t - p.t0) / p.duration;
|
||||
if (u >= 1) { p.active = false; p.mesh.visible = false; continue; }
|
||||
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||
}
|
||||
}
|
||||
function updateTomography(t) {
|
||||
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||
if (!tomoActive && t > tomoNextAt) {
|
||||
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||
const sf = document.getElementById('scan-flash');
|
||||
sf.style.animation = 'none';
|
||||
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||
}
|
||||
if (tomoActive) {
|
||||
const dur = 2.4;
|
||||
const e = (t - tomoT0) / dur;
|
||||
if (e >= 1) {
|
||||
tomoActive = false; tomoPlane.visible = false;
|
||||
tomoNextAt = t + 4 + Math.random() * 5;
|
||||
} else {
|
||||
tomoPlane.position.x = -3 + e * 6;
|
||||
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||
tomoMat.uniforms.time.value = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateBbox() {
|
||||
const want = document.getElementById('t-bbox').checked && model;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
document.getElementById('bbox-vol').textContent = '—';
|
||||
return;
|
||||
}
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||
scene.add(bboxHelper);
|
||||
} else bboxHelper.setFromObject(model);
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
function updateFaceCloud(t) {
|
||||
if (!facePoints || !headBone) return;
|
||||
const headWorld = new THREE.Vector3();
|
||||
headBone.getWorldPosition(headWorld);
|
||||
const pos = facePoints.geometry.attributes.position;
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
pos.array[i*3+0] = headWorld.x + faceOffsets[i*3+0];
|
||||
pos.array[i*3+1] = headWorld.y + faceOffsets[i*3+1] + 0.06;
|
||||
pos.array[i*3+2] = headWorld.z + faceOffsets[i*3+2];
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
facePoints.material.uniforms.time.value = t;
|
||||
}
|
||||
let hudT = 0;
|
||||
function updateHud(t, fps) {
|
||||
if (t - hudT < 0.1) return;
|
||||
hudT = t;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const pct = Math.round(csiAmp[i] * 100);
|
||||
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||
document.getElementById('val-' + i).textContent = pct + '%';
|
||||
}
|
||||
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UI wiring
|
||||
// ---------------------------------------------------------------------
|
||||
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||
btn.addEventListener('click', () => setBase(btn.dataset.base));
|
||||
}
|
||||
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||
btn.addEventListener('click', () => setAdditive(btn.dataset.add));
|
||||
}
|
||||
document.getElementById('add-weight').addEventListener('input', (e) => {
|
||||
addWeight = parseFloat(e.target.value);
|
||||
document.getElementById('add-weight-val').textContent = addWeight.toFixed(2);
|
||||
if (additiveActions[currentAddName]) additiveActions[currentAddName].setEffectiveWeight(addWeight);
|
||||
document.getElementById('add-name').textContent = currentAddName + ' · ' + addWeight.toFixed(2);
|
||||
});
|
||||
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||
const ts = parseFloat(e.target.value);
|
||||
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||
if (mixer) mixer.timeScale = ts;
|
||||
});
|
||||
function bindToggle(id, obj) {
|
||||
document.getElementById(id).addEventListener('change', e => {
|
||||
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||
else if (!e.target.checked) scene.remove(obj);
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
document.getElementById('t-skel').addEventListener('change', e => {
|
||||
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||
});
|
||||
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||
else if (!e.target.checked) scene.remove(bb);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main loop
|
||||
// ---------------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let lastMs = performance.now();
|
||||
let fpsEma = 60;
|
||||
function tick() {
|
||||
const nowMs = performance.now();
|
||||
const dt = nowMs - lastMs;
|
||||
lastMs = nowMs;
|
||||
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||
const t = nowMs * 0.001;
|
||||
const delta = clock.getDelta();
|
||||
|
||||
if (mixer) mixer.update(delta);
|
||||
floorMat.uniforms.time.value = t;
|
||||
filmShader.uniforms.time.value = t;
|
||||
|
||||
// get model center for CSI / ping targeting
|
||||
const center = new THREE.Vector3();
|
||||
if (model) {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
} else center.set(0, 0.9, 0);
|
||||
|
||||
tickCsi(t, center);
|
||||
updateNodes();
|
||||
maybeEmitPings(t, center);
|
||||
updatePings(t);
|
||||
updateTomography(t);
|
||||
updateBbox();
|
||||
updateFaceCloud(t);
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
|
||||
updateHud(t, fpsEma);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,961 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · Skinned (FBX) · Mixamo X Bot in the ADR-097 helpers scene</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #050507; --bg-panel: rgba(8,10,14,0.78);
|
||||
--amber: #ffb840; --amber-hot: #ffe09f;
|
||||
--cyan: #4cf; --magenta: #ff4cc8;
|
||||
--text: #d8c69a; --text-mute: #6b6155;
|
||||
--border: rgba(255,184,64,0.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||
}
|
||||
canvas { display: block; }
|
||||
.overlay-frame {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||
}
|
||||
.scanlines {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||
mix-blend-mode: overlay; opacity: 0.5;
|
||||
}
|
||||
.panel {
|
||||
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||
}
|
||||
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||
#info .row .v.amber { color: var(--amber); }
|
||||
#info .row .v.cyan { color: var(--cyan); }
|
||||
#info .row .v.mag { color: var(--magenta); }
|
||||
|
||||
#anim {
|
||||
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#anim .row { padding: 6px 0; font-size: 10px; }
|
||||
#anim .row .label { color: var(--text-mute); margin-right: 8px; }
|
||||
#anim button {
|
||||
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; margin-top: 6px; border-top: 1px solid rgba(255,184,64,0.08); padding-top: 8px; }
|
||||
#anim .slider-row .label { width: 90px; }
|
||||
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
#anim .empty-hint {
|
||||
font-size: 10px; color: var(--text-mute); line-height: 1.5; margin-top: 4px;
|
||||
padding: 8px; background: rgba(255,184,64,0.04); border-radius: 3px;
|
||||
border-left: 2px solid var(--amber);
|
||||
}
|
||||
#anim .empty-hint a { color: var(--amber); text-decoration: none; }
|
||||
#anim .empty-hint a:hover { color: var(--amber-hot); text-decoration: underline; }
|
||||
|
||||
#helpers {
|
||||
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#helpers label {
|
||||
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||
}
|
||||
#helpers label:hover { color: var(--amber-hot); }
|
||||
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||
|
||||
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||
#csi .bar-row .bar-fill {
|
||||
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||
}
|
||||
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(5,5,7,0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||
letter-spacing: 2px; text-transform: uppercase;
|
||||
}
|
||||
#loading.hidden { display: none; }
|
||||
#loading .text { text-shadow: 0 0 12px var(--amber); animation: loadPulse 1.4s ease-in-out infinite; }
|
||||
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||
|
||||
@keyframes scanFlash { 0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; } }
|
||||
.scan-flash {
|
||||
position: fixed; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||
}
|
||||
|
||||
#titlecard {
|
||||
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||
}
|
||||
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="https://unpkg.com/fflate@0.7.4/umd/index.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/curves/NURBSCurve.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-frame"></div>
|
||||
<div class="scanlines"></div>
|
||||
<div class="scan-flash" id="scan-flash"></div>
|
||||
<div id="loading"><div class="text">▸ Loading skinned subject · X Bot.fbx</div></div>
|
||||
|
||||
<div class="panel" id="info">
|
||||
<h1>RuView · Skinned (FBX)</h1>
|
||||
<div class="sub">ADR-097 · Mixamo X Bot · loaded via FBXLoader</div>
|
||||
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||
<div class="row"><span class="k">Source</span><span class="v" id="src-name">X Bot.fbx</span></div>
|
||||
<div class="row"><span class="k">Format</span><span class="v">FBX 7700 · 1.75 MB</span></div>
|
||||
<div class="row"><span class="k">Bones</span><span class="v" id="bone-count">—</span></div>
|
||||
<div class="row"><span class="k">Animation</span><span class="v amber" id="anim-name">—</span></div>
|
||||
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||
</div>
|
||||
|
||||
<div id="anim">
|
||||
<h2>AnimationMixer</h2>
|
||||
<div class="row">
|
||||
<span class="label">clips</span>
|
||||
<span id="clip-buttons"></span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="label">time scale</span>
|
||||
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||
<span class="val" id="time-scale-val">1.00</span>
|
||||
</div>
|
||||
<div class="empty-hint" id="empty-hint" style="display:none;">
|
||||
<strong>No animations in this FBX.</strong><br>
|
||||
Mixamo's "T-Pose / Without Skin" export rigs the model but has no clips.
|
||||
Re-download with <em>"Original Pose"</em> + an animation selected
|
||||
(e.g. <a href="https://www.mixamo.com/#/?page=1&query=walking&type=Motion%2CMotionPack" target="_blank" rel="noopener">Walking</a>) to get a clip, or drop another FBX with anim and reload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="csi">
|
||||
<h2>Per-node CSI</h2>
|
||||
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0"></div></div><span class="val" id="val-0">—</span></div>
|
||||
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1"></div></div><span class="val" id="val-1">—</span></div>
|
||||
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2"></div></div><span class="val" id="val-2">—</span></div>
|
||||
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3"></div></div><span class="val" id="val-3">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="helpers">
|
||||
<h2>ADR-097 helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||
<label><input type="checkbox" id="t-rays" checked>RF illumination cones<span class="swatch" style="color:#ffb840"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="titlecard">
|
||||
RuView · Seldon Vault
|
||||
<div class="sub">FBXLoader · Mixamo · ADR-097</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · Skinned (FBX) · Mixamo X Bot loaded via FBXLoader
|
||||
// --------------------------------------------------------------------
|
||||
// Sibling of helpers-skinned.html that loads a local .fbx file
|
||||
// rather than the canonical GLB. Same cinematic atmosphere
|
||||
// (UnrealBloomPass, sonar pings, tomography sweep, pseudo-CSI),
|
||||
// same ADR-097 helpers wrapping the rigged mesh.
|
||||
//
|
||||
// Mixamo FBX caveats handled here:
|
||||
// 1. Mixamo exports in cm (100 = 1 m). We auto-detect by the
|
||||
// loaded model's bbox height and rescale to ~1.7 m human size.
|
||||
// 2. PhongMaterial → StandardMaterial swap for cleaner shading
|
||||
// under our amber key + cyan rim lights.
|
||||
// 3. Bone name probing for the head (Mixamo: "mixamorigHead",
|
||||
// legacy: "Bip01_Head", or any bone with /head/i match).
|
||||
// 4. Graceful no-animations case — many Mixamo exports are
|
||||
// rig-only.
|
||||
// =====================================================================
|
||||
|
||||
const MODEL_URL = '../assets/X%20Bot.fbx';
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene / camera / renderer
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050507);
|
||||
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.2, 1.55, 4.0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.80;
|
||||
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0.9, 0);
|
||||
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
||||
controls.minDistance = 2; controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||
controls.autoRotateSpeed = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lights — amber key + cyan rim from each ESP32 direction
|
||||
// ---------------------------------------------------------------------
|
||||
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffc070, 1.05);
|
||||
keyLight.position.set(2.5, 3.8, 2.5);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.bias = -0.0008;
|
||||
scene.add(keyLight);
|
||||
|
||||
const rimLights = [];
|
||||
NODE_POSITIONS.forEach(pos => {
|
||||
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||
scene.add(rim); rimLights.push(rim);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// ---------------------------------------------------------------------
|
||||
const composer = new THREE.EffectComposer(renderer);
|
||||
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||
const bloom = new THREE.UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
0.45, 0.40, 0.78,
|
||||
);
|
||||
composer.addPass(bloom);
|
||||
const filmShader = {
|
||||
uniforms: { tDiffuse: { value: null }, time: { value: 0 }, grain: { value: 0.04 },
|
||||
vignette: { value: 0.32 }, aberration: { value: 0.0018 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||
varying vec2 vUv;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
vec2 off = (vUv - 0.5) * aberration;
|
||||
float r = texture2D(tDiffuse, vUv + off).r;
|
||||
float g = texture2D(tDiffuse, vUv).g;
|
||||
float b = texture2D(tDiffuse, vUv - off).b;
|
||||
vec3 col = vec3(r, g, b);
|
||||
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||
col *= mix(1.0 - vignette, 1.0, v);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
};
|
||||
const filmPass = new THREE.ShaderPass(filmShader);
|
||||
composer.addPass(filmPass);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Floor (same procedural shader as cinematic / skinned-glb)
|
||||
// ---------------------------------------------------------------------
|
||||
const floorMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||
void main() {
|
||||
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||
scan = pow(scan, 14.0);
|
||||
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
scene.add(floor);
|
||||
|
||||
const shadowGround = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||
);
|
||||
shadowGround.rotation.x = -Math.PI / 2;
|
||||
shadowGround.position.y = 0.001;
|
||||
shadowGround.receiveShadow = true;
|
||||
scene.add(shadowGround);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers + sensor nodes (same as helpers-skinned.html)
|
||||
// ---------------------------------------------------------------------
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||
polarHelper.position.y = 0.002;
|
||||
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||
scene.add(polarHelper);
|
||||
|
||||
let bboxHelper = null;
|
||||
let skeletonHelper = null;
|
||||
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeRings = [];
|
||||
const nodeAnchors = [];
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
group.add(new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.14, 0.06, 0.20),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840 })
|
||||
));
|
||||
const antenna = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.018, 0.10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
antenna.position.y = 0.08; group.add(antenna);
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide,
|
||||
transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||
group.add(ring); nodeRings.push(ring);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.025, 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
core.position.y = 0.04; group.add(core);
|
||||
|
||||
scene.add(group); nodeAnchors.push(group);
|
||||
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// God-ray cones — one per node, pointed at the subject. Visualizes
|
||||
// "the four ESP32s are jointly illuminating the body with RF". Each
|
||||
// cone has a volumetric-feeling gradient shader and is opacity-
|
||||
// modulated by that node's csiAmp × csiCoherence (so when a node's
|
||||
// signal degrades, its ray dims).
|
||||
// ---------------------------------------------------------------------
|
||||
const godRayMat = (color, idx) => new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
intensity: { value: 0.0 },
|
||||
color: { value: new THREE.Color(color) },
|
||||
seed: { value: idx * 17.3 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
varying float vY;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vY = position.y;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity, seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
varying float vY;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
// along the cone (uv.y goes 0=tip → 1=base), fade out at the base
|
||||
float edgeFade = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.65, vUv.y);
|
||||
// soft radial falloff (cone-edge transparency)
|
||||
float radial = sin(vUv.y * 3.14159);
|
||||
radial = pow(radial, 2.0);
|
||||
// volumetric noise (slow scrolling)
|
||||
float n = hash(floor(vUv * vec2(40.0, 60.0)) + vec2(seed, time * 0.4));
|
||||
float scroll = 0.85 + 0.30 * sin(vUv.y * 32.0 - time * 1.4 + seed);
|
||||
float a = edgeFade * radial * scroll * (0.55 + 0.45 * n);
|
||||
gl_FragColor = vec4(color, a * intensity * 0.25);
|
||||
}`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const godRays = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// cone with apex at node, expanding toward the subject
|
||||
// height 4 m (more than enough to reach subject), radius 0.45 m at base
|
||||
const geom = new THREE.ConeGeometry(0.45, 4.0, 28, 1, true);
|
||||
// ConeGeometry tip is at +Y, base at -Y — rotate so tip is along -Y
|
||||
// (we'll later orient each cone so its tip touches the node).
|
||||
geom.translate(0, -2.0, 0); // shift so apex is at origin
|
||||
const mat = godRayMat(0xffb840, i);
|
||||
const cone = new THREE.Mesh(geom, mat);
|
||||
scene.add(cone);
|
||||
godRays.push({ mesh: cone, mat });
|
||||
}
|
||||
function updateGodRays(t) {
|
||||
if (!model) return;
|
||||
const want = document.getElementById('t-rays').checked;
|
||||
const center = new THREE.Vector3();
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
godRays[i].mesh.visible = want;
|
||||
if (!want) continue;
|
||||
const node = nodeAnchors[i];
|
||||
const np = node.getWorldPosition(new THREE.Vector3());
|
||||
const dir = new THREE.Vector3().subVectors(center, np);
|
||||
const len = dir.length();
|
||||
dir.normalize();
|
||||
const ray = godRays[i];
|
||||
ray.mesh.position.copy(np);
|
||||
// align cone's -Y axis (apex direction after the geometry shift)
|
||||
// to point along `dir`
|
||||
ray.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir);
|
||||
// stretch cone length to actual distance, keep base width reasonable
|
||||
ray.mesh.scale.set(1, len / 4.0, 1);
|
||||
ray.mat.uniforms.time.value = t;
|
||||
// intensity follows that node's CSI amplitude * global coherence
|
||||
const target = csiAmp[i] * csiCoherence * 1.4;
|
||||
ray.mat.uniforms.intensity.value =
|
||||
ray.mat.uniforms.intensity.value * 0.85 + target * 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// FBX load with Mixamo-aware fixups
|
||||
// ---------------------------------------------------------------------
|
||||
let model = null;
|
||||
let mixer = null;
|
||||
let headBone = null;
|
||||
let boneCount = 0;
|
||||
const clipActions = {}; // by clip name
|
||||
let currentClip = null;
|
||||
|
||||
const loader = new THREE.FBXLoader();
|
||||
loader.load(MODEL_URL, (object) => {
|
||||
model = object;
|
||||
|
||||
// 1. Scale fix — Mixamo defaults to cm; detect by bbox height and
|
||||
// rescale so the rig reads as ~1.7 m human size.
|
||||
const raw = new THREE.Box3().setFromObject(model);
|
||||
const height = raw.max.y - raw.min.y;
|
||||
if (height > 10) {
|
||||
model.scale.setScalar(1 / 100); // cm → m
|
||||
} else if (height > 5) {
|
||||
model.scale.setScalar(1 / 50); // catch in-between rigs
|
||||
}
|
||||
// recenter on origin at floor
|
||||
const b2 = new THREE.Box3().setFromObject(model);
|
||||
model.position.y -= b2.min.y;
|
||||
|
||||
// 2. Material upgrade + shadow casting + head/bone scan
|
||||
model.traverse((obj) => {
|
||||
if (obj.isMesh) {
|
||||
obj.castShadow = true;
|
||||
obj.receiveShadow = true;
|
||||
// Phong → Standard for cleaner shading under our PBR lights.
|
||||
// Keep diffuse map + skinning intact.
|
||||
if (obj.material && obj.material.isMeshPhongMaterial) {
|
||||
const m = obj.material;
|
||||
const upgraded = new THREE.MeshStandardMaterial({
|
||||
map: m.map, normalMap: m.normalMap, color: m.color,
|
||||
skinning: !!obj.isSkinnedMesh,
|
||||
metalness: 0.0, roughness: 0.85,
|
||||
});
|
||||
obj.material = upgraded;
|
||||
}
|
||||
}
|
||||
if (obj.isBone) {
|
||||
boneCount++;
|
||||
if (!headBone && /head/i.test(obj.name)) headBone = obj;
|
||||
}
|
||||
});
|
||||
document.getElementById('bone-count').textContent = boneCount;
|
||||
|
||||
scene.add(model);
|
||||
|
||||
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||
skeletonHelper.visible = false;
|
||||
scene.add(skeletonHelper);
|
||||
|
||||
// 3. Animations — Mixamo exports one clip per FBX (sometimes none)
|
||||
const clips = object.animations || [];
|
||||
if (clips.length === 0) {
|
||||
document.getElementById('anim-name').textContent = 'none (rig-only)';
|
||||
document.getElementById('empty-hint').style.display = 'block';
|
||||
} else {
|
||||
mixer = new THREE.AnimationMixer(model);
|
||||
const btnHost = document.getElementById('clip-buttons');
|
||||
for (const clip of clips) {
|
||||
const action = mixer.clipAction(clip);
|
||||
clipActions[clip.name] = action;
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = clip.name || 'clip-' + Object.keys(clipActions).length;
|
||||
btn.addEventListener('click', () => playClip(clip.name));
|
||||
btnHost.appendChild(btn);
|
||||
}
|
||||
playClip(clips[0].name);
|
||||
}
|
||||
|
||||
// 4. Face point cloud
|
||||
if (headBone) buildFacePointCloud();
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}, (xhr) => {
|
||||
const total = xhr.total || 1750032;
|
||||
const pct = (xhr.loaded / total * 100).toFixed(0);
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = `▸ Loading skinned subject · X Bot.fbx · ${pct} %`;
|
||||
}, (err) => {
|
||||
console.error('FBX load failed', err);
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = '⚠ Load failed — see console';
|
||||
});
|
||||
|
||||
function playClip(name) {
|
||||
for (const k in clipActions) {
|
||||
const a = clipActions[k];
|
||||
if (k === name) {
|
||||
a.reset(); a.play(); currentClip = name;
|
||||
document.getElementById('anim-name').textContent = name;
|
||||
for (const btn of document.querySelectorAll('#anim button[data-base], #anim button')) {
|
||||
if (btn.dataset.base !== undefined || !btn.textContent) continue;
|
||||
btn.classList.toggle('active', btn.textContent === name);
|
||||
}
|
||||
} else a.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — anchored to head bone, same shimmer shader
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 220; // fewer points so each dot is visible as a tracked landmark
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||
const facePhases = new Float32Array(FACE_POINTS);
|
||||
let facePoints = null;
|
||||
function buildFacePointCloud() {
|
||||
// Front-hemisphere only — points scattered on the +Z half of an
|
||||
// ellipsoid so the cloud reads as a FACE projection forward from
|
||||
// the head bone, not a halo wrapping the skull. Local coords:
|
||||
// +Z = forward (face direction), +Y = up, +X = right.
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
// theta in [0, 2π) around the local Z axis, phi in [0, π/2]
|
||||
// (front hemisphere only — no points behind the head)
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(1 - Math.random() * 0.95); // dense near face front
|
||||
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
|
||||
// ellipsoid radii (taller than wide, slightly squashed F-B)
|
||||
const rx = 0.085, ry = 0.108, rz = 0.075;
|
||||
// local coords with +Z = face forward
|
||||
faceOffsets[i*3+0] = rx * sinPhi * Math.cos(theta);
|
||||
faceOffsets[i*3+1] = ry * sinPhi * Math.sin(theta) * 1.05; // taller
|
||||
faceOffsets[i*3+2] = rz * cosPhi; // forward
|
||||
facePhases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 } },
|
||||
vertexShader: `
|
||||
attribute float aPhase; uniform float time;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
// Slow per-point shimmer + occasional "scan-lit" spike
|
||||
// so the cloud reads as discrete tracked landmarks
|
||||
// rather than a fluffy halo.
|
||||
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||
float spark = step(0.95, fract(sin(aPhase * 17.0 + time * 0.5) * 43.0));
|
||||
vAlpha = 0.10 + 0.25 * shimmer + 0.55 * spark;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
// 6× smaller — tracked dots, not a cloud
|
||||
gl_PointSize = (1.0 + shimmer * 0.6 + spark * 1.5) * (32.0 / -mv.z);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
if (d > 0.5) discard;
|
||||
float falloff = smoothstep(0.5, 0.0, d);
|
||||
vec3 col = vec3(0.40, 0.78, 1.00);
|
||||
gl_FragColor = vec4(col, vAlpha * falloff);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
facePoints = new THREE.Points(geom, mat);
|
||||
scene.add(facePoints);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pings + tomography + CSI driver — copied wholesale from skinned-glb
|
||||
// ---------------------------------------------------------------------
|
||||
const PING_POOL = 24;
|
||||
const pings = [];
|
||||
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||
for (let i = 0; i < PING_POOL; i++) {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(pingGeo, mat); mesh.visible = false; scene.add(mesh);
|
||||
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||
}
|
||||
let pingIndex = 0;
|
||||
function emitPing(origin, target) {
|
||||
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||
p.active = true; p.t0 = performance.now() * 0.001;
|
||||
p.duration = 0.55 + Math.random() * 0.20;
|
||||
p.origin.copy(origin); p.target.copy(target);
|
||||
p.mesh.position.copy(origin); p.mesh.visible = true; p.mesh.material.opacity = 0;
|
||||
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||
}
|
||||
|
||||
const tomoMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity; varying vec2 vUv;
|
||||
void main() {
|
||||
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||
}`,
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||
});
|
||||
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||
tomoPlane.rotation.y = Math.PI / 2;
|
||||
tomoPlane.position.set(-2, 1.0, 0); tomoPlane.visible = false;
|
||||
scene.add(tomoPlane);
|
||||
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||
|
||||
const csiAmp = [0, 0, 0, 0];
|
||||
let csiCoherence = 0.5;
|
||||
const csiNoise = [0, 0, 0, 0];
|
||||
function tickCsi(t, targetWorld) {
|
||||
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||
let mean = 0; const amps = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const np = NODE_POSITIONS[i];
|
||||
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||
const r2 = dx*dx + dy*dy + dz*dz;
|
||||
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||
amps.push(a);
|
||||
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||
mean += a;
|
||||
}
|
||||
mean /= 4;
|
||||
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||
v = Math.sqrt(v / 4);
|
||||
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||
}
|
||||
|
||||
let lastPingT = [0, 0, 0, 0];
|
||||
// Subject hit-flash: when a sonar ping lands, briefly raise the
|
||||
// emissive on every mesh in the model. Decays each frame.
|
||||
let subjectFlash = 0;
|
||||
const modelMeshes = [];
|
||||
function collectModelMeshes() {
|
||||
if (!model || modelMeshes.length) return;
|
||||
model.traverse(o => {
|
||||
if (o.isMesh && o.material && o.material.isMeshStandardMaterial) {
|
||||
o.material.emissive = new THREE.Color(0xffb840);
|
||||
o.material.emissiveIntensity = 0;
|
||||
modelMeshes.push(o);
|
||||
}
|
||||
});
|
||||
}
|
||||
function updateSubjectFlash() {
|
||||
collectModelMeshes();
|
||||
subjectFlash *= 0.86;
|
||||
for (const m of modelMeshes) {
|
||||
m.material.emissiveIntensity = subjectFlash;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle root motion — even with a stationary Idle clip, give the
|
||||
// figure a gentle drift + look-around so it doesn't feel pinned.
|
||||
function updateRootMotion(t) {
|
||||
if (!model) return;
|
||||
model.position.x = Math.sin(t * 0.18) * 0.06;
|
||||
model.position.z = Math.cos(t * 0.13) * 0.05;
|
||||
model.rotation.y = Math.sin(t * 0.11) * 0.18;
|
||||
}
|
||||
|
||||
function updateNodes() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ring = nodeRings[i];
|
||||
const amp = csiAmp[i];
|
||||
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||
ring.scale.setScalar(1 + 0.30 * amp);
|
||||
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||
}
|
||||
}
|
||||
function maybeEmitPings(t, modelCenter) {
|
||||
if (!document.getElementById('t-pings').checked || !model) return;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||
if (t - lastPingT[i] > interval) {
|
||||
lastPingT[i] = t;
|
||||
const target = modelCenter.clone();
|
||||
target.y += (Math.random() - 0.3) * 0.8;
|
||||
target.x += (Math.random() - 0.5) * 0.2;
|
||||
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||
emitPing(origin, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
function updatePings(t) {
|
||||
for (const p of pings) {
|
||||
if (!p.active) continue;
|
||||
const u = (t - p.t0) / p.duration;
|
||||
if (u >= 1) {
|
||||
p.active = false; p.mesh.visible = false;
|
||||
// ping landed — flash the subject (drives emissiveIntensity)
|
||||
subjectFlash = Math.min(0.42, subjectFlash + 0.18);
|
||||
continue;
|
||||
}
|
||||
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||
}
|
||||
}
|
||||
function updateTomography(t) {
|
||||
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||
if (!tomoActive && t > tomoNextAt) {
|
||||
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||
const sf = document.getElementById('scan-flash');
|
||||
sf.style.animation = 'none';
|
||||
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||
}
|
||||
if (tomoActive) {
|
||||
const dur = 2.4;
|
||||
const e = (t - tomoT0) / dur;
|
||||
if (e >= 1) {
|
||||
tomoActive = false; tomoPlane.visible = false;
|
||||
tomoNextAt = t + 4 + Math.random() * 5;
|
||||
} else {
|
||||
tomoPlane.position.x = -3 + e * 6;
|
||||
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||
tomoMat.uniforms.time.value = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateBbox() {
|
||||
const want = document.getElementById('t-bbox').checked && model;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
document.getElementById('bbox-vol').textContent = '—';
|
||||
return;
|
||||
}
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||
scene.add(bboxHelper);
|
||||
} else bboxHelper.setFromObject(model);
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
const tmpHeadPos = new THREE.Vector3();
|
||||
const tmpHeadQuat = new THREE.Quaternion();
|
||||
const tmpHeadScl = new THREE.Vector3();
|
||||
const tmpOffset = new THREE.Vector3();
|
||||
function updateFaceCloud(t) {
|
||||
if (!facePoints || !headBone) return;
|
||||
// Decompose the head bone's world matrix so we can apply its
|
||||
// orientation (face direction) to each local offset. This way
|
||||
// the cloud rotates with the head — turn left/right and the
|
||||
// face points stay in front of the face.
|
||||
headBone.updateMatrixWorld(true);
|
||||
headBone.matrixWorld.decompose(tmpHeadPos, tmpHeadQuat, tmpHeadScl);
|
||||
// Mixamo head bone forward is along +Y in some rigs (head looks up the
|
||||
// bone chain) — project the cloud along the model's actual forward
|
||||
// vector, which for Mixamo X Bot facing camera is world +Z.
|
||||
// Use the model's root rotation as the source of "forward".
|
||||
const forward = new THREE.Vector3(0, 0, 1);
|
||||
if (model) forward.applyQuaternion(model.getWorldQuaternion(new THREE.Quaternion()));
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
|
||||
const facingUp = up.clone();
|
||||
// anchor the cloud just in front of the head
|
||||
const anchor = tmpHeadPos.clone().addScaledVector(forward, 0.04);
|
||||
anchor.y += 0.04; // nudge up so cloud sits over the face, not the chin
|
||||
const pos = facePoints.geometry.attributes.position;
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const ox = faceOffsets[i*3+0];
|
||||
const oy = faceOffsets[i*3+1];
|
||||
const oz = faceOffsets[i*3+2];
|
||||
// map local (ox, oy, oz) into world via (right, up, forward)
|
||||
tmpOffset.copy(right).multiplyScalar(ox)
|
||||
.addScaledVector(facingUp, oy)
|
||||
.addScaledVector(forward, oz);
|
||||
pos.array[i*3+0] = anchor.x + tmpOffset.x;
|
||||
pos.array[i*3+1] = anchor.y + tmpOffset.y;
|
||||
pos.array[i*3+2] = anchor.z + tmpOffset.z;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
facePoints.material.uniforms.time.value = t;
|
||||
}
|
||||
|
||||
let hudT = 0;
|
||||
function updateHud(t, fps) {
|
||||
if (t - hudT < 0.1) return;
|
||||
hudT = t;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const pct = Math.round(csiAmp[i] * 100);
|
||||
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||
document.getElementById('val-' + i).textContent = pct + '%';
|
||||
}
|
||||
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
// UI wiring
|
||||
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||
const ts = parseFloat(e.target.value);
|
||||
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||
if (mixer) mixer.timeScale = ts;
|
||||
});
|
||||
function bindToggle(id, obj) {
|
||||
document.getElementById(id).addEventListener('change', e => {
|
||||
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||
else if (!e.target.checked) scene.remove(obj);
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
document.getElementById('t-skel').addEventListener('change', e => {
|
||||
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||
});
|
||||
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||
else if (!e.target.checked) scene.remove(bb);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main loop
|
||||
// ---------------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let lastMs = performance.now();
|
||||
let fpsEma = 60;
|
||||
function tick() {
|
||||
const nowMs = performance.now();
|
||||
const dt = nowMs - lastMs;
|
||||
lastMs = nowMs;
|
||||
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||
const t = nowMs * 0.001;
|
||||
const delta = clock.getDelta();
|
||||
|
||||
if (mixer) mixer.update(delta);
|
||||
floorMat.uniforms.time.value = t;
|
||||
filmShader.uniforms.time.value = t;
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
if (model) {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
} else center.set(0, 0.9, 0);
|
||||
|
||||
tickCsi(t, center);
|
||||
updateRootMotion(t);
|
||||
updateNodes();
|
||||
updateGodRays(t);
|
||||
maybeEmitPings(t, center);
|
||||
updatePings(t);
|
||||
updateSubjectFlash();
|
||||
updateTomography(t);
|
||||
updateBbox();
|
||||
updateFaceCloud(t);
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
updateHud(t, fpsEma);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 682 KiB |
|
After Width: | Height: | Size: 596 KiB |
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ruvultra → browser CSI bridge.
|
||||
|
||||
Reads adaptive_ctrl tick lines from the ESP32-S3 RuView firmware on
|
||||
/dev/ttyACM0 and forwards normalized per-node metrics over a WebSocket
|
||||
that the helpers-skinned-realtime demo can subscribe to via Tailscale.
|
||||
|
||||
Sample serial line (1 Hz cadence from firmware):
|
||||
I (22890561) adaptive_ctrl: medium tick: state=6 yield=15pps motion=1.00 presence=5.35 rssi=-33
|
||||
|
||||
Output JSON (per tick):
|
||||
{
|
||||
"ts": 1716830400.123,
|
||||
"node": 0, # always 0 (single node), client expands to 4
|
||||
"motion": 1.00, # raw firmware metric
|
||||
"presence": 5.35,
|
||||
"rssi": -33,
|
||||
"yield_pps": 15,
|
||||
"amp": 0.78 # synthesized CSI amplitude in [0..1] for the bar
|
||||
}
|
||||
|
||||
Run on ruvultra:
|
||||
python3 -u ruvultra-csi-bridge.py
|
||||
"""
|
||||
import asyncio
|
||||
import builtins
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
# Force every print to flush — we're often piped to a log file
|
||||
_orig_print = builtins.print
|
||||
def _print(*a, **kw):
|
||||
kw.setdefault("flush", True)
|
||||
return _orig_print(*a, **kw)
|
||||
builtins.print = _print
|
||||
|
||||
import serial
|
||||
import websockets
|
||||
|
||||
PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
WS_HOST = "0.0.0.0"
|
||||
WS_PORT = 8766
|
||||
|
||||
TICK_RE = re.compile(
|
||||
r"adaptive_ctrl:\s*\w+\s+tick:\s*"
|
||||
r"state=(?P<state>\d+)\s+"
|
||||
r"yield=(?P<yield>\d+)pps\s+"
|
||||
r"motion=(?P<motion>[\d.]+)\s+"
|
||||
r"presence=(?P<presence>[\d.]+)\s+"
|
||||
r"rssi=(?P<rssi>-?\d+)"
|
||||
)
|
||||
|
||||
clients = set()
|
||||
last_payload = None
|
||||
|
||||
|
||||
def amp_from_metrics(motion, presence, rssi):
|
||||
"""Map firmware metrics to a [0..1] CSI-style amplitude."""
|
||||
rssi_norm = max(0.0, min(1.0, (rssi + 80) / 50)) # -80..-30 → 0..1
|
||||
presence_norm = max(0.0, min(1.0, presence / 8.0)) # cap at 8
|
||||
motion_norm = max(0.0, min(1.0, motion)) # already 0..1ish
|
||||
return 0.40 * rssi_norm + 0.35 * presence_norm + 0.25 * motion_norm
|
||||
|
||||
|
||||
async def serial_reader_loop():
|
||||
global last_payload
|
||||
print(f"[bridge] opening {PORT} @ {BAUD}…")
|
||||
while True:
|
||||
try:
|
||||
ser = serial.Serial(PORT, BAUD, timeout=1)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
print(f"[bridge] serial open failed ({e}); retry in 3s")
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
print(f"[bridge] connected to {PORT}")
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
while True:
|
||||
line = await loop.run_in_executor(None, ser.readline)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
text = line.decode(errors="replace").strip()
|
||||
except Exception:
|
||||
continue
|
||||
m = TICK_RE.search(text)
|
||||
if not m:
|
||||
continue
|
||||
motion = float(m["motion"])
|
||||
presence = float(m["presence"])
|
||||
rssi = int(m["rssi"])
|
||||
payload = {
|
||||
"ts": time.time(),
|
||||
"node": 0,
|
||||
"state": int(m["state"]),
|
||||
"yield_pps": int(m["yield"]),
|
||||
"motion": motion,
|
||||
"presence": presence,
|
||||
"rssi": rssi,
|
||||
"amp": amp_from_metrics(motion, presence, rssi),
|
||||
}
|
||||
last_payload = payload
|
||||
msg = json.dumps(payload)
|
||||
if clients:
|
||||
dead = []
|
||||
for ws in list(clients):
|
||||
try:
|
||||
await ws.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
dead.append(ws)
|
||||
for d in dead:
|
||||
clients.discard(d)
|
||||
print(
|
||||
f"[tick] motion={motion:.2f} presence={presence:5.2f} "
|
||||
f"rssi={rssi:+d} yield={int(m['yield']):3d}pps "
|
||||
f"amp={payload['amp']:.2f} clients={len(clients)}"
|
||||
)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
print(f"[bridge] serial error ({e}); reopen in 1s")
|
||||
with suppress(Exception):
|
||||
ser.close()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def ws_handler(ws):
|
||||
addr = ws.remote_address
|
||||
clients.add(ws)
|
||||
print(f"[ws] client connected: {addr} total={len(clients)}")
|
||||
try:
|
||||
if last_payload is not None:
|
||||
await ws.send(json.dumps(last_payload))
|
||||
await ws.wait_closed()
|
||||
finally:
|
||||
clients.discard(ws)
|
||||
print(f"[ws] client gone: {addr} total={len(clients)}")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"[bridge] websocket on ws://{WS_HOST}:{WS_PORT}")
|
||||
async with websockets.serve(ws_handler, WS_HOST, WS_PORT):
|
||||
await serial_reader_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tiny threaded HTTP server for the three.js demos that fetch local files.
|
||||
|
||||
Why a sibling helper script instead of `python -m http.server`?
|
||||
The stdlib SimpleHTTPServer is single-threaded; Chrome opens many parallel
|
||||
connections (HTML + 9 script tags + FBX), the first eats the worker, the
|
||||
rest time out with net::ERR_EMPTY_RESPONSE. ThreadingHTTPServer fixes it.
|
||||
|
||||
Usage:
|
||||
python examples/three.js/server/serve-demo.py
|
||||
open http://localhost:8765/examples/three.js/demos/05-skinned-realtime.html
|
||||
"""
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
import os, sys
|
||||
|
||||
PORT = int(os.environ.get("PORT", 8765))
|
||||
# Always serve from the repo root regardless of where the script is launched.
|
||||
# This file lives at examples/three.js/server/serve-demo.py — three levels deep.
|
||||
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||
|
||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
# Aggressive no-cache so browser ALWAYS fetches the latest .html
|
||||
# after we edit it. Otherwise stale code sticks around even on hard
|
||||
# refresh and you debug a phantom.
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
DEMOS = [
|
||||
"01-helpers.html",
|
||||
"02-cinematic.html",
|
||||
"03-skinned.html",
|
||||
"04-skinned-fbx.html",
|
||||
"05-skinned-realtime.html",
|
||||
]
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
|
||||
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
|
||||
print("demos:")
|
||||
for d in DEMOS:
|
||||
print(f" http://127.0.0.1:{PORT}/examples/three.js/demos/{d}")
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -15,7 +15,7 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 and
|
||||
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
|
||||
> | **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** | Phase variance + adaptive calibration | < 1 ms latency |
|
||||
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
@@ -37,18 +37,22 @@ MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
`bootloader=0x0`, `partition-table=0x8000`, `otadata=0xf000`, `app (ota_0)=0x20000`.
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
@@ -129,11 +133,32 @@ Adds real-time health and safety monitoring.
|
||||
|
||||
- **Breathing rate** -- biquad IIR bandpass 0.1-0.5 Hz, zero-crossing BPM (6-30 BPM)
|
||||
- **Heart rate** -- biquad IIR bandpass 0.8-2.0 Hz, zero-crossing BPM (40-120 BPM)
|
||||
- **Presence detection** -- adaptive threshold calibration (60 s ambient learning)
|
||||
- **Presence indicator** -- phase variance vs an adaptively-calibrated threshold (60 s ambient learning at boot). Heuristic, not a learned classifier — strong RF interferers (fans, microwaves, transmit-power swings) can push variance above threshold without anyone in the room. See "What this firmware does NOT do" below.
|
||||
- **Fall detection** -- phase acceleration exceeds configurable threshold
|
||||
- **Multi-person estimation** -- subcarrier group clustering (up to 4 persons)
|
||||
- **Multi-person slot count** -- partitions the top-K subcarriers into `top_k / 2` groups (clamped to `[1, EDGE_MAX_PERSONS]`), computes per-group filtered breathing/heart-rate estimates, and reports the slot count as `pkt.n_persons`. This is a **slot-capacity heuristic**, not a learned counter — the reported count tracks subcarrier diversity, not actual occupancy. See [`edge_processing.c:481-548`](main/edge_processing.c#L481-L548).
|
||||
- **Vitals packet** -- 32-byte UDP packet at 1 Hz (magic `0xC5110002`)
|
||||
|
||||
### What this firmware does NOT do (Tier 2 caveats)
|
||||
|
||||
- It does **not** run a trained neural model. The "person count" is an
|
||||
arithmetic slot-capacity heuristic over the top-K subcarrier groups
|
||||
(`firmware/esp32-csi-node/main/edge_processing.c:481`). It tracks
|
||||
subcarrier diversity, not actual occupancy.
|
||||
- It does **not** run pose estimation. Pose-related features in the host
|
||||
UI come from the Rust `wifi-densepose-sensing-server` running a separate
|
||||
pipeline. When no `.rvf` model file is loaded via `--model`, the server
|
||||
drives the on-screen skeleton from signal-based heuristics (amplitude
|
||||
variance, motion-band power), not from learned keypoint inference. The
|
||||
repository does not ship pre-trained weights — see issues
|
||||
[#509](../../issues/509) and [#506](../../issues/506) for context, and
|
||||
[ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for
|
||||
the planned training path (phases P7-P9 are `Pending`).
|
||||
- The presence indicator is a calibrated variance threshold and **will
|
||||
false-positive** under strong RF interference from non-human sources
|
||||
(fans near the antenna, microwave duty cycles, neighbouring AP power
|
||||
swings) without re-running the 60-second ambient calibration. If you
|
||||
see ghost detections, re-calibrate by power-cycling in an empty room.
|
||||
|
||||
### Tier 3 -- WASM Programmable Sensing (Alpha)
|
||||
|
||||
Turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules -- compiled from Rust, packaged in signed RVF containers.
|
||||
@@ -254,9 +279,10 @@ Find your serial port: `COM7` on Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.SLAB
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### Serial Monitor
|
||||
@@ -285,7 +311,7 @@ All settings can be changed at runtime via Non-Volatile Storage (NVS) without re
|
||||
The easiest way to write NVS settings:
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "MyPassword" \
|
||||
--target-ip 192.168.1.20
|
||||
|
||||
@@ -11,7 +11,26 @@ set(SRCS
|
||||
"adaptive_controller.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
# ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps).
|
||||
set(REQUIRES
|
||||
esp_wifi
|
||||
esp_netif
|
||||
esp_event
|
||||
nvs_flash
|
||||
app_update
|
||||
esp_http_server
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
esp_driver_spi
|
||||
esp_driver_i2c
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
)
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
@@ -21,7 +40,11 @@ endif()
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
if(CONFIG_DISPLAY_ENABLE)
|
||||
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
|
||||
set(REQUIRES esp_lcd esp_lcd_touch lvgl)
|
||||
list(APPEND REQUIRES esp_lcd esp_lcd_touch lvgl)
|
||||
endif()
|
||||
|
||||
if(CONFIG_WASM_ENABLE)
|
||||
list(APPEND REQUIRES wasm3)
|
||||
endif()
|
||||
|
||||
idf_component_register(
|
||||
|
||||
@@ -371,6 +371,30 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)");
|
||||
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
|
||||
* (bitfields), not the legacy 802.11n bool layout used on ESP32-S3. */
|
||||
wifi_csi_config_t csi_config;
|
||||
memset(&csi_config, 0, sizeof(csi_config));
|
||||
csi_config.enable = 1U;
|
||||
csi_config.acquire_csi_legacy = 1U;
|
||||
csi_config.acquire_csi_ht20 = 1U;
|
||||
csi_config.acquire_csi_ht40 = 1U;
|
||||
csi_config.acquire_csi_su = 1U;
|
||||
csi_config.acquire_csi_mu = 1U;
|
||||
csi_config.acquire_csi_dcm = 1U;
|
||||
csi_config.acquire_csi_beamformed = 1U;
|
||||
#if CONFIG_SOC_WIFI_MAC_VERSION_NUM >= 3
|
||||
csi_config.acquire_csi_force_lltf = 1U;
|
||||
csi_config.acquire_csi_vht = 1U;
|
||||
csi_config.acquire_csi_he_stbc_mode = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
|
||||
csi_config.val_scale_cfg = 0U;
|
||||
#else
|
||||
csi_config.acquire_csi_he_stbc = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
|
||||
csi_config.val_scale_cfg = 0U;
|
||||
#endif
|
||||
csi_config.dump_ack_en = 0U;
|
||||
#else
|
||||
wifi_csi_config_t csi_config = {
|
||||
.lltf_en = true,
|
||||
.htltf_en = true,
|
||||
@@ -380,6 +404,7 @@ void csi_collector_init(void)
|
||||
.manu_scale = false,
|
||||
.shift = false,
|
||||
};
|
||||
#endif
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file edge_processing.c
|
||||
* @brief ADR-039 Edge Intelligence — dual-core CSI processing pipeline.
|
||||
*
|
||||
* Core 0 (WiFi task): Pushes raw CSI frames into lock-free SPSC ring buffer.
|
||||
* Core 1 (DSP task): Pops frames, runs signal processing pipeline:
|
||||
* Core 0 (WiFi path): Pushes raw CSI frames into lock-free SPSC ring buffer.
|
||||
* Second core when present (DSP task): pops frames, runs signal processing pipeline.
|
||||
* On unicore targets (e.g. ESP32-C6), the DSP task is pinned to core 0.
|
||||
* 1. Phase extraction from I/Q pairs
|
||||
* 2. Phase unwrapping (continuous phase)
|
||||
* 3. Welford variance tracking per subcarrier
|
||||
@@ -1050,7 +1051,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Start DSP task on Core 1. */
|
||||
/* Pin DSP off WiFi's preferred core when SMP; else core 0 only (ESP32-C6). */
|
||||
const BaseType_t dsp_core = (portNUM_PROCESSORS > 1) ? (BaseType_t)1 : (BaseType_t)0;
|
||||
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
edge_task,
|
||||
"edge_dsp",
|
||||
@@ -1058,14 +1061,14 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
NULL,
|
||||
5, /* Priority 5 — above idle, below WiFi. */
|
||||
NULL,
|
||||
1 /* Pin to Core 1. */
|
||||
);
|
||||
dsp_core);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create edge DSP task");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Edge DSP task created on Core 1 (stack=8192, priority=5)");
|
||||
ESP_LOGI(TAG, "Edge DSP task created on core %d (stack=8192, priority=5)",
|
||||
(int)dsp_core);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "psa/crypto.h"
|
||||
|
||||
static const char *TAG = "rvf";
|
||||
|
||||
@@ -125,9 +125,13 @@ esp_err_t rvf_parse(const uint8_t *data, uint32_t data_len, rvf_parsed_t *out)
|
||||
|
||||
/* ---- Verify build hash (SHA-256 of WASM payload) ---- */
|
||||
uint8_t computed_hash[32];
|
||||
int ret = mbedtls_sha256(wasm_data, hdr->wasm_len, computed_hash, 0);
|
||||
if (ret != 0) {
|
||||
ESP_LOGE(TAG, "SHA-256 computation failed: %d", ret);
|
||||
size_t hash_len = 0;
|
||||
psa_status_t psa_st = psa_hash_compute(PSA_ALG_SHA_256, wasm_data,
|
||||
hdr->wasm_len, computed_hash,
|
||||
sizeof(computed_hash), &hash_len);
|
||||
if (psa_st != PSA_SUCCESS || hash_len != 32) {
|
||||
ESP_LOGE(TAG, "SHA-256 computation failed: psa=%d len=%u",
|
||||
(int)psa_st, (unsigned)hash_len);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -186,8 +190,7 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
/*
|
||||
* Ed25519 verification.
|
||||
*
|
||||
* ESP-IDF v5.2 mbedtls does NOT include Ed25519 (Curve25519 is
|
||||
* for ECDH/X25519 only). We use a SHA-256-HMAC integrity check:
|
||||
* Legacy mbedtls Ed25519 is optional. We use a SHA-256 keyed digest:
|
||||
*
|
||||
* expected = SHA-256(pubkey || signed_region)
|
||||
*
|
||||
@@ -196,35 +199,34 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
* pubkey produces a different expected hash, so unauthorized
|
||||
* publishers cannot forge a valid signature.
|
||||
*
|
||||
* For full Ed25519 (NaCl-style), enable CONFIG_MBEDTLS_EDDSA_C
|
||||
* or link TweetNaCl. The RVF builder should match this scheme.
|
||||
* For full Ed25519, enable CONFIG_MBEDTLS_EDDSA_C or equivalent.
|
||||
* The RVF builder should match this scheme.
|
||||
*/
|
||||
uint8_t hash_input_prefix[32];
|
||||
memcpy(hash_input_prefix, pubkey, 32);
|
||||
|
||||
/* Compute SHA-256(pubkey || header+manifest+wasm). */
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
int ret = mbedtls_sha256_starts(&ctx, 0);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
/* Compute SHA-256(pubkey || header+manifest+wasm) via PSA Crypto. */
|
||||
psa_hash_operation_t op = PSA_HASH_OPERATION_INIT;
|
||||
psa_status_t st = psa_hash_setup(&op, PSA_ALG_SHA_256);
|
||||
if (st != PSA_SUCCESS) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, hash_input_prefix, 32);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
st = psa_hash_update(&op, hash_input_prefix, 32);
|
||||
if (st != PSA_SUCCESS) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, data, signed_len);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
st = psa_hash_update(&op, data, signed_len);
|
||||
if (st != PSA_SUCCESS) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint8_t expected[32];
|
||||
ret = mbedtls_sha256_finish(&ctx, expected);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
if (ret != 0) {
|
||||
size_t out_len = 0;
|
||||
st = psa_hash_finish(&op, expected, sizeof(expected), &out_len);
|
||||
if (st != PSA_SUCCESS || out_len != 32) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32-S3 CSI Node Provisioning Script
|
||||
ESP32 CSI node provisioning (ESP32-S3, ESP32-C6, other targets).
|
||||
|
||||
Writes WiFi credentials and aggregator target to the ESP32's NVS partition
|
||||
so users can configure a pre-built firmware binary without recompiling.
|
||||
|
||||
Usage:
|
||||
python provision.py --port COM7 --ssid "MyWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
python provision.py --port /dev/ttyUSB0 --chip esp32c6 --ssid "..." \\
|
||||
--password "..." --target-ip 192.168.1.20
|
||||
|
||||
Requirements:
|
||||
pip install 'esptool>=5.0' nvs-partition-gen
|
||||
@@ -35,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()
|
||||
@@ -143,7 +178,7 @@ def generate_nvs_binary(csv_content, size):
|
||||
os.unlink(p)
|
||||
|
||||
|
||||
def flash_nvs(port, baud, nvs_bin):
|
||||
def flash_nvs(port, baud, nvs_bin, chip):
|
||||
"""Flash the NVS partition binary to the ESP32."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
|
||||
f.write(nvs_bin)
|
||||
@@ -152,16 +187,13 @@ def flash_nvs(port, baud, nvs_bin):
|
||||
try:
|
||||
cmd = [
|
||||
sys.executable, "-m", "esptool",
|
||||
"--chip", "esp32s3",
|
||||
"--chip", chip,
|
||||
"--port", port,
|
||||
"--baud", str(baud),
|
||||
# Keep underscore form — ESP-IDF v5.4 bundles esptool 4.10.0 which only
|
||||
# accepts "write_flash". pip's esptool >=5.x accepts both (hyphenated
|
||||
# form preferred) but keeps underscore working. Do not "correct" this.
|
||||
"write_flash",
|
||||
"write-flash",
|
||||
hex(NVS_PARTITION_OFFSET), bin_path,
|
||||
]
|
||||
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port}...")
|
||||
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port} (chip={chip})...")
|
||||
subprocess.check_call(cmd)
|
||||
print("NVS provisioning complete!")
|
||||
finally:
|
||||
@@ -170,10 +202,20 @@ def flash_nvs(port, baud, nvs_bin):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Provision ESP32-S3 CSI Node with WiFi and aggregator settings",
|
||||
epilog="Example: python provision.py --port COM7 --ssid MyWiFi --password secret --target-ip 192.168.1.20",
|
||||
description="Provision CSI node NVS (WiFi + aggregator); works on S3, C6, etc.",
|
||||
epilog=(
|
||||
"Example: python provision.py --port COM7 --ssid MyWiFi --password secret "
|
||||
"--target-ip 192.168.1.20\n"
|
||||
"ESP32-C6: same, or pass --chip esp32c6 if auto-detect fails "
|
||||
"(default chip is auto for esptool v5+)."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--port", required=True, help="Serial port (e.g. COM7, /dev/ttyUSB0)")
|
||||
parser.add_argument(
|
||||
"--chip",
|
||||
default="auto",
|
||||
help="esptool target: auto (default), esp32s3, esp32c6, ... (must match connected chip)",
|
||||
)
|
||||
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
|
||||
parser.add_argument("--ssid", help="WiFi SSID")
|
||||
parser.add_argument("--password", help="WiFi password")
|
||||
@@ -214,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.
|
||||
@@ -281,7 +313,7 @@ def main():
|
||||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
@@ -337,11 +369,11 @@ def main():
|
||||
with open(out, "wb") as f:
|
||||
f.write(nvs_bin)
|
||||
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
|
||||
print(f"Flash manually: python -m esptool --chip esp32s3 --port {args.port} "
|
||||
print(f"Flash manually: python -m esptool --chip {args.chip} --port {args.port} "
|
||||
f"write-flash 0x9000 {out}")
|
||||
return
|
||||
|
||||
flash_nvs(args.port, args.baud, nvs_bin)
|
||||
flash_nvs(args.port, args.baud, nvs_bin, args.chip)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,13 @@ typedef struct {
|
||||
uint8_t primary;
|
||||
} wifi_ap_record_t;
|
||||
|
||||
typedef enum {
|
||||
WIFI_PS_NONE = 0,
|
||||
WIFI_PS_MIN_MODEM = 1,
|
||||
WIFI_PS_MAX_MODEM = 2,
|
||||
} wifi_ps_type_t;
|
||||
|
||||
static inline esp_err_t esp_wifi_set_ps(wifi_ps_type_t type) { (void)type; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous(bool en) { (void)en; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_rx_cb(void *cb) { (void)cb; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_filter(wifi_promiscuous_filter_t *f) { (void)f; return ESP_OK; }
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
# ESP32-S3 Hello World — Capability Discovery
|
||||
# ESP32 Hello World — Capability Discovery (S3 / C6 targets)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief ESP32-S3 Hello World — Full Capability Discovery
|
||||
* @brief ESP32 Hello World — Full Capability Discovery
|
||||
*
|
||||
* Boots up, prints "Hello World!", then probes and reports every major
|
||||
* hardware/software capability of the ESP32-S3: chip info, flash, PSRAM,
|
||||
* WiFi (including CSI), Bluetooth, GPIOs, peripherals, FreeRTOS stats,
|
||||
* and power management features. No WiFi connection required.
|
||||
* Boots up, prints "Hello World!", then probes chip info, flash, PSRAM,
|
||||
* WiFi (including CSI where enabled), 802.15.4/BLE on C6, GPIOs,
|
||||
* peripherals, FreeRTOS stats, and power management. No WiFi connection
|
||||
* required. Supports ESP32-S3 and ESP32-C6 (set IDF target accordingly).
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -18,7 +18,6 @@
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_flash.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_timer.h"
|
||||
@@ -33,7 +32,24 @@
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "hello";
|
||||
/*
|
||||
* Peripheral counts: ESP-IDF v6+ dropped some SOC_* macros; values below
|
||||
* match each target's HAL (esp_hal_* *_ll.h) where applicable.
|
||||
*/
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
#define PROBE_I2S_CTRL_NUM 2
|
||||
#define PROBE_RMT_CHAN_NUM 8
|
||||
#define PROBE_MCPWM_GROUPS 2
|
||||
#define PROBE_PCNT_UNITS 4
|
||||
#define PROBE_TOUCH_CHAN_NUM ((int)(SOC_TOUCH_MAX_CHAN_ID - SOC_TOUCH_MIN_CHAN_ID + 1))
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
#define PROBE_I2S_CTRL_NUM 1
|
||||
#define PROBE_RMT_CHAN_NUM 4
|
||||
#define PROBE_MCPWM_GROUPS 1
|
||||
#define PROBE_PCNT_UNITS 4
|
||||
#else
|
||||
#error "hello-world: add PROBE_* peripheral counts for this IDF target in main.c"
|
||||
#endif
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,6 +62,7 @@ static const char *chip_model_str(esp_chip_model_t model)
|
||||
case CHIP_ESP32C3: return "ESP32-C3";
|
||||
case CHIP_ESP32H2: return "ESP32-H2";
|
||||
case CHIP_ESP32C2: return "ESP32-C2";
|
||||
case CHIP_ESP32C6: return "ESP32-C6";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -168,7 +185,11 @@ static void probe_wifi_capabilities(void)
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
/* Protocol capabilities */
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Protocols: 802.11 b/g/n/ax (Wi-Fi 6, 2.4 GHz)\n");
|
||||
#else
|
||||
printf(" Protocols: 802.11 b/g/n\n");
|
||||
#endif
|
||||
|
||||
/* CSI (Channel State Information) */
|
||||
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
|
||||
@@ -246,7 +267,7 @@ static void probe_bluetooth(void)
|
||||
esp_chip_info(&info);
|
||||
|
||||
if (info.features & CHIP_FEATURE_BLE) {
|
||||
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
|
||||
printf(" BLE: Supported (Bluetooth LE)\n");
|
||||
printf(" - GATT Server/Client\n");
|
||||
printf(" - Advertising & Scanning\n");
|
||||
printf(" - Mesh Networking\n");
|
||||
@@ -256,10 +277,16 @@ static void probe_bluetooth(void)
|
||||
printf(" BLE: Not supported on this chip\n");
|
||||
}
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
if (info.features & CHIP_FEATURE_IEEE802154) {
|
||||
printf(" 802.15.4: Supported (Thread / Zigbee style MAC)\n");
|
||||
}
|
||||
#endif
|
||||
|
||||
if (info.features & CHIP_FEATURE_BT) {
|
||||
printf(" BT Classic: Supported (A2DP, SPP, HFP)\n");
|
||||
} else {
|
||||
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
|
||||
printf(" BT Classic: Not available (BLE-only on this chip)\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,24 +296,52 @@ static void probe_peripherals(void)
|
||||
|
||||
printf(" GPIOs: %d total\n", SOC_GPIO_PIN_COUNT);
|
||||
printf(" ADC:\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" - SAR ADC: %d channels (12-bit, one controller)\n",
|
||||
(int)SOC_ADC_CHANNEL_NUM(0));
|
||||
#else
|
||||
printf(" - ADC1: %d channels (12-bit SAR)\n", SOC_ADC_CHANNEL_NUM(0));
|
||||
printf(" - ADC2: %d channels (shared with WiFi)\n", SOC_ADC_CHANNEL_NUM(1));
|
||||
printf(" DAC: Not available on ESP32-S3\n");
|
||||
printf(" Touch Sensors: %d channels (capacitive)\n", SOC_TOUCH_SENSOR_NUM);
|
||||
printf(" SPI: %d controllers (SPI2/SPI3 for user)\n", SOC_SPI_PERIPH_NUM);
|
||||
printf(" I2C: %d controllers\n", SOC_I2C_NUM);
|
||||
printf(" I2S: %d controllers (audio/PDM/TDM)\n", SOC_I2S_NUM);
|
||||
printf(" UART: %d controllers\n", SOC_UART_NUM);
|
||||
#endif
|
||||
printf(" DAC: Not available on this chip\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" Touch Sensors: %d channels (capacitive)\n", PROBE_TOUCH_CHAN_NUM);
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Touch Sensors: Not available (no capacitive touch on ESP32-C6)\n");
|
||||
#endif
|
||||
printf(" SPI: %d controllers\n", SOC_SPI_PERIPH_NUM);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" (SPI2/SPI3 typical for user apps)\n");
|
||||
#endif
|
||||
printf(" I2C: %d controllers\n", (int)SOC_I2C_NUM);
|
||||
printf(" I2S: %d controller(s) (audio/PDM/TDM)\n", PROBE_I2S_CTRL_NUM);
|
||||
printf(" UART: %d controllers\n", (int)SOC_UART_NUM);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" USB: USB-OTG 1.1 (Host & Device)\n");
|
||||
printf(" USB-Serial: Built-in USB-JTAG/Serial (this console)\n");
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" USB: No native USB-OTG (use SPI/USB bridge or off-chip PHY)\n");
|
||||
printf(" USB-Serial: Built-in USB Serial/JTAG (this console)\n");
|
||||
#endif
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" TWAI (CAN): 1 controller (CAN 2.0B compatible)\n");
|
||||
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" TWAI (CAN): %d controller(s) (CAN 2.0B compatible)\n",
|
||||
(int)SOC_TWAI_CONTROLLER_NUM);
|
||||
#endif
|
||||
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", PROBE_RMT_CHAN_NUM);
|
||||
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
|
||||
printf(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
|
||||
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
|
||||
printf(" MCPWM: %d group(s) (motor control)\n", PROBE_MCPWM_GROUPS);
|
||||
printf(" PCNT: %d units (pulse counter / encoder)\n", PROBE_PCNT_UNITS);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" LCD: Parallel 8/16-bit + SPI + I2C interfaces\n");
|
||||
printf(" Camera: DVP 8/16-bit parallel interface\n");
|
||||
printf(" SDMMC: SD/MMC host controller (1-bit / 4-bit)\n");
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" PARLIO: Parallel TX/RX (e.g. LED matrix / custom buses)\n");
|
||||
printf(" Camera: SPI / external bridge (no native DVP)\n");
|
||||
printf(" SDIO: SDIO slave peripheral (see TRM for capabilities)\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
static void probe_security(void)
|
||||
@@ -309,17 +364,29 @@ static void probe_power(void)
|
||||
{
|
||||
print_separator("POWER MANAGEMENT");
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Clock Modes:\n");
|
||||
printf(" - 160 MHz (max CPU on ESP32-C6)\n");
|
||||
printf(" - 120 MHz (balanced)\n");
|
||||
printf(" - 80 MHz (low power)\n");
|
||||
#else
|
||||
printf(" Clock Modes:\n");
|
||||
printf(" - 240 MHz (max performance)\n");
|
||||
printf(" - 160 MHz (balanced)\n");
|
||||
printf(" - 80 MHz (low power)\n");
|
||||
#endif
|
||||
printf(" Sleep Modes:\n");
|
||||
printf(" - Modem Sleep (WiFi off, CPU active)\n");
|
||||
printf(" - Light Sleep (CPU paused, fast wake)\n");
|
||||
printf(" - Deep Sleep (RTC only, ~10 uA)\n");
|
||||
printf(" - Hibernation (RTC timer only, ~5 uA)\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Wake Sources: GPIO, LP timer, UART, etc.\n");
|
||||
printf(" LP domain: LP core / LP peripherals (see TRM)\n");
|
||||
#else
|
||||
printf(" Wake Sources: GPIO, timer, touch, ULP, UART\n");
|
||||
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
|
||||
printf(" ULP Coprocessor: FSM (runs in deep sleep)\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
static void probe_temperature(void)
|
||||
@@ -389,6 +456,9 @@ static void probe_csi_details(void)
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip;
|
||||
esp_chip_info(&chip);
|
||||
|
||||
/* NVS required for WiFi */
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
@@ -401,7 +471,7 @@ void app_main(void)
|
||||
printf("\n");
|
||||
printf(" ╭─────────────────────────────────────────────────╮\n");
|
||||
printf(" │ │\n");
|
||||
printf(" │ HELLO WORLD from ESP32-S3! │\n");
|
||||
printf(" │ HELLO WORLD from %-24s │\n", chip_model_str(chip.model));
|
||||
printf(" │ │\n");
|
||||
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
|
||||
printf(" │ │\n");
|
||||
@@ -422,8 +492,9 @@ void app_main(void)
|
||||
probe_csi_details();
|
||||
|
||||
print_separator("DONE — ALL CAPABILITIES REPORTED");
|
||||
printf("\n This ESP32-S3 is ready for WiFi-DensePose!\n");
|
||||
printf(" Flash the full firmware (esp32-csi-node) to begin CSI sensing.\n\n");
|
||||
printf("\n This %s is ready for WiFi-DensePose experiments.\n",
|
||||
chip_model_str(chip.model));
|
||||
printf(" For production CSI on S3, flash esp32-csi-node; C6 path may differ.\n\n");
|
||||
|
||||
/* Keep alive — blink a status message every 10 seconds */
|
||||
int tick = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ESP32-S3 Hello World — SDK Configuration
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
# ESP32 Hello World — SDK Configuration (default: ESP32-C6)
|
||||
CONFIG_IDF_TARGET="esp32c6"
|
||||
|
||||
# Flash: 4MB (this chip has Embedded Flash 4MB)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-mock>=3.10.0
|
||||
pytest-benchmark>=4.0.0
|
||||
pytest-benchmark>=5.2.3
|
||||
|
||||
# Linting and formatting
|
||||
black>=23.0.0
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ pydantic-settings>=2.0.0
|
||||
# Database dependencies
|
||||
sqlalchemy>=2.0.0
|
||||
asyncpg>=0.28.0
|
||||
aiosqlite>=0.19.0
|
||||
aiosqlite>=0.22.1
|
||||
redis>=4.5.0
|
||||
|
||||
# CLI dependencies
|
||||
@@ -26,8 +26,8 @@ click>=8.0.0
|
||||
alembic>=1.10.0
|
||||
|
||||
# Hardware interface dependencies
|
||||
asyncio-mqtt>=0.11.0
|
||||
aiohttp>=3.8.0
|
||||
asyncio-mqtt>=0.16.2
|
||||
aiohttp>=3.13.5
|
||||
paramiko>=3.0.0
|
||||
|
||||
# Data processing dependencies
|
||||
|
||||
@@ -110,6 +110,109 @@
|
||||
"require": ["VERIFY.sh", "witness-bundle"],
|
||||
"rationale": "scripts/generate-witness-bundle.sh produces the self-contained, recipient-verifiable witness bundle (witness log + proof + test results + firmware hashes + VERIFY.sh). Part of the ADR-028 attestation chain.",
|
||||
"ref": "docs/WITNESS-LOG-028.md"
|
||||
},
|
||||
{
|
||||
"id": "RuView#559",
|
||||
"title": "./verify wrapper points at archive/v1/ paths (post-v1-archive layout)",
|
||||
"files": ["verify"],
|
||||
"require": ["${SCRIPT_DIR}/archive/v1/data/proof", "${SCRIPT_DIR}/archive/v1/src"],
|
||||
"rationale": "After v1 moved to archive/v1, the ./verify wrapper still pointed at the removed v1/ paths and failed before reaching verify.py on a fresh clone. Reverting to the un-prefixed paths reintroduces the FAIL-before-pipeline regression that #559 reported.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/559"
|
||||
},
|
||||
{
|
||||
"id": "RuView#561",
|
||||
"title": "ESP32 CSI firmware README documents the correct flash offsets (app at 0x20000, ota_data at 0xf000)",
|
||||
"files": ["firmware/esp32-csi-node/README.md"],
|
||||
"require": [
|
||||
"0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin",
|
||||
"0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin",
|
||||
"firmware/esp32-csi-node/provision.py"
|
||||
],
|
||||
"forbid": [
|
||||
"/0x10000 firmware\\/esp32-csi-node\\/build\\/esp32-csi-node\\.bin/",
|
||||
"/python scripts\\/provision\\.py/"
|
||||
],
|
||||
"rationale": "Partition tables (partitions_display.csv, partitions_4mb.csv) put ota_0 at 0x20000. The README previously said 0x10000 and pointed at scripts/provision.py (an older copy). Reverting causes first-time users to misflash and miss WiFi provisioning.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/561"
|
||||
},
|
||||
{
|
||||
"id": "RuView#588-SEC020",
|
||||
"title": "provision.py prints a fixed (set)/(empty) marker, not a length-leaking asterisk run",
|
||||
"files": ["scripts/provision.py", "firmware/esp32-csi-node/provision.py"],
|
||||
"require": ["(set)' if args.password else '(empty)"],
|
||||
"forbid": ["/'\\*' \\* len\\(args\\.password\\)/"],
|
||||
"rationale": "Both provision.py scripts previously printed '*' * len(args.password), masking the value but leaking the password length. Flagged as SEC020 by Repobility. Fix replaces with a fixed (set)/(empty) marker.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/588"
|
||||
},
|
||||
{
|
||||
"id": "RuView#593",
|
||||
"title": "vital_signs.rs uses circular variance for wrapped atan2 phase values",
|
||||
"files": ["v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs"],
|
||||
"require": [
|
||||
"phase_circular_variance",
|
||||
"standard circular variance (1 - mean resultant length)",
|
||||
"test_phase_variance_handles_wraparound"
|
||||
],
|
||||
"rationale": "Phases come from atan2 and are wrapped to (-pi, pi]. The original linear mean/variance treated two phases straddling +/-pi (physically ~0 rad apart) as ~2*pi apart, producing variance ~pi^2 instead of ~1e-6 and feeding that noise straight into the heart-rate FFT buffer. Caused jumpy vitals in #519 and +/-15 BPM jitter in #485.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/593"
|
||||
},
|
||||
{
|
||||
"id": "RuView#590-fuzz-stub",
|
||||
"title": "Fuzz host stubs declare WIFI_PS_NONE / wifi_ps_type_t / esp_wifi_set_ps()",
|
||||
"files": ["firmware/esp32-csi-node/test/stubs/esp_stubs.h"],
|
||||
"require": ["wifi_ps_type_t", "WIFI_PS_NONE", "esp_wifi_set_ps"],
|
||||
"rationale": "csi_collector.c:346 calls esp_wifi_set_ps(WIFI_PS_NONE) per the RuView#521 fix. The host-native fuzz target compiles csi_collector.c against test/stubs/esp_stubs.h; missing these symbols red-greens the Fuzz Testing (ADR-061 Layer 6) job. Was red on main for ~5 weeks before PR #590.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/590"
|
||||
},
|
||||
{
|
||||
"id": "RuView#590-swarm-test",
|
||||
"title": "QEMU swarm test passes --force-partial to provision.py for per-node overlays",
|
||||
"files": ["scripts/qemu_swarm.py"],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Platform probe: reproduce verify.py's hash-relevant FFT steps in isolation.
|
||||
|
||||
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 — 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.
|
||||
|
||||
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
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import scipy.fft
|
||||
import scipy.signal
|
||||
|
||||
# Deterministic synthetic input -- no IO, no .env, no Settings
|
||||
rng = np.random.RandomState(42)
|
||||
N_FRAMES = 100
|
||||
N_SUBC = 100
|
||||
amp = rng.randn(N_FRAMES, N_SUBC).astype(np.float64)
|
||||
|
||||
# Mirror the three scipy calls verify.py's hash depends on:
|
||||
# archive/v1/src/core/csi_processor.py:349 -> scipy.signal.windows.hamming
|
||||
# archive/v1/src/core/csi_processor.py:426 -> scipy.fft.fft(mean_phase_diff, n=64)
|
||||
# archive/v1/src/core/csi_processor.py:438 -> scipy.fft.fft(amp.flatten(), n=128)
|
||||
mean_phase_diff = amp.mean(axis=1)
|
||||
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)
|
||||
|
||||
# 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")
|
||||
except Exception:
|
||||
blas_info = {"error": "show_config(mode=dicts) unavailable"}
|
||||
|
||||
print(json.dumps({
|
||||
"uname": platform.uname()._asdict(),
|
||||
"python": sys.version.split()[0],
|
||||
"numpy": np.__version__,
|
||||
"scipy": __import__("scipy").__version__,
|
||||
"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),
|
||||
}, indent=2, default=str))
|
||||
@@ -213,7 +213,7 @@ def main():
|
||||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
|
||||
@@ -259,11 +259,16 @@ def provision_node(
|
||||
if stale.exists():
|
||||
stale.unlink()
|
||||
|
||||
# Build provision.py arguments
|
||||
# Build provision.py arguments.
|
||||
# --force-partial: this is a per-node TDM/channel overlay; WiFi
|
||||
# credentials live in the base flash image, not the per-node NVS slice.
|
||||
# Without --force-partial, provision.py rejects calls missing the
|
||||
# --ssid/--password/--target-ip trio (issue #391 guard).
|
||||
args = [
|
||||
sys.executable, str(PROVISION_SCRIPT),
|
||||
"--port", "/dev/null",
|
||||
"--dry-run",
|
||||
"--force-partial",
|
||||
"--node-id", str(node.node_id),
|
||||
"--tdm-slot", str(node.tdm_slot),
|
||||
"--tdm-total", str(n_total),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UDP relay for Docker Desktop on Windows (issue #374, #386).
|
||||
|
||||
Docker Desktop on Windows multiplexes inbound UDP from multiple source IPs to
|
||||
a single source IP inside the container, which causes packets from all but one
|
||||
ESP32 node to be silently dropped at the WSL/Hyper-V boundary.
|
||||
|
||||
This relay listens on the host, then re-emits each datagram from its own
|
||||
single socket back to a localhost port that Docker forwards into the
|
||||
container. Because every forwarded datagram now has the same source IP/port
|
||||
(the relay's loopback socket), Docker passes them all through.
|
||||
|
||||
Usage:
|
||||
# Default: listen on host:5005, forward to 127.0.0.1:5006
|
||||
# Container should be started with -p 5006:5005/udp.
|
||||
python scripts/udp-relay.py
|
||||
|
||||
# Custom ports
|
||||
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
|
||||
# Verbose (one line per packet)
|
||||
python scripts/udp-relay.py --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def run_relay(listen_host: str, listen_port: int, forward_host: str,
|
||||
forward_port: int, stats_interval: float, verbose: bool) -> int:
|
||||
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
rx.bind((listen_host, listen_port))
|
||||
except OSError as e:
|
||||
print(f"udp-relay: failed to bind {listen_host}:{listen_port}: {e}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
forward_addr = (forward_host, forward_port)
|
||||
|
||||
print(f"udp-relay: listening on {listen_host}:{listen_port} "
|
||||
f"-> forwarding to {forward_host}:{forward_port}")
|
||||
print("udp-relay: collapses multi-source UDP to a single loopback source "
|
||||
"so Docker Desktop on Windows forwards every packet (issue #374).")
|
||||
|
||||
sources: dict[tuple[str, int], int] = {}
|
||||
total = 0
|
||||
last_stats = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
data, src = rx.recvfrom(65535)
|
||||
tx.sendto(data, forward_addr)
|
||||
total += 1
|
||||
sources[src] = sources.get(src, 0) + 1
|
||||
|
||||
if verbose:
|
||||
print(f"udp-relay: {src[0]}:{src[1]} -> "
|
||||
f"{forward_host}:{forward_port} ({len(data)}B)")
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_stats >= stats_interval:
|
||||
print(f"udp-relay: forwarded {total} pkts from "
|
||||
f"{len(sources)} sources in last {stats_interval:.0f}s")
|
||||
sources.clear()
|
||||
total = 0
|
||||
last_stats = now
|
||||
except KeyboardInterrupt:
|
||||
print("udp-relay: stopping")
|
||||
return 0
|
||||
finally:
|
||||
rx.close()
|
||||
tx.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--listen-host", default="0.0.0.0",
|
||||
help="Host interface to bind (default: 0.0.0.0)")
|
||||
p.add_argument("--listen-port", type=int, default=5005,
|
||||
help="Port the ESP32 nodes send to (default: 5005)")
|
||||
p.add_argument("--forward-host", default="127.0.0.1",
|
||||
help="Where to forward packets (default: 127.0.0.1)")
|
||||
p.add_argument("--forward-port", type=int, default=5006,
|
||||
help="Port Docker maps into the container (default: 5006)")
|
||||
p.add_argument("--stats-interval", type=float, default=10.0,
|
||||
help="Seconds between stats lines (default: 10)")
|
||||
p.add_argument("--verbose", action="store_true",
|
||||
help="Log every forwarded packet")
|
||||
args = p.parse_args()
|
||||
|
||||
return run_relay(args.listen_host, args.listen_port, args.forward_host,
|
||||
args.forward_port, args.stats_interval, args.verbose)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,15 +13,15 @@
|
||||
"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-native": "0.83.2",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "0.85.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -9,11 +9,25 @@
|
||||
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
|
||||
*/
|
||||
|
||||
// Derive WebSocket URL from the page origin so it works on any port.
|
||||
// The /ws/sensing endpoint is available on the same HTTP port (3000).
|
||||
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
|
||||
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
|
||||
const SENSING_WS_PORT_BY_HTTP_PORT = {
|
||||
// Docker image: HTTP UI/API on 3000, sensing stream on 3001.
|
||||
'3000': '3001',
|
||||
// Python sensing stack: UI on 8080, sensing stream on 8765.
|
||||
'8080': '8765',
|
||||
};
|
||||
|
||||
export function buildSensingWsUrl(locationLike = (typeof window !== 'undefined' ? window.location : null)) {
|
||||
const protocol = locationLike && locationLike.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = locationLike && locationLike.host ? locationLike.host : 'localhost:3001';
|
||||
const hostname = locationLike && locationLike.hostname ? locationLike.hostname : host.split(':')[0];
|
||||
const port = locationLike && locationLike.port ? locationLike.port : '';
|
||||
const wsPort = SENSING_WS_PORT_BY_HTTP_PORT[port];
|
||||
const wsHost = wsPort ? `${hostname}:${wsPort}` : host;
|
||||
|
||||
return `${protocol}//${wsHost}/ws/sensing`;
|
||||
}
|
||||
|
||||
const SENSING_WS_URL = buildSensingWsUrl();
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
// Number of failed attempts that must occur before simulation starts.
|
||||
|
||||
@@ -136,9 +136,22 @@ export class WebSocketService {
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
setupEventHandlers(url, ws, handlers) {
|
||||
const connection = this.connections.get(url);
|
||||
const getConnection = (eventName) => {
|
||||
const connection = this.connections.get(url);
|
||||
if (!connection) {
|
||||
this.logger.warn(`Ignoring WebSocket ${eventName} for unregistered connection`, {
|
||||
url,
|
||||
readyState: ws.readyState
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return connection;
|
||||
};
|
||||
|
||||
ws.onopen = (event) => {
|
||||
const connection = getConnection('open');
|
||||
if (!connection) return;
|
||||
|
||||
const connectionTime = Date.now() - connection.connectionStartTime;
|
||||
this.logger.info(`WebSocket connected successfully`, { url, connectionTime });
|
||||
|
||||
@@ -158,6 +171,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const connection = getConnection('message');
|
||||
if (!connection) return;
|
||||
|
||||
connection.lastActivity = Date.now();
|
||||
connection.messageCount++;
|
||||
|
||||
@@ -188,6 +204,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
const connection = getConnection('error');
|
||||
if (!connection) return;
|
||||
|
||||
connection.errorCount++;
|
||||
this.logger.error(`WebSocket error occurred`, {
|
||||
url,
|
||||
@@ -208,6 +227,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const connection = getConnection('close');
|
||||
if (!connection) return;
|
||||
|
||||
const { code, reason, wasClean } = event;
|
||||
this.logger.info(`WebSocket closed`, { url, code, reason, wasClean });
|
||||
|
||||
@@ -607,4 +629,4 @@ export class WebSocketService {
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const wsService = new WebSocketService();
|
||||
export const wsService = new WebSocketService();
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { API_CONFIG, buildApiUrl, buildWsUrl } from '../config/api.config.js';
|
||||
import { apiService } from '../services/api.service.js';
|
||||
import { wsService } from '../services/websocket.service.js';
|
||||
import { buildSensingWsUrl } from '../services/sensing.service.js';
|
||||
import { poseService } from '../services/pose.service.js';
|
||||
import { healthService } from '../services/health.service.js';
|
||||
import { TabManager } from '../components/TabManager.js';
|
||||
@@ -232,6 +233,17 @@ testRunner.test('buildWsUrl constructs WebSocket URLs', 'apiConfig', () => {
|
||||
testRunner.assert(url.includes('token=test-token'), 'URL should contain token parameter');
|
||||
});
|
||||
|
||||
testRunner.test('buildSensingWsUrl maps Docker UI port to sensing WebSocket port', 'apiConfig', () => {
|
||||
const url = buildSensingWsUrl({
|
||||
protocol: 'http:',
|
||||
host: '192.168.28.147:3000',
|
||||
hostname: '192.168.28.147',
|
||||
port: '3000',
|
||||
});
|
||||
|
||||
testRunner.assertEqual(url, 'ws://192.168.28.147:3001/ws/sensing');
|
||||
});
|
||||
|
||||
// API Service Tests
|
||||
testRunner.test('apiService has required methods', 'apiService', () => {
|
||||
testRunner.assert(typeof apiService.get === 'function', 'get method should exist');
|
||||
@@ -473,4 +485,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
testRunner.updateSummary();
|
||||
});
|
||||
|
||||
export { testRunner };
|
||||
export { testRunner };
|
||||
|
||||
@@ -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();
|
||||