Compare commits

..

3 Commits

Author SHA1 Message Date
ruv 84638314a4 fix(docker): bump rust 1.85 -> 1.90 + enforce LF on shell scripts
Two real bugs found while pushing the v0.8.0 image to Docker Hub:

## Rust 1.85 -> 1.90

`hnsw_rs 0.3.4` (transitive via wifi-densepose-ruvector ->
ruvector-attn-mincut -> hnsw_rs) calls `nbp.is_multiple_of(500_000)`.
`is_multiple_of` on unsigned integers was stabilised in Rust 1.87
(rust-lang/rust#128101 — RFC 3565). On 1.85 the compile fails with:

  error[E0658]: use of unstable library feature `unsigned_is_multiple_of`
   --> hnsw_rs-0.3.4/src/hnswio.rs:736:20

Pinned to 1.90 for reproducibility — a comment in the Dockerfile flags
the 1.87 MSRV requirement so a future downgrade can't quietly break it.

## .gitattributes — force LF on shell scripts + Dockerfile

Without a `.gitattributes`, git's default `core.autocrlf=true` on
Windows converts shell scripts to CRLF on checkout. `COPY`ing
`docker/docker-entrypoint.sh` into a Linux image then preserves CRLF.
The shebang line `#!/bin/sh\r\n` causes `exec /app/docker-entrypoint.sh`
to fail with:

  exec /app/docker-entrypoint.sh: no such file or directory

The kernel tries to look up an interpreter literally named `/bin/sh\r`,
which doesn't exist. Container exits immediately. The first v0.8.0
image push (digest sha256:7957…44fa) suffered exactly this; the
re-pushed image (digest sha256:e9f4…d38315) was built on a
renormalised tree.

The .gitattributes rule forces LF for:
  - *.sh / *.bash
  - Dockerfile*
  - docker/* (covers docker-entrypoint.sh + docker-compose.yml)
  - scripts/*
  - `verify` (the proof-replay wrapper — same root cause as if it
    had landed CRLF in someone's clone)

Binary file globs (*.bin, *.wasm, *.rvf, *.pcap, etc.) explicitly
marked binary so text-normalisation never touches them.

## CHANGELOG — drop the false `--introspection` flag claim

The CHANGELOG entry for v0.8.0 said the introspection endpoints were
"off by default, enabled via `--introspection`". That isn't true:
`sensing-server --help` has no such flag. The routes are mounted
unconditionally in `main.rs`. The per-frame `update()` p99 of
0.041 ms (~24× under D4's 1 ms budget) makes always-on viable; the
"off by default" framing came from an earlier draft of ADR-099 that
the implementation outgrew. Corrected.

## Verification

End-to-end smoke test of the pushed image:

  docker run -d -p 13000:3000 -e CSI_SOURCE=simulated     -e SENSING_BIND_ADDR=0.0.0.0 ruvnet/wifi-densepose:v0.8.0

  /health -> {"status":"ok","source":"simulated",...}
  /api/v1/info -> {"backend":"rust","features":{"ruvector":true,"signal_processing":true,...}}
  /api/v1/introspection/snapshot -> {"regime":"unknown",
    "regime_changed":false,"top_k_similarity":[]} (ADR-099 shape exact)
  /ui/observatory.html -> HTTP 200, 15 KB

Published manifest digests:
  ruvnet/wifi-densepose:v0.8.0 -> sha256:e9f4c5af…d38315
  ruvnet/wifi-densepose:latest -> sha256:e9f4c5af…d38315

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-14 13:38:58 -04:00
ruv f396c44751 ci(verify-pipeline): fix stale v1/ working-directory + SECRET_KEY env
Same drift as #559 but in CI: the workflow ran `working-directory: v1`
on the two verify steps, but the Python codebase moved to `archive/v1/`
ages ago. The job failed with:

  An error occurred trying to start process '/usr/bin/bash' with
  working directory '/home/runner/work/RuView/RuView/v1'.
  No such file or directory

Fixed both occurrences (working-directory: v1 -> working-directory:
archive/v1).

Also added `SECRET_KEY` env var to both steps — `verify.py` transitively
imports `src.app` -> `src.config.settings` (since PR #547 introduced
pydantic-settings with a required `secret_key` field). The value is
never used for any auth path in the proof pipeline; it just needs to
satisfy the import chain. Same env-var workaround used locally to make
`./verify` pass.

After this commit, "Verify Pipeline Determinism (3.11)" should go green
on this PR.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-14 11:45:03 -04:00
ruv 86f38c4fc6 fix: first-run breakage (closes #559, #561) + #560 platform-aware diagnosis
Three related fixes — a fresh-clone user hitting any of these would
conclude the project doesn't work; #557's "feels like mock" narrative
is fed in part by these breakages.

## #559 — `./verify` pointed at removed `v1/` paths

The wrapper hard-coded `v1/data/proof` / `v1/src`, but the proof scripts
moved to `archive/v1/` long ago. A fresh clone failed before the
pipeline could even run. User `Fewmanism` provided the exact diff in
the issue. Applied verbatim across four hits (PROOF_DIR, V1_SRC, the
Phase 3 scan-message, and the SKIP-state recovery hint).

  ./verify  # now PASS end-to-end

## #561 — firmware README would misflash and point at the wrong provisioner

Two real bring-up bugs:

1. Manual flash command put the app at `0x10000`. The partition tables
   (`partitions_display.csv`, `partitions_4mb.csv`) define `ota_0` at
   `0x20000`. `0x10000` is the start of `phy_init` data — flashing
   the app binary there would corrupt the PHY init data and the app
   would never run. The QEMU section already had the right `0x20000`,
   so this was an internal contradiction. Both occurrences fixed.

   Also added `0xf000 ota_data_initial.bin` to the manual flash
   command — the release bundle ships this binary and without it the
   bootloader can refuse to boot after a factory wipe.

2. `python scripts/provision.py` referenced the wrong file. There are
   actually TWO `provision.py` files in the repo (`scripts/` — 275
   lines, stale; `firmware/esp32-csi-node/` — 348 lines, has the
   issue #391 full-replace semantics fix). The canonical one is in
   the firmware dir. Both README occurrences fixed to point at the
   canonical path. (The stale `scripts/provision.py` is a separate
   cleanup; the historical ADRs that reference it are intentionally
   not touched.)

## #560 — proof hash mismatches on macOS arm64 / Accelerate

User `Fewmanism` reports that with the same pinned `numpy 1.26.4` /
`scipy 1.14.1` on macOS arm64, the proof's SHA-256 differs from the
published expected hash. The proof passes on linux-x86_64 and
windows-x86_64 (where wheels ship OpenBLAS); it mismatches on
darwin-arm64 (where numpy/scipy use Accelerate.framework). That is
not a code bug — Accelerate's FFT and BLAS produce bit-different
output on identical IEEE 754 inputs from the same backend, and the
proof's bit-exact contract therefore cannot hold across backends.

What this commit changes:

- `verify.py` now prints a RUNTIME ENVIRONMENT block before the
  pipeline runs: platform, machine, Python version, numpy BLAS
  backend. Users on a non-reference backend see the cause up front.
- The FAIL message reorders causes: platform BLAS/FFT backend is
  now the *primary* suspect (not "unlikely"), with a pointer to
  the printed RUNTIME ENVIRONMENT block.
- New `archive/v1/data/proof/REFERENCE_PLATFORMS.md` documents the
  reference platforms (linux-x86_64 + windows-x86_64 with OpenBLAS),
  the expected-MISMATCH platforms (darwin-arm64 with Accelerate,
  any MKL install), and three workable responses for users hitting
  a non-reference backend (run on a reference platform, generate a
  local-reference hash, or use tolerance-based comparison — that
  last one is the roadmap path).

This converts #560 from "the proof is broken on my Mac" to "the proof
has a documented single-backend contract".

## Verification

- `./verify` (Windows x86_64 / OpenBLAS): VERDICT PASS, hash
  `8c0680d7…51c6` matches expected. RUNTIME ENVIRONMENT block prints
  numpy BLAS = `scipy-openblas`.
- `grep -E '0x10000|scripts/provision\.py' firmware/esp32-csi-node/README.md`:
  no matches.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-14 08:45:33 -04:00
139 changed files with 1257 additions and 15513 deletions
+35
View File
@@ -0,0 +1,35 @@
# Line-ending policy.
#
# `* text=auto` lets git normalise text files to LF in the repository and convert
# to the platform's native line endings on checkout. That default is fine for
# .md / .rs / .toml / .py — broken for shell scripts and Dockerfiles, where
# CRLF on the shebang line causes Linux exec to look for an interpreter named
# `/bin/sh\r` (or similar) and fail with "no such file or directory".
#
# Force LF for anything that ends up executed inside a Linux container or a
# POSIX shell. This is what prevented the v0.8.0 image from booting at first
# build until the entrypoint was renormalised.
* text=auto
*.sh text eol=lf
*.bash text eol=lf
verify text eol=lf
Dockerfile* text eol=lf
docker/* text eol=lf
scripts/* text eol=lf
# Binary blobs that should never be touched by text-normalisation.
*.bin binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tar binary
*.tgz binary
*.gz binary
*.wasm binary
*.rvf binary
*.task binary
*.csi.jsonl binary
*.pcap binary
+7 -7
View File
@@ -33,7 +33,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@v5
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@v6
uses: actions/setup-python@v5
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@v6
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unittests
@@ -226,7 +226,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
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@v6
uses: docker/metadata-action@v5
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@v7
uses: docker/build-push-action@v5
with:
context: .
target: production
@@ -341,7 +341,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
--out-dir ../../dashboard/public/nvsim-pkg \
--release -- --no-default-features --features wasm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
- working-directory: dashboard
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
-- --no-default-features --features wasm
- name: Setup Node 20
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
@@ -85,7 +85,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: '3.11'
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
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@v7
uses: docker/build-push-action@v5
with:
context: v2
file: v2/crates/nvsim-server/Dockerfile
+5 -5
View File
@@ -32,7 +32,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v6
uses: actions/setup-python@v5
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@v6
uses: actions/setup-python@v5
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@v7
uses: docker/build-push-action@v5
with:
context: .
target: production
@@ -197,7 +197,7 @@ jobs:
- name: Run Grype vulnerability scanner
continue-on-error: true
uses: anchore/scan-action@v7
uses: anchore/scan-action@v3
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@v6
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
+3 -13
View File
@@ -50,12 +50,6 @@ 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
@@ -74,7 +68,7 @@ jobs:
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
docker.io/ruvnet/wifi-densepose
@@ -87,7 +81,7 @@ jobs:
- name: Build + push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rust
@@ -96,11 +90,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 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
platforms: linux/amd64
# ---------------------------------------------------------------------
# Smoke-test the freshly-pushed image:
+7 -16
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -59,16 +59,11 @@ jobs:
- name: Run pipeline verification
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"
# verify.py transitively imports src.app -> src.config.settings, which
# uses pydantic-settings with a required `secret_key` field. The proof
# only needs the import chain to resolve; the value is never used for
# any auth path in the proof pipeline.
SECRET_KEY: ci-proof-replay-only-not-a-real-secret
run: |
echo "=== Running pipeline verification ==="
python data/proof/verify.py
@@ -78,11 +73,7 @@ jobs:
- name: Run verification twice to confirm determinism
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"
SECRET_KEY: ci-proof-replay-only-not-a-real-secret
run: |
echo "=== Second run for determinism confirmation ==="
python data/proof/verify.py
-3
View File
@@ -13,9 +13,6 @@ 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/
+2 -78
View File
@@ -7,60 +7,6 @@ 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
@@ -68,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
regime classification) and `temporal-compare` (DTW pattern matching) as a
**parallel tap** alongside RuView's existing event pipeline — no replacement,
no behaviour change to the existing `/ws/sensing` fan-out or `wifi-densepose-signal`
DSP. Two new endpoints (off by default, enabled via `--introspection`):
DSP. Two new endpoints (always mounted — the tap is cheap enough at 0.041 ms p99
per-frame `update()` to ship hot by default):
- `GET /ws/introspection` — newline-delimited JSON snapshots streamed at the CSI
frame rate. Each snapshot carries `frame_count`, `regime` (Idle / Periodic /
Transient / Chaotic / Unknown), `lyapunov_exponent`, `attractor_dim`,
@@ -162,22 +109,6 @@ 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.010.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 (BiotSavart, dipole,
@@ -197,13 +128,6 @@ 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 24 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 -8
View File
@@ -14,6 +14,9 @@ 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 |
@@ -132,14 +135,17 @@ 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-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)
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)
### Validation & Witness Verification (ADR-028)
+17 -50
View File
@@ -15,7 +15,7 @@
## **See through walls with WiFi** ##
**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.
**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.
### π 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 **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 KB16 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 P7P9 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.
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.
### Built for low-power edge applications
@@ -47,26 +47,18 @@ RuView **ships pretrained CSI weights on Hugging Face** at [`ruvnet/wifi-densepo
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-ruvector.svg)](https://crates.io/crates/wifi-densepose-ruvector)
> | 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**. P7P9 (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.
> | 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 |
```bash
# Option 1: Docker (simulated data, no hardware needed)
@@ -96,10 +88,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 | 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 |
> | **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 |
> | **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 (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
>
@@ -123,31 +115,6 @@ 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 P7P9.
## 🔬 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:
@@ -0,0 +1,52 @@
# Reference platforms for `expected_features.sha256`
The hash in `expected_features.sha256` was generated on a specific BLAS / FFT
backend. Numpy + scipy delegate FFT/linear-algebra to platform-native
libraries, and those libraries produce **bit-different output on identical
IEEE 754 inputs** depending on the backend. This is not a bug in the proof
pipeline — it is a property of the underlying numerical libraries. (See
issue #560.)
## Platforms where the hash is expected to MATCH
| Platform | BLAS backend | Status |
|---|---|---|
| `linux-x86_64-gnu` (Python 3.11.x, numpy 1.26.4 from PyPI wheels, scipy 1.14.1) | OpenBLAS | ✅ Reference |
| `windows-x86_64-msvc` (Python 3.11.x / 3.13.x, numpy 1.26.4 from PyPI wheels, scipy 1.14.1) | OpenBLAS | ✅ Reference |
## Platforms where the hash is **expected to MISMATCH**
| Platform | BLAS backend | Why |
|---|---|---|
| `darwin-arm64` (macOS arm64, Apple Silicon) | Accelerate.framework | FFT + matrix kernels differ in last-bit positions; the SHA-256 will differ even with pinned `numpy 1.26.4` / `scipy 1.14.1`. |
| Any environment with MKL installed | Intel MKL | Same root cause as Accelerate: different vectorized FFT path. |
## What to do if you get MISMATCH on a non-reference platform
The pipeline is still correct on your platform — the *output* is bit-different
because the *backend* is bit-different, not because the proof code has a bug.
Three workable responses:
1. **Run the proof on a reference platform** (Linux x86_64 or Windows x86_64
with the PyPI OpenBLAS wheels). This is what CI does.
2. **Generate a new local-reference hash** for your platform and check it
against the same hash on a teammate's machine with the *same* backend:
```bash
# Regenerate from your platform
python archive/v1/data/proof/verify.py --generate-hash
# Commit the new hash to a side file (do NOT overwrite expected_features.sha256
# unless you are publishing a new cross-platform reference)
```
3. **Compare numerical output, not the hash.** A relaxed-tolerance comparison
on the feature vectors (e.g. `np.allclose(features, reference, atol=1e-10)`)
will pass across backends. This is on the roadmap (see issue #560).
## The `verify.py` runtime environment block
Every run of `verify.py` now prints a `RUNTIME ENVIRONMENT` block before the
pipeline runs. Include that block in any issue report — it identifies the
platform + numpy version + BLAS backend in one place.
@@ -1 +1 @@
667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7
8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6
+62 -39
View File
@@ -116,6 +116,48 @@ def print_source_provenance():
print()
def print_runtime_environment():
"""Print the platform + numpy/scipy BLAS backend.
The proof pipeline's SHA-256 is sensitive to the BLAS / FFT backend
behind numpy + scipy.fft. Different platforms ship different backends
(OpenBLAS on Linux/Windows wheels, Accelerate.framework on macOS arm64,
MKL when installed) and they produce bit-different output on identical
IEEE 754 inputs. Surfacing the backend up front turns an unexplained
MISMATCH into a one-line diagnosis -- see issue #560.
"""
import platform
print(" RUNTIME ENVIRONMENT:")
print(f" Platform : {platform.platform()}")
print(f" Machine : {platform.machine()}")
print(f" Python : {platform.python_version()} ({platform.python_implementation()})")
# numpy BLAS / LAPACK backend.
try:
blas_info = np.__config__.blas_ilp64_opt_info # type: ignore[attr-defined]
backend = getattr(blas_info, "get", lambda *_: None)("libraries", None) or "unknown"
except Exception:
# Newer numpy (>= 1.26) reports via show_config(); fall back to a stringified dump.
try:
import io
buf = io.StringIO()
np.show_config(mode="dicts") if hasattr(np, "show_config") else None
# `show_config(mode='dicts')` returns a dict in numpy >= 1.26.
cfg = np.show_config(mode="dicts") if hasattr(np, "show_config") else {}
if isinstance(cfg, dict):
blas = cfg.get("Build Dependencies", {}).get("blas", {})
backend = blas.get("name", "unknown")
else:
backend = "unknown"
except Exception:
backend = "unknown"
print(f" numpy BLAS : {backend}")
print(" (FFT/BLAS backend affects the hash -- see #560 if MISMATCH on")
print(" macOS arm64 / Accelerate. Reference platforms: linux-x86_64,")
print(" windows-x86_64 with OpenBLAS; see expected_features.sha256.)")
print()
def load_reference_signal(data_path):
"""Load the reference CSI signal from JSON.
@@ -164,44 +206,18 @@ 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.
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.
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.
Args:
features: CSIFeatures instance.
Returns:
bytes: Canonical, quantized byte representation.
bytes: Canonical byte representation.
"""
parts = []
@@ -215,10 +231,6 @@ 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))
@@ -447,6 +459,7 @@ def main():
# ---------------------------------------------------------------
print("[0/4] SOURCE PROVENANCE")
print_source_provenance()
print_runtime_environment()
# ---------------------------------------------------------------
# Step 1: Load and describe reference signal
@@ -548,13 +561,23 @@ def main():
print()
print(" The pipeline output does NOT match the expected hash.")
print()
print(" Possible causes:")
print(" - Numpy/scipy version mismatch (check requirements)")
print(" - Code change in CSI processor that alters numerical output")
print(" - Platform floating-point differences (unlikely for IEEE 754)")
print(" Likely causes, in order of probability:")
print(" 1. Platform BLAS/FFT backend differs from the reference.")
print(" The expected hash was generated on linux-x86_64 +")
print(" windows-x86_64 with OpenBLAS. macOS arm64 ships with")
print(" Accelerate.framework, which produces bit-different FFT")
print(" output on identical inputs (issue #560). Inspect the")
print(" RUNTIME ENVIRONMENT block printed at the top of this run.")
print(" 2. Numpy/scipy version mismatch.")
print(" Install pinned versions: pip install -r archive/v1/requirements-lock.txt")
print(" 3. Real code change in the CSI processor that alters output.")
print(" Investigate the diff against the reference commit.")
print()
print(" To update the expected hash after intentional changes:")
print(" To regenerate the expected hash on a NEW reference platform:")
print(" python verify.py --generate-hash")
print(" (Only do this if you intend to publish a new reference; the")
print(" single-platform contract of expected_features.sha256 is")
print(" documented at the top of that file.)")
print("=" * 72)
sys.exit(1)
+5 -7
View File
@@ -9,7 +9,6 @@ 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
@@ -156,17 +155,16 @@ class UserManager:
return False
class AuthenticationMiddleware(BaseHTTPMiddleware):
class AuthenticationMiddleware:
"""Authentication middleware for FastAPI."""
def __init__(self, app, settings: Settings):
super().__init__(app)
def __init__(self, settings: Settings):
self.settings = settings
self.token_manager = TokenManager(settings)
self.user_manager = UserManager()
self.enabled = settings.enable_authentication
async def dispatch(self, request: Request, call_next: Callable) -> Response:
async def __call__(self, request: Request, call_next: Callable) -> Response:
"""Process request through authentication middleware."""
start_time = time.time()
+5 -7
View File
@@ -11,7 +11,6 @@ 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
@@ -300,16 +299,15 @@ class RateLimiter:
}
class RateLimitMiddleware(BaseHTTPMiddleware):
class RateLimitMiddleware:
"""Rate limiting middleware for FastAPI."""
def __init__(self, app, settings: Settings):
super().__init__(app)
def __init__(self, settings: Settings):
self.settings = settings
self.rate_limiter = RateLimiter(settings)
self.enabled = settings.enable_rate_limiting
async def dispatch(self, request: Request, call_next: Callable) -> Response:
async def __call__(self, request: Request, call_next: Callable) -> Response:
"""Process request through rate limiting middleware."""
if not self.enabled:
return await call_next(request)
+1 -5
View File
@@ -220,11 +220,7 @@ class PoseService:
# Apply phase sanitization if we have phase data
if hasattr(detection_result.features, 'phase_difference'):
phase_data = detection_result.features.phase_difference
# 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)
sanitized_phase = self.phase_sanitizer.sanitize(phase_data)
# Combine amplitude and phase data
return np.concatenate([amplitude_data, sanitized_phase])
+5 -1
View File
@@ -3,7 +3,11 @@
# Multi-stage build for minimal final image
# Stage 1: Build
FROM rust:1.85-bookworm AS builder
# Rust 1.87+ is required: `hnsw_rs 0.3.4` (transitive via wifi-densepose-ruvector ->
# ruvector-attn-mincut) uses `u*::is_multiple_of`, stabilised in 1.87. Pinning to a
# recent stable (1.90) for reproducibility — bump cautiously since reproducible
# builds rely on this.
FROM rust:1.90-bookworm AS builder
WORKDIR /build
+1 -12
View File
@@ -9,18 +9,7 @@ services:
ports:
- "3000:3000" # REST API
- "3001:3001" # WebSocket
# 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"
- "5005:5005/udp" # ESP32 UDP
environment:
- RUST_LOG=info
# CSI_SOURCE controls the data source for the sensing server.
-72
View File
@@ -109,75 +109,3 @@ 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.
-191
View File
@@ -1,191 +0,0 @@
# 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`.
-1
View File
@@ -108,7 +108,6 @@ 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 |
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

@@ -1,466 +0,0 @@
# 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:** 46 hours (hardware 1h, firmware 1h, software 1h, calibration 13h)
**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 3060 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.08.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.60.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.*
+3 -108
View File
@@ -21,7 +21,6 @@ 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)
@@ -29,14 +28,13 @@ 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. [Loading the Pretrained Model from Hugging Face](#loading-the-pretrained-model-from-hugging-face)
12. [Adaptive Classifier](#adaptive-classifier)
11. [Adaptive Classifier](#adaptive-classifier)
- [Recording Training Data](#recording-training-data)
- [Training the Model](#training-the-model)
- [Using the Trained Model](#using-the-trained-model)
13. [Training a Model](#training-a-model)
12. [Training a Model](#training-a-model)
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
14. [RVF Model Containers](#rvf-model-containers)
13. [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)
@@ -333,46 +331,6 @@ 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.
@@ -794,67 +752,6 @@ 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).
@@ -1847,8 +1744,6 @@ 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
-10
View File
@@ -1,10 +0,0 @@
# 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
-77
View File
@@ -1,77 +0,0 @@
# 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 | |
|---|---|
| ![01](screenshots/01-helpers.png) | ![02](screenshots/02-cinematic.png) |
| ![03](screenshots/03-skinned.png) | ![04](screenshots/04-skinned-fbx.png) |
| ![05](screenshots/05-skinned-realtime.png) | |
## 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).
-587
View File
@@ -1,587 +0,0 @@
<!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>
File diff suppressed because it is too large Load Diff
-854
View File
@@ -1,854 +0,0 @@
<!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>
-961
View File
@@ -1,961 +0,0 @@
<!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>
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

@@ -1,153 +0,0 @@
#!/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
-46
View File
@@ -1,46 +0,0 @@
"""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)
+8 -27
View File
@@ -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 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) |
> | **Presence sensing** | Phase variance + adaptive calibration | < 1 ms latency |
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
@@ -37,9 +37,6 @@ 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 \
@@ -49,6 +46,11 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
> The app slot (`ota_0`) starts at `0x20000` per `partitions_display.csv` /
> `partitions_4mb.csv`. `ota_data_initial.bin` at `0xf000` initialises the OTA
> slot pointer; without it the bootloader can refuse to boot the app after a
> factory wipe.
### 3. Provision WiFi credentials (no reflash needed)
```bash
@@ -133,32 +135,11 @@ 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 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.
- **Presence detection** -- adaptive threshold calibration (60 s ambient learning)
- **Fall detection** -- phase acceleration exceeds configurable threshold
- **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).
- **Multi-person estimation** -- subcarrier group clustering (up to 4 persons)
- **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.
+2 -25
View File
@@ -11,26 +11,7 @@ set(SRCS
"adaptive_controller.c"
)
# 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
)
set(REQUIRES "")
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
if(CONFIG_CSI_MOCK_ENABLED)
@@ -40,11 +21,7 @@ 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")
list(APPEND REQUIRES esp_lcd esp_lcd_touch lvgl)
endif()
if(CONFIG_WASM_ENABLE)
list(APPEND REQUIRES wasm3)
set(REQUIRES esp_lcd esp_lcd_touch lvgl)
endif()
idf_component_register(
@@ -371,30 +371,6 @@ 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,
@@ -404,7 +380,6 @@ 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,9 +2,8 @@
* @file edge_processing.c
* @brief ADR-039 Edge Intelligence — dual-core CSI 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.
* Core 0 (WiFi task): Pushes raw CSI frames into lock-free SPSC ring buffer.
* Core 1 (DSP task): Pops frames, runs signal processing pipeline:
* 1. Phase extraction from I/Q pairs
* 2. Phase unwrapping (continuous phase)
* 3. Welford variance tracking per subcarrier
@@ -1051,9 +1050,7 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
return ESP_OK;
}
/* 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;
/* Start DSP task on Core 1. */
BaseType_t ret = xTaskCreatePinnedToCore(
edge_task,
"edge_dsp",
@@ -1061,14 +1058,14 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
NULL,
5, /* Priority 5 — above idle, below WiFi. */
NULL,
dsp_core);
1 /* Pin to Core 1. */
);
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 %d (stack=8192, priority=5)",
(int)dsp_core);
ESP_LOGI(TAG, "Edge DSP task created on Core 1 (stack=8192, priority=5)");
return ESP_OK;
}
@@ -8,6 +8,3 @@ dependencies:
## LCD touch abstraction
espressif/esp_lcd_touch: "^1.0"
## Onboard WS2812 LED Disabling
espressif/led_strip: "^3.0.0"
-18
View File
@@ -18,7 +18,6 @@
#include "nvs_flash.h"
#include "esp_app_desc.h"
#include "sdkconfig.h"
#include "led_strip.h"
#include "csi_collector.h"
#include "stream_sender.h"
@@ -150,23 +149,6 @@ 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();
+5 -5
View File
@@ -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 >= sizeof(float)) {
if (len >= 4) {
/* 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 >= sizeof(float)) {
if (len >= 4) {
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 >= sizeof(uint32_t) + sizeof(float)) {
if (len >= 8) {
/* 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) {
if (range_flag != 0 && len >= 8) {
float dist;
memcpy(&dist, &data[sizeof(uint32_t)], sizeof(float));
memcpy(&dist, &data[4], sizeof(float));
s_state.distance_cm = dist;
}
}
+8 -37
View File
@@ -38,24 +38,14 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
/**
* ADR-050: Verify the Authorization header contains the correct 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.
* Returns true if auth is disabled (no PSK provisioned) or if the
* Bearer token matches the stored PSK.
*/
static bool ota_check_auth(httpd_req_t *req)
{
if (s_ota_psk[0] == '\0') {
/* 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;
/* No PSK provisioned — auth disabled (permissive for dev). */
return true;
}
char auth_header[128] = {0};
@@ -251,45 +241,26 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
return ESP_OK;
}
/**
* 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)
esp_err_t ota_update_init(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 upload endpoint will REJECT all requests until "
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
}
nvs_close(nvs);
} else {
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_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", 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);
}
+23 -25
View File
@@ -10,7 +10,7 @@
#include <string.h>
#include "esp_log.h"
#include "psa/crypto.h"
#include "mbedtls/sha256.h"
static const char *TAG = "rvf";
@@ -125,13 +125,9 @@ 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];
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);
int ret = mbedtls_sha256(wasm_data, hdr->wasm_len, computed_hash, 0);
if (ret != 0) {
ESP_LOGE(TAG, "SHA-256 computation failed: %d", ret);
return ESP_FAIL;
}
@@ -190,7 +186,8 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
/*
* Ed25519 verification.
*
* Legacy mbedtls Ed25519 is optional. We use a SHA-256 keyed digest:
* ESP-IDF v5.2 mbedtls does NOT include Ed25519 (Curve25519 is
* for ECDH/X25519 only). We use a SHA-256-HMAC integrity check:
*
* expected = SHA-256(pubkey || signed_region)
*
@@ -199,34 +196,35 @@ 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, enable CONFIG_MBEDTLS_EDDSA_C or equivalent.
* The RVF builder should match this scheme.
* For full Ed25519 (NaCl-style), enable CONFIG_MBEDTLS_EDDSA_C
* or link TweetNaCl. 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) 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) {
/* 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);
return ESP_FAIL;
}
st = psa_hash_update(&op, hash_input_prefix, 32);
if (st != PSA_SUCCESS) {
(void)psa_hash_abort(&op);
ret = mbedtls_sha256_update(&ctx, hash_input_prefix, 32);
if (ret != 0) {
mbedtls_sha256_free(&ctx);
return ESP_FAIL;
}
st = psa_hash_update(&op, data, signed_len);
if (st != PSA_SUCCESS) {
(void)psa_hash_abort(&op);
ret = mbedtls_sha256_update(&ctx, data, signed_len);
if (ret != 0) {
mbedtls_sha256_free(&ctx);
return ESP_FAIL;
}
uint8_t expected[32];
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);
ret = mbedtls_sha256_finish(&ctx, expected);
mbedtls_sha256_free(&ctx);
if (ret != 0) {
return ESP_FAIL;
}
+24 -56
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env python3
"""
ESP32 CSI node provisioning (ESP32-S3, ESP32-C6, other targets).
ESP32-S3 CSI Node Provisioning Script
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
@@ -37,39 +35,6 @@ 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()
@@ -178,7 +143,7 @@ def generate_nvs_binary(csv_content, size):
os.unlink(p)
def flash_nvs(port, baud, nvs_bin, chip):
def flash_nvs(port, baud, nvs_bin):
"""Flash the NVS partition binary to the ESP32."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(nvs_bin)
@@ -187,13 +152,16 @@ def flash_nvs(port, baud, nvs_bin, chip):
try:
cmd = [
sys.executable, "-m", "esptool",
"--chip", chip,
"--chip", "esp32s3",
"--port", port,
"--baud", str(baud),
"write-flash",
# 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",
hex(NVS_PARTITION_OFFSET), bin_path,
]
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port} (chip={chip})...")
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port}...")
subprocess.check_call(cmd)
print("NVS provisioning complete!")
finally:
@@ -202,20 +170,10 @@ def flash_nvs(port, baud, nvs_bin, chip):
def main():
parser = argparse.ArgumentParser(
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+)."
),
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",
)
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")
@@ -256,7 +214,17 @@ def main():
args = parser.parse_args()
if not has_config_value(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:
parser.error("At least one config value must be specified")
# Bug 2 (#391): Prevent silent wipe of WiFi credentials on partial invocations.
@@ -313,7 +281,7 @@ def main():
if args.ssid:
print(f" WiFi SSID: {args.ssid}")
if args.password is not None:
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
print(f" WiFi Password: {'*' * len(args.password)}")
if args.target_ip:
print(f" Target IP: {args.target_ip}")
if args.target_port:
@@ -369,11 +337,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 {args.chip} --port {args.port} "
print(f"Flash manually: python -m esptool --chip esp32s3 --port {args.port} "
f"write-flash 0x9000 {out}")
return
flash_nvs(args.port, args.baud, nvs_bin, args.chip)
flash_nvs(args.port, args.baud, nvs_bin)
if __name__ == "__main__":
@@ -34,11 +34,3 @@ 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,13 +153,6 @@ 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; }
@@ -1,63 +0,0 @@
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
View File
@@ -1 +1 @@
0.6.5
0.6.4
+1 -1
View File
@@ -1,4 +1,4 @@
# ESP32 Hello World — Capability Discovery (S3 / C6 targets)
# ESP32-S3 Hello World — Capability Discovery
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+22 -93
View File
@@ -1,11 +1,11 @@
/**
* @file main.c
* @brief ESP32 Hello World — Full Capability Discovery
* @brief ESP32-S3 Hello World — Full Capability Discovery
*
* 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).
* 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.
*/
#include <stdio.h>
@@ -18,6 +18,7 @@
#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"
@@ -32,24 +33,7 @@
#include "driver/temperature_sensor.h"
#include "sdkconfig.h"
/*
* 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
static const char *TAG = "hello";
/* ── Helpers ─────────────────────────────────────────────────────────── */
@@ -62,7 +46,6 @@ 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";
}
}
@@ -185,11 +168,7 @@ 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
@@ -267,7 +246,7 @@ static void probe_bluetooth(void)
esp_chip_info(&info);
if (info.features & CHIP_FEATURE_BLE) {
printf(" BLE: Supported (Bluetooth LE)\n");
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
printf(" - GATT Server/Client\n");
printf(" - Advertising & Scanning\n");
printf(" - Mesh Networking\n");
@@ -277,16 +256,10 @@ 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 (BLE-only on this chip)\n");
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
}
}
@@ -296,52 +269,24 @@ 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));
#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(" 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);
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");
#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(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
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(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
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)
@@ -364,29 +309,17 @@ 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: FSM (runs in deep sleep)\n");
#endif
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
}
static void probe_temperature(void)
@@ -456,9 +389,6 @@ 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) {
@@ -471,7 +401,7 @@ void app_main(void)
printf("\n");
printf(" ╭─────────────────────────────────────────────────╮\n");
printf(" │ │\n");
printf(" │ HELLO WORLD from %-24s\n", chip_model_str(chip.model));
printf(" │ HELLO WORLD from ESP32-S3! \n");
printf(" │ │\n");
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
printf(" │ │\n");
@@ -492,9 +422,8 @@ void app_main(void)
probe_csi_details();
print_separator("DONE — ALL CAPABILITIES REPORTED");
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");
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");
/* Keep alive — blink a status message every 10 seconds */
int tick = 0;
@@ -1,5 +1,5 @@
# ESP32 Hello World — SDK Configuration (default: ESP32-C6)
CONFIG_IDF_TARGET="esp32c6"
# ESP32-S3 Hello World — SDK Configuration
CONFIG_IDF_TARGET="esp32s3"
# Flash: 4MB (this chip has Embedded Flash 4MB)
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
+1 -1
View File
@@ -5,7 +5,7 @@
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-mock>=3.10.0
pytest-benchmark>=5.2.3
pytest-benchmark>=4.0.0
# Linting and formatting
black>=23.0.0
+4 -4
View File
@@ -7,7 +7,7 @@ torchvision>=0.13.0
# API dependencies
fastapi>=0.95.0
uvicorn>=0.20.0
websockets>=15.0.1
websockets>=10.4
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.22.1
aiosqlite>=0.19.0
redis>=4.5.0
# CLI dependencies
@@ -26,8 +26,8 @@ click>=8.0.0
alembic>=1.10.0
# Hardware interface dependencies
asyncio-mqtt>=0.16.2
aiohttp>=3.13.5
asyncio-mqtt>=0.11.0
aiohttp>=3.8.0
paramiko>=3.0.0
# Data processing dependencies
-103
View File
@@ -110,109 +110,6 @@
"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"
}
]
}
-86
View File
@@ -1,86 +0,0 @@
#!/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))
+1 -1
View File
@@ -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: {'(set)' if args.password else '(empty)'}")
print(f" WiFi Password: {'*' * len(args.password)}")
if args.target_ip:
print(f" Target IP: {args.target_ip}")
if args.target_port:
+1 -6
View File
@@ -259,16 +259,11 @@ def provision_node(
if stale.exists():
stale.unlink()
# 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).
# Build provision.py arguments
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),
-103
View File
@@ -1,103 +0,0 @@
#!/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())
-33
View File
@@ -1,33 +0,0 @@
{
"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"
]
}
+38 -163
View File
@@ -10,24 +10,6 @@ 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() {
@@ -48,13 +30,10 @@ 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');
@@ -188,118 +167,6 @@ 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}`);
@@ -405,17 +272,45 @@ class WiFiDensePoseApp {
});
}
// Show backend status notification (uses enhanced toast system)
// Show backend status notification
showBackendStatus(message, type) {
const toastType = type === 'success' ? 'success' : 'warning';
toastManager[toastType](message, {
duration: type === 'success' ? 3000 : 8000
});
// 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);
}
// Show global error message (uses enhanced toast system)
// Show global error message
showGlobalError(message) {
toastManager.error(message, { duration: 6000 });
// 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);
}
// Clean up resources
@@ -431,29 +326,9 @@ 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
+4 -39
View File
@@ -19,33 +19,6 @@ 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) {
@@ -63,22 +36,14 @@ export class TabManager {
return;
}
// Update tab states and ARIA attributes
// Update tab states
this.tabs.forEach(tab => {
const isActive = tab === tabElement;
tab.classList.toggle('active', isActive);
if (tab.hasAttribute('aria-selected')) {
tab.setAttribute('aria-selected', String(isActive));
}
tab.classList.toggle('active', tab === tabElement);
});
// Update content visibility and ARIA
// Update content visibility
this.tabContents.forEach(content => {
const isActive = content.id === tabId;
content.classList.toggle('active', isActive);
if (content.hasAttribute('role')) {
content.setAttribute('aria-hidden', String(!isActive));
}
content.classList.toggle('active', content.id === tabId);
});
// Update active tab
-66
View File
@@ -1,66 +0,0 @@
<!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>
+30 -38
View File
@@ -3,48 +3,40 @@
<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" role="banner">
<header class="header">
<h1>WiFi DensePose</h1>
<p class="subtitle" data-i18n="dashboard.subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<div class="header-info">
<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>
<span class="api-version"></span>
<span class="api-environment"></span>
<span class="overall-health"></span>
</div>
</header>
<!-- Navigation -->
<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>
<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>
<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" role="tabpanel" aria-labelledby="dashboard">
<section id="dashboard" class="tab-content active">
<div class="hero-section">
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
<h2>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
@@ -56,7 +48,7 @@
<!-- Live Status Panel -->
<div class="live-status-panel">
<h3 data-i18n="dashboard.status">System Status</h3>
<h3>System Status</h3>
<div class="status-grid">
<div class="component-status" data-component="api">
<span class="component-name">API Server</span>
@@ -88,24 +80,24 @@
<!-- System Metrics -->
<div class="system-metrics-panel">
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
<h3>System Metrics</h3>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
<span class="metric-label">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" data-i18n="metrics.memory">Memory Usage</span>
<span class="metric-label">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" data-i18n="metrics.disk">Disk Usage</span>
<span class="metric-label">Disk Usage</span>
<div class="progress-bar" data-type="disk">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
@@ -116,13 +108,13 @@
<!-- Features Status -->
<div class="features-panel">
<h3 data-i18n="dashboard.features">Features</h3>
<h3>Features</h3>
<div class="features-status"></div>
</div>
<!-- Live Statistics -->
<div class="live-stats-panel">
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
<h3>Live Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Active Persons</span>
@@ -189,7 +181,7 @@
</section>
<!-- Hardware Tab -->
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
<section id="hardware" class="tab-content">
<h2>Hardware Configuration</h2>
<div class="hardware-grid">
@@ -267,7 +259,7 @@
</section>
<!-- Demo Tab -->
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
<section id="demo" class="tab-content">
<h2>Live Demonstration</h2>
<div class="demo-controls">
@@ -320,7 +312,7 @@
</section>
<!-- Architecture Tab -->
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
<section id="architecture" class="tab-content">
<h2>System Architecture</h2>
<div class="architecture-flow">
@@ -358,7 +350,7 @@
</section>
<!-- Performance Tab -->
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
<section id="performance" class="tab-content">
<h2>Performance Analysis</h2>
<div class="performance-chart">
@@ -430,7 +422,7 @@
</section>
<!-- Applications Tab -->
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
<section id="applications" class="tab-content">
<h2>Real-World Applications</h2>
<div class="applications-grid">
@@ -497,10 +489,10 @@
</section>
<!-- Sensing Tab -->
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
<section id="sensing" class="tab-content"></section>
<!-- Training Tab -->
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
<section id="training" class="tab-content">
<div class="tab-header">
<h2>Model Training</h2>
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
-25
View File
@@ -1,25 +0,0 @@
{
"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"
}
]
}
+250 -771
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -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.10",
"@react-navigation/bottom-tabs": "^7.15.3",
"@react-navigation/native": "^7.1.31",
"@types/three": "^0.183.1",
"axios": "^1.15.2",
"axios": "^1.13.6",
"expo": "~55.0.4",
"expo-status-bar": "~55.0.4",
"react": "19.2.0",
"react-dom": "19.2.6",
"react-native": "0.85.2",
"react-dom": "19.2.0",
"react-native": "0.83.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.12"
"zustand": "^5.0.11"
},
"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.59.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"babel-preset-expo": "^55.0.10",
"eslint": "^10.2.1",
"eslint": "^10.0.2",
"jest": "^30.2.0",
"jest-expo": "^55.0.9",
"prettier": "^3.8.3",
"prettier": "^3.8.1",
"react-native-worklets": "^0.7.4",
"typescript": "~5.9.2"
},
+5 -19
View File
@@ -9,25 +9,11 @@
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
*/
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();
// 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 RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
const MAX_RECONNECT_ATTEMPTS = 20;
// Number of failed attempts that must occur before simulation starts.
+2 -24
View File
@@ -136,22 +136,9 @@ export class WebSocketService {
// Set up WebSocket event handlers
setupEventHandlers(url, ws, handlers) {
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;
};
const connection = this.connections.get(url);
ws.onopen = (event) => {
const connection = getConnection('open');
if (!connection) return;
const connectionTime = Date.now() - connection.connectionStartTime;
this.logger.info(`WebSocket connected successfully`, { url, connectionTime });
@@ -171,9 +158,6 @@ export class WebSocketService {
};
ws.onmessage = (event) => {
const connection = getConnection('message');
if (!connection) return;
connection.lastActivity = Date.now();
connection.messageCount++;
@@ -204,9 +188,6 @@ export class WebSocketService {
};
ws.onerror = (event) => {
const connection = getConnection('error');
if (!connection) return;
connection.errorCount++;
this.logger.error(`WebSocket error occurred`, {
url,
@@ -227,9 +208,6 @@ 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 });
@@ -629,4 +607,4 @@ export class WebSocketService {
}
// Create singleton instance
export const wsService = new WebSocketService();
export const wsService = new WebSocketService();
-1741
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
// 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' }
});
}
}
+1 -13
View File
@@ -3,7 +3,6 @@
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';
@@ -233,17 +232,6 @@ 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');
@@ -485,4 +473,4 @@ document.addEventListener('DOMContentLoaded', () => {
testRunner.updateSummary();
});
export { testRunner };
export { testRunner };
-472
View File
@@ -1,472 +0,0 @@
<!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('&lt;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>
-181
View File
@@ -1,181 +0,0 @@
// 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">&times;</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);
}
}
}
-311
View File
@@ -1,311 +0,0 @@
// 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);
}
}
}
-84
View File
@@ -1,84 +0,0 @@
// 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);
}
}
}
-148
View File
@@ -1,148 +0,0 @@
// 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();
}
}
-79
View File
@@ -1,79 +0,0 @@
// 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();
}
}
-264
View File
@@ -1,264 +0,0 @@
// 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();
-83
View File
@@ -1,83 +0,0 @@
// 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: [] };
}
}
-168
View File
@@ -1,168 +0,0 @@
// 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">&times;</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);
}
}
}
-171
View File
@@ -1,171 +0,0 @@
// 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();
}
}
-233
View File
@@ -1,233 +0,0 @@
// 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();
}
}
-192
View File
@@ -1,192 +0,0 @@
// 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();
}
}
-216
View File
@@ -1,216 +0,0 @@
// 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">&times;</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);
}
}
}
+3 -7
View File
@@ -651,18 +651,14 @@ export class PoseRenderer {
this.performanceMetrics.frameCount++;
if (this.performanceMetrics.lastFrameTime > 0) {
// 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 deltaTime = currentTime - this.performanceMetrics.lastFrameTime;
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);
}
}
-191
View File
@@ -1,191 +0,0 @@
// 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">&times;</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();
}
}
-47
View File
@@ -1,47 +0,0 @@
// 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
}
}

Some files were not shown because too many files have changed in this diff Show More