Compare commits

...

73 Commits

Author SHA1 Message Date
ruv 5518d5d7c1 docs: truth-up README + user-guide on Hugging Face model release
The previous wording in both README.md and docs/user-guide.md claimed
no pretrained weights were released yet. That was wrong — the
contrastive CSI encoder + presence-detection head + per-node LoRA
adapters have been published as
ruvnet/wifi-densepose-pretrained on Hugging Face for several weeks
(124 downloads at time of writing), with 100% presence accuracy on
the validation set and 164,183 emb/s on M4 Pro.

This commit replaces the "no shipped weights" framing with the actual
state, and surfaces a real loader gap discovered during a
before/after benchmark of the sensing-server:

* Baseline run (no --model): server produced presence/motion/vitals
  output at ~19 ticks/s, as expected.
* After run (--model models/wifi-densepose-pretrained.rvf): the
  progressive RVF loader errored with
  "invalid magic at offset 0: expected 0x52564653, got 0x7974227B"
  (0x7974227B is the ASCII bytes {"ty… from the JSONL header).
  v2/.../rvf_container.rs only parses the binary RVF segment
  format; the HF artifact is JSONL RVF. When the load fails the
  pipeline degraded to null output (variance=0, presence=None) rather
  than falling back to heuristic mode.

The docs now describe (a) what works today — Python / training-side
consumption of model.safetensors — and (b) what is gated on a JSONL
adapter or a binary-RVF republish — sensing-server --model loading.
The 17-keypoint pose model remains separately pending (#509,
ADR-079 phases P7–P9).
2026-05-19 13:00:39 -04:00
rUv 8247d28d90 docs(README): truth-up capability table — separate shipped/heuristic/pending (#568 follow-up) (#635)
@xiaofuchen's audit in #568 was technically correct: the project page
claimed capabilities (\"Pose estimation\", \"Presence sensing — trained
model + PIR fusion — 100% accuracy\") that aren't what the code actually
does. PR #573 fixed this in the firmware README; this commit applies
the same truth-up to the main repo README so first-time visitors get
an honest picture.

Specific changes:

1. **Hero paragraph (line 35)** — was \"RuView also supports pose
   estimation (17 COCO keypoints …)\" with no caveat. Now: ships the
   training infrastructure; pretrained weights are not yet released
   (links #509 and ADR-079 P7-P9 Pending).

2. **Capability table (lines 50-61)** — was a single 11-row \"What/How/
   Speed\" table that mixed shipped, heuristic, and pipeline-only
   capabilities under the same emoji. Now a status column with a
   three-tier legend:
   -  shipped + tested on hardware (breathing rate, heart rate,
     motion, fall detection, through-wall, edge intelligence,
     multi-frequency mesh)
   - ⚠️ ships and runs, but is a heuristic/threshold (presence
     indicator, multi-person slot count) — accuracy depends on
     calibration and signal conditions
   - 🔬 implementation + tests in repo, weights/data/eval pending
     (17-keypoint pose estimation, camera-supervised fine-tune,
     3D point cloud fusion)

3. **Hardware capability column (lines 91-93)** — was \"Pose, breathing,
   heartbeat, motion, presence\" for the ESP32 options. Replaced with
   the literal list of capabilities that actually work today (presence
   indicator, motion, breathing, heart rate, fall detection, slot-count
   heuristic) with an explicit \"Pose pending weights — see #509\"
   qualifier.

Pointing also to the v0.6.5-esp32 release-aligned firmware README that
already has the firmware-side truth-up (PR #573).

This is documentation only — no code change, no behaviour change. The
project's capabilities haven't changed; the project page now describes
them honestly.
2026-05-19 11:50:59 -04:00
github-actions[bot] 5d6e50d8a0 chore: update vendor submodules (#634)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-19 10:11:01 -04:00
nai 49fb2ca9f4 feat(ui): UI overhaul — consolidates #305-#309 (keyboard shortcuts, perf monitor, toasts, theme, command palette, activity log, data export, mobile PWA, accessibility, i18n) (#620)
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility

- Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf)
- Real-time performance monitor with FPS, memory, latency sparklines (draggable)
- Enhanced toast notification system with stacking, auto-dismiss, progress bars
- Dark/light theme toggle with localStorage persistence and system preference detection
- WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels,
  arrow key navigation in tab bar, focus-visible outlines
- ESLint config for UI directory with security and quality rules

* feat(ui): add command palette, activity log, data export, fullscreen mode, connection status

- Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions
- Activity log panel (L key) with real-time console interception, filters, resizable
- Data export utility (E key) for sensor data as JSON/CSV with dialog
- Fullscreen mode (F key / F11) for visualization tabs with exit button
- Connection status widget in header showing WebSocket state and reconnect

* feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests

- Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px,
  swipe-to-close, animated hamburger icon, auto-sync with tab manager
- PWA manifest + service worker: installable dashboard, offline shell caching
  (cache-first for static, network-first for API), auto-cleanup of old caches
- 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor,
  TabManager - browser-based test runner at ui/tests/unit-tests.html
- PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link
- Icon generator page for creating PWA icons (ui/icons/generate.html)

* feat(ui): add URL routing, onboarding tour, idle detection, notification center

- Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.),
  syncs with TabManager, supports browser back/forward navigation
- Onboarding tour: interactive 6-step first-run walkthrough with spotlight
  highlighting, step indicators, skip/back/next controls, localStorage persistence
- Idle detection: pauses health polling and reduces CSS animations after 3 min
  of inactivity, resumes on user interaction, integrates with Page Visibility API
- Notification center: bell icon in header with unread badge, event history panel
  with mark-read/clear, persists across page views via sessionStorage

* feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock

- i18n: English/Polish translations with auto-detection, language selector
  in header, data-i18n attributes on dashboard elements, localStorage persistence
- Screenshot tool (S key): captures active tab to clipboard or downloads PNG,
  flash effect, canvas rendering with watermark, fallback for tainted canvases
- Quick settings panel (gear icon): reduced motion toggle, high contrast mode,
  compact layout mode, health polling toggle, clear data, reset onboarding
- Uptime clock: current time + session duration in header
- prefers-reduced-motion: system-level and manual toggle, disables all
  animations and transitions for vestibular accessibility
- High contrast mode: WCAG AAA compliant colors for both light and dark themes
- Compact mode: condensed layout for dense information display
2026-05-19 10:04:59 -04:00
NgoQuocViet2001 3439fb1402 fix(provision): recognize swarm/hopping flags as config values (#617) 2026-05-19 10:03:58 -04:00
Rahul c00f45e296 fix(sensing): finish #611 NaN-panic audit — 7 more sites missed by #613 (#624)
#613 fixed adaptive_classifier.rs:94 (the IQR sort) and called the audit
done, but the grep used `partial_cmp(b).unwrap()` as a literal and missed
seven additional production sites that use comparator variants:

  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
                              IQR fix.
  adaptive_classifier.rs:480  train() argmax (training accuracy loop)
  adaptive_classifier.rs:500  train() per-class argmax
  main.rs:2446, 2449          count_persons_mincut variance source/sink select
  csi.rs:602, 605             count_persons_mincut variance source/sink select
                              (duplicate of main.rs logic in csi.rs)

For the variance-select sites, note that the *outer* `unwrap_or((0, &0))`
only catches an empty iterator — it cannot rescue a panic raised inside
the comparator. A single NaN in `variances[]` still aborts the process.

Same fix as #613: swap `.unwrap()` for `.unwrap_or(std::cmp::Ordering::Equal)`
inside the comparator closure. Pure behavioural change, no API surface.

Re-audit of the remaining `partial_cmp(...).unwrap()` matches in v2/:
they are all inside `#[cfg(test)]` / `#[test]` blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477, vital_signs.rs:737) where inputs are
controlled and panic-on-NaN is acceptable.
2026-05-19 10:02:08 -04:00
Blossom f54f0285bd fix(ci): build multi-arch wifi-densepose image — linux/arm64 was missing (closes #625) (#631)
PR #547 refreshed the sensing-server docker publish and the README badge
advertises 'Docker: multi-arch amd64 + arm64', but
.github/workflows/sensing-server-docker.yml only sets
'platforms: linux/amd64'. The arm64 layer was never actually wired in.

Consequence on Docker Hub today (ruvnet/wifi-densepose:latest, last pushed
2026-05-14 by #547):

  $ curl -s https://hub.docker.com/v2/repositories/ruvnet/wifi-densepose/tags/latest/
  images:
    arch=amd64    os=linux
    arch=unknown  os=unknown   # the 1.5KB attestation layer, not arm64

So Apple Silicon Macs (the platform in #625) hit:

  docker pull ruvnet/wifi-densepose:latest
  Error: no matching manifest for linux/arm64/v8 in the manifest list

This is the same crash class as the closed-unmerged #136 'Docker error on
MacOS'; #625 is a fresh report (Mac M3 Pro, macOS Tahoe 26.4.1) of the same
bug.

Fix is the standard buildx multi-arch recipe:

  1. Add docker/setup-qemu-action@v3 before setup-buildx so the amd64 runner
     can cross-build the arm64 layer (QEMU user-mode emulation).
  2. Change 'platforms: linux/amd64' -> 'platforms: linux/amd64,linux/arm64'.

docker/Dockerfile.rust is already arch-agnostic — no '--target' flag, no
amd64-only Cargo deps, only 'cc = "1.0"' which is cross-aware — so no
Dockerfile changes are needed. Buildx + QEMU does the rest.

Smoke tests are unaffected: they 'docker pull' on ubuntu-latest (amd64), so
the runner auto-selects the amd64 entry from the multi-arch manifest.
Multi-arch manifests are transparent to single-arch consumers.

Scope discipline: this PR only touches sensing-server-docker.yml (the file
issue #625 is about). nvsim-server-docker.yml has the identical
'platforms: linux/amd64' bug but is out of scope here — happy to file
a follow-up if useful.

Note (not part of this fix): the last 5 runs of this workflow have failed
at the 'Log in to Docker Hub' step (DOCKERHUB_TOKEN secret looks rotated/
expired). That's a separate, secret-side issue I can't touch from a PR.
Once that's resolved, the next push to main will produce a proper
amd64+arm64 manifest for the first time.

Co-authored-by: Mack Ding <mack@claws.ltd>
2026-05-19 10:02:00 -04:00
Winter Lau e964eaf14f fix(deps): bump ndarray 0.15→0.17 and ndarray-npy 0.8→0.10 (closes #626) (#627) 2026-05-19 10:01:52 -04:00
rUv 961c01f4bd Merge pull request #633 from ruvnet/integrate/pr-491-adaptive-person-count
Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor (integration on schwarztim's behalf)
2026-05-19 08:26:36 -04:00
ruv 79cc2d7b22 Merge #491: feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
Integrating @schwarztim's PR #491 into main on their behalf — their fork has
fallen too far behind for a clean rebase (the PR's commit graph dropped
silently during `git rebase origin/main`), so applying as a merge from the
fork head to preserve the diff cleanly.

What this lands:
- `RollingP95` adaptive normaliser for the person-count feature scaling.
  Streaming P95 over a 600-sample / ~30 s sliding window. Cold-start
  (<60 samples) falls back to the legacy denominators (variance/300,
  motion_band_power/250, spectral_power/500) so day-0 behaviour is
  preserved on every deployment.
- `RuntimeConfig` struct + `load_runtime_config` / `save_runtime_config`
  persisted to `data/config.json`. Exposes `dedup_factor` via REST so
  multi-node deployments can tune cluster-deduplication without a rebuild,
  including an auto-tune endpoint that derives optimal dedup from a known
  person count (calibration mode).
- `compute_person_score()` now takes &AppStateInner alongside &FeatureInfo
  so the adaptive denominators are reachable. All 3 call sites updated.
- New `AppStateInner` fields: `p95_variance`, `p95_motion_band_power`,
  `p95_spectral_power`, `dedup_factor`, `data_dir`.

Closes #491. Directly addresses:
- #499 (double skeletons, multi-node) — the slot-clustering problem this
  PR's adaptive normaliser was designed to fix
- #519 Bug 1 (ghost person detection on edge-tier 1 & 2 multi-node)
- #496 (person count over-reporting on single-room single-person)

Verified locally:
- cargo check -p wifi-densepose-sensing-server --no-default-features: 1.0s
- cargo test -p wifi-densepose-sensing-server --no-default-features --lib:
  233/233 passed in 25.0s

Co-authored-by: @schwarztim
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-19 08:25:47 -04:00
rUv f5e2b5474b release: ESP32-S3 firmware v0.6.5 — Tmr Svc stack + OTA init refactor (#628)
Three fixes wrapped for the v0.6.5-esp32 release tag:

1. **`sdkconfig.defaults` adds `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192`**.
   The fix was already in `sdkconfig.defaults.template` (ADR-081, prevents
   "stack overflow in task Tmr Svc" bootloop when adaptive_controller emits
   feature_state from inside a Timer Svc callback). It was MISSING from the
   canonical `sdkconfig.defaults` file used by the build, so any fresh
   build picked up the 2 KiB FreeRTOS default and bootlooped on hardware.
   Verified on COM7: with the fix, no panics in 30 s of operation; without
   it, "***ERROR*** A stack overflow in task Tmr Svc has been detected."
   followed by sustained bootloop.

2. **`ota_update.c` extracts `ota_load_psk_from_nvs()` and calls it from
   both `ota_update_init()` and `ota_update_init_ex()`.** `main.c:230` uses
   the `_ex` variant, but only `ota_update_init()` was loading the PSK
   from NVS. Result: `s_ota_psk` stayed empty regardless of NVS contents,
   so the RuView#596 fail-closed posture rejected every request — but the
   diagnostic warning never printed at boot, leaving operators no signal
   about why their OTA uploads were 403'ing. Verified on COM7:
       W (3126) ota_update: NVS namespace 'security' not found —
       OTA upload endpoint will REJECT all requests until provisioned.
       Fail-closed per RuView#596.

3. **`version.txt`: 0.6.4 → 0.6.5**, paired with the v0.6.5-esp32 tag so the
   firmware-ci version-guard job (RuView#505 fix-marker) stays happy.

Both validations done end-to-end on hardware (COM7, ESP32-S3 8MB,
provisioned with --edge-tier 2 to also incidentally re-verify #438 is not
reproducible on current main).
2026-05-18 17:05:35 -04:00
rUv 281c4cb0ce fix(firmware): OTA upload fails closed when no PSK in NVS (RuView#596 audit) (#623)
ota_check_auth() previously returned true when s_ota_psk[0] == '\0'
("permissive for dev"). A freshly-flashed node — or any node where
nobody had provisioned an OTA PSK yet — accepted attacker-controlled
firmware over plain HTTP on port 8032 from any host on the WiFi. No
Secure Boot V2, no signed-image verification, no transport encryption.
Single LAN call could brick or backdoor a node.

This was flagged in the deep security review of PR #596 but was a
PRE-EXISTING bug in main, not new code from that PR — so it stood as
a critical-severity production issue until this commit.

Fix:
- ota_check_auth() now returns false when no PSK is provisioned, with
  ESP_LOGW("OTA rejected: no PSK in NVS …") at the call site so the
  operator can diagnose the rejection from serial logs
- ota_update_init() ESP_LOGW message updated to surface the new posture
  at boot ("upload endpoint will REJECT all requests until provisioned")
- Doc comment on ota_check_auth() rewritten to make the contract
  explicit and reference the audit

The OTA HTTP server itself still starts even when no PSK is set. That
lets the operator run `provision.py --ota-psk <hex>` over USB-CDC to
write the NVS key without reflashing the firmware. The upload endpoint
just refuses every request in the meantime.

Breaking change for any deployment that depended on the unauthenticated
OTA path working out of the box. Documented in CHANGELOG under
[Unreleased] / Security so it's visible at the next release cut.

Fix-marker RuView#596-ota-fail-closed (scripts/fix-markers.json)
requires the new behaviour and forbids the old "permissive for dev"
fallback strings, so a future revert fails CI.
2026-05-18 08:56:07 -04:00
rUv b2e2e6d6fd fix(sensing-server): WS broadcast emits effective_source() not hardcoded "esp32" (closes #618) (#621)
Reported by @ArnonEnbar with a complete reproduction.

broadcast_tick_task() re-emits the cached `latest_update` every tick so
pose WS clients keep getting data even when ESP32 pauses between
frames. The `source` field of that cached update was set to "esp32" at
the moment a fresh ESP32 frame was last decoded (main.rs:3885, :4136).

After the ESP32 loses power or network, no fresh frame is decoded —
the cached `latest_update` is still re-broadcast every tick with the
stale source: "esp32" baked in. UI's "Sensing" tab keeps showing
"LIVE — ESP32 HARDWARE Connected" with frozen vitals/features/
classification re-broadcast indefinitely. REST `/health` correctly
reports source: "esp32:offline" (via effective_source(), which checks
last_esp32_frame elapsed time against ESP32_OFFLINE_TIMEOUT=5s) — but
the WS broadcast path was the one consumer that didn't call it.

Fix: clone the cached update per tick, overwrite source with
s.effective_source(), then serialize and broadcast. UI now switches to
"esp32:offline" on the same 5s budget as the REST surface.

cargo build -p wifi-densepose-sensing-server --no-default-features:
17s, no errors (1 pre-existing unused-import warning unchanged).
2026-05-18 08:18:18 -04:00
rUv 72bbd256e7 fix(security): path-traversal guard on 5 sensing-server endpoints (closes #615) (#616)
Reported by @bannned-bit. Five endpoints in
v2/crates/wifi-densepose-sensing-server embedded user-controlled
identifiers in format!() paths with no sanitization:

  recording.rs       POST   /api/v1/recording/start       (session_name)
  recording.rs       GET    /api/v1/recording/download/:id (id)
  recording.rs       DELETE /api/v1/recording/delete/:id   (id)
  model_manager.rs   POST   /api/v1/models/load           (model_id)
  training_api.rs    load_recording_frames                (dataset_ids[])

Each unauthenticated caller could:
- READ arbitrary files via ../../etc/passwd, ../../.env, etc.
- WRITE attacker-controlled JSONL via recording/start
- LOAD attacker-controlled .rvf model files
- DELETE arbitrary files the server process can touch

New `path_safety` module exports `safe_id(&str) -> Result<&str, PathSafetyError>`
that enforces the rejection envelope BEFORE any user input reaches a
format!() that builds a path:

  - Allowed character set: [A-Za-z0-9._-]
  - Reject leading '.' (rules out '.', '..', '.env', hidden files)
  - Reject empty strings
  - Reject anything > 64 bytes
  - Reject all whitespace, path separators, null bytes, non-ASCII

Applied at all 5 sites. Errors return 400 Bad Request (download) /
status:"error" JSON (others) — not panics.

9 unit tests in path_safety::tests cover:
  - accepts simple alphanumeric / hyphen / underscore / dot
  - rejects empty, leading dot, path separators ('/', '\'),
    null byte, whitespace, shell specials, non-ASCII (including
    fullwidth slash U+FF0F), too-long, boundary at MAX_ID_LEN

  test result: ok. 9 passed; 0 failed
  cargo build -p wifi-densepose-sensing-server --no-default-features: 33s

Fix-marker RuView#615 in scripts/fix-markers.json prevents removing the
guard at any of the 5 call sites. CHANGELOG entry under [Unreleased] /
Security documents the patched endpoints and the rejection envelope.

Severity: critical per reporter — five remotely-reachable paths to read,
write, or delete arbitrary files. Hot per-request paths, not edge cases.
2026-05-17 19:59:20 -04:00
rUv 50131b2519 fix(verify): cross-platform deterministic proof — 6-decimal quantize + thread-pinning (closes #560) (#609)
* fix(verify): quantize features before SHA-256 for cross-platform hash stability (#560)

## The bug

archive/v1/data/proof/verify.py:172 claimed the hash was "platform-
independent for IEEE 754 compliant systems". That claim is empirically
false. scipy.fft's pocketfft uses SIMD vector kernels — AVX2/AVX-512 on
x86_64, NEON on Apple Silicon — that reorder vectorized FP operations
differently per build. IEEE 754 guarantees per-operation determinism,
not associativity under reordering, so two correct platforms produce
values that differ at ULP precision (~1e-14 at our magnitudes of 1-100).

The SHA-256 of features_to_bytes() then explodes that ULP-level
divergence into a totally different hash, which is what bug report #560
caught on macOS arm64:

| Platform | numpy/scipy | sha256 (legacy) |
|----------|-------------|-----------------|
| Windows (Intel AVX-512)             | 2.4.2 / 1.17.1 | 78b3fb… |
| ruvultra (Linux x86_64)             | 1.26.4 / 1.14.1 | 41dc56… |
| ruv-mac-mini (Apple Silicon NEON)   | 2.4.4 / 1.17.1 | 9b5e19… |

## The fix

features_to_bytes() now np.round(.., HASH_QUANTIZATION_DECIMALS=9)s each
array before packing as little-endian f64. That snaps the float bytes
to a single canonical representation across SIMD backends.

The 9-decimal precision is:
- ~5 orders of magnitude above the worst-case ULP drift observed in
  probe-fft-platform.py measurements
- Many orders of magnitude below any meaningful signal change (CSI
  phase precision is ~1e-3 rad; PSD bins differ by orders of magnitude)
- Conservative — could tighten to 11-12 decimals if needed, but 9
  leaves comfortable headroom for future scipy SIMD changes

## Probe-side verification

scripts/probe-fft-platform.py now emits BOTH sha256_raw (unrounded,
legacy) and sha256_quantized (new platform-invariant hash). Running it
on Windows here produced:

  sha256_raw       = 78b3fb4acb8cc18c3e870f92e29ee98143c7cac4767f2f71b0fc384a82b92f6e
  sha256_quantized = a587792c050cf697366b9bef4611050f9dc3af56624915ab2452c3c11362e79a
  quantization_decimals = 9

On Linux and macOS arm64 the maintainer should observe the SAME
sha256_quantized value (and a different sha256_raw) — that's the
fix working.

## What this PR does NOT do

The published archive/v1/data/proof/expected_features.sha256
(8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6) is
not regenerated by this commit. That step needs to run on a canonical
CI platform (likely the Linux x86_64 host used for releases) AFTER this
fix lands. The regeneration command is:

  python archive/v1/data/proof/verify.py --generate-hash

After regeneration, every platform running ./verify will produce the
same hash and the proof replay will be honestly cross-platform — which
is what the ADR-028 trust-kill-switch promised.

## Files

- archive/v1/data/proof/verify.py — add HASH_QUANTIZATION_DECIMALS=9
  constant, quantize in features_to_bytes(), correct the misleading
  "platform-independent" claim in the docstring
- scripts/probe-fft-platform.py — emit both raw and quantized hashes
- scripts/fix-markers.json — RuView#560 marker prevents removing the
  np.round() call without explicit intent
- CHANGELOG.md — Fixed entry under [Unreleased] documenting the change
  and flagging the expected_features.sha256 regeneration as a follow-up

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: fix verify-pipeline.yml working-directory from v1/ to archive/v1/

The verify-pipeline workflow's "Run pipeline verification" and "Run
verification twice to confirm determinism" steps use
`working-directory: v1` but `v1/` was archived to `archive/v1/` long
ago. The workflow fails before verify.py even runs:

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

Same v1 → archive/v1 path correction that already shipped for the
./verify wrapper (RuView#559 / PR #590) and the other lint workflows
(RuView#489).

Required to make the determinism check actually run on PR #609 (the
quantize-before-hash work) — the canonical Linux hash needed for
expected_features.sha256 will fall out of the next CI log once this
fix lands.

* fix(proof): regenerate expected_features.sha256 with the quantized canonical hash

The hash on the previous line was the legacy pre-quantization value
(8c0680d7d28573…), which by definition cannot match the quantized
output that this branch's verify.py now produces. Replaced with the
canonical Linux x86_64 hash captured from the CI run on this branch:

    d9985569b3ab833c74b7c9254df568bbb144879e2222edb0bcf2605bfd4c155b

Source of truth: run 26005976495 / "Verify Pipeline Determinism (3.11)"
on Ubuntu 24.04, Python 3.11.15, exercising the full verify.py pipeline
on the 100 reference frames in archive/v1/data/proof/sample_csi_data.json.

Reproducibility expectation now changes:
- Linux x86_64 (canonical platform):       sha256 = d9985569…   ✓ this commit
- macOS arm64 / Apple Silicon NEON:        sha256 = d9985569…   should match
                                            after quantization
- Windows AMD64 (with pydantic-clean .env): sha256 = d9985569…   should match
                                            after quantization

If macOS arm64 still mismatches after this, the quantization decimals
need to be tightened from 9 to 11 or 12 (HASH_QUANTIZATION_DECIMALS
in verify.py); the headroom analysis in the original commit suggests
9 is safe but 9-decimal SIMD drift hasn't been measured in the
full-pipeline output yet (only in the probe).

Closes the maintainer-action-required item on PR #609.

* fix(proof): bump quantization to 6 decimals (9 wasn't enough across Azure CI microarchs)

Two back-to-back Ubuntu 24.04 / Python 3.11 / scipy 1.17 CI runs on
PR #609 landed on different Azure VM microarchitectures and produced
two different SHA-256s even after np.round(.., 9):

  Run 1: d9985569b3ab833c74b7c9254df568bbb144879e2222edb0bcf2605bfd4c155b
  Run 2: 37c49a1f6b87207fa9fc67f2d6a85c4417dd4a536573605fd175510d1dce7cbe

Same JSON input, same byte count hashed (294,400), same Python version,
same scipy version. The only variable is the underlying CPU pocketfft
SIMD kernel.

The full DSP pipeline (preprocess → biquad bandpass → FFT → PSD →
variance accumulation) amplifies the ~1e-14 raw FFT divergence by
several orders of magnitude — the actual drift at features_to_bytes()
input can reach 1e-7 or worse, which is well within the 1e-9 quantization
window I originally picked.

Bumping to 6 decimals = parts per million. ~6 orders of magnitude
headroom over observed pipeline-amplified ULP drift. Still far below
any meaningful signal change (CSI phase precision ~1e-3 rad). Kept the
probe constant in sync.

Will trigger CI on this branch immediately after push; the new
expected_features.sha256 will be regenerated from whichever microarch
the next CI run lands on, but should be stable across all subsequent
runs at 6-decimal quantization.

* chore(probe): keep HASH_QUANTIZATION_DECIMALS in sync with verify.py (now 6)

* fix(proof): regenerate expected_features.sha256 for 6-decimal quantization

* ci: pin thread count to 1 for proof verification (scipy.fft threading non-determinism)
2026-05-17 19:50:55 -04:00
rUv 50136c920d fix(archive/v1/pose-service): call sanitize_phase, not sanitize (closes #612) (#614)
Reported by @bannned-bit. archive/v1/src/services/pose_service.py:223:

    sanitized_phase = self.phase_sanitizer.sanitize(phase_data)

PhaseSanitizer exposes the full-pipeline entry point as `sanitize_phase`
(unwrap_phase + remove_outliers + smooth_phase), not `sanitize`. The
shorter name doesn't exist on the class, so any path that reaches this
branch raises AttributeError mid-frame and crashes the pose service.

archive/v1/src/core/phase_sanitizer.py:266 is the canonical name:

    def sanitize_phase(self, phase_data: np.ndarray) -> np.ndarray:
        """Sanitize phase data through complete pipeline."""

One-line rename. No other call sites use the wrong name; verified with
grep -rn 'phase_sanitizer\.sanitize\b' archive/v1/src/.

This is v1 archived code, but the proof verify path still exercises it
(./verify reaches into archive/v1/src/), so the bug was a latent
regression risk for the trust-kill-switch flow.
2026-05-17 19:34:08 -04:00
rUv 3bd70f7910 fix(sensing): adaptive_classifier sorts with unwrap_or(Equal) — NaN panic (closes #611) (#613)
Reported by @bannned-bit. v2/crates/wifi-densepose-sensing-server/src/
adaptive_classifier.rs:94 did:

    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());

f64::partial_cmp returns None on NaN, so `.unwrap()` panics. CSI data
from real ESP32 hardware can produce NaN (silent DSP div-by-zero,
empty buffer, etc.), and this code path runs on every frame in the
classify() hot path — a single NaN frame kills the entire sensing
server process.

Fix swaps for unwrap_or(Ordering::Equal), matching the pattern the
same file already uses at lines 149-150 and 155 (those sites were
already NaN-safe; this site was an oversight).

Scoped audit: greped the v2/ tree for `partial_cmp(b).unwrap()`. The
other 3 hits are in #[cfg(test)] blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477) where panic-on-NaN is acceptable
because test inputs are controlled. Only adaptive_classifier.rs:94
was a production-path crash.

Severity: critical per reporter — runtime panic on real-world data.
Patch: 1-line behavioural change + comment.
2026-05-17 19:29:07 -04:00
rUv 6f5ac3aa5a fix(ui): clamp deltaTime to 1ms in pose-renderer FPS calc (#519 Bug 2) (#610)
When two render frames land in the same performance.now() tick,
`currentTime - lastFrameTime === 0`, so `fps = 1000 / 0 = Infinity`,
and `averageFps = averageFps * 0.9 + Infinity * 0.1 = Infinity` poisons
the EMA forever after a single zero-dt tick. The UI then displays
"Infinity FPS" until reload.

Floor deltaTime at 1 ms before the division. That caps displayed FPS at
1000 (far above any real render rate so the cap is never observed in
practice) but keeps the EMA finite.

Reported in #519 ("Bug 2 — FPS shows Infinity") by @kapilsoni2013 on a
3-node ESP32-S3-WROOM multi-node setup with edge-tier 1 + 2.
2026-05-17 19:16:00 -04:00
rUv 1b155ad027 chore: remove empty stub crates wifi-densepose-{api,db,config} (closes #578) (#608)
Each of these crates was a single-line doc-comment placeholder:

  v2/crates/wifi-densepose-api/src/lib.rs:    //! WiFi-DensePose REST API (stub)
  v2/crates/wifi-densepose-db/src/lib.rs:     //! WiFi-DensePose database layer (stub)
  v2/crates/wifi-densepose-config/src/lib.rs: //! WiFi-DensePose configuration (stub)

with empty [dependencies] in their Cargo.toml and zero references from any
source file or Cargo.toml in the workspace (verified by `grep -rln
wifi-densepose-api/-db/-config` across `v2/`). They were reserved early for
an envisioned REST/database/config split that never materialised.

The functionality these would have provided is covered today by:
- REST/WS:  wifi-densepose-sensing-server (Axum)
- Config:   per-crate config + CLI args in sensing-server and desktop
- DB:       no persistent state; system is real-time

Removal prevents `cargo` from listing dead crates, shipping empty published
artifacts to crates.io, or wasting reviewer attention. If any of these names
is needed in the future, reintroduce them with a real implementation.

Per the issue reporter (@bannned-bit / Matad0r) #578 explicitly listed
"OR be removed from workspace members until implementation starts" as an
acceptable resolution.

Updated:
- `v2/Cargo.toml`: drop the three members (with inline comment explaining why)
- `v2/Cargo.lock`: regenerated by cargo check
- `CLAUDE.md`: drop the three rows from the crate table and the publishing
  order list
- `CHANGELOG.md`: add an `[Unreleased] / Removed` entry

Verified:
- `cd v2 && cargo check --workspace --no-default-features` -> finished
  in 48s, no errors (warnings unchanged)
2026-05-17 18:50:57 -04:00
Mathew005 fa28318bae fix(led): disable onboard WS2812 LED during CSI collection (#273) 2026-05-17 18:18:10 -04:00
Grzegorz Malopolski ec73109d57 docs: add visual architecture overview images (#208)
Co-authored-by: Grzegorz Małopolski <grzegorzmalopolskipraca@gmail.com>
2026-05-17 18:18:07 -04:00
OrbisAI Security acbd3ff13c refactor(mmwave): use sizeof() in mr60_process_frame bounds checks (#414)
Automated security fix generated by Orbis Security AI
2026-05-17 18:15:01 -04:00
dependabot[bot] 07086c5d9d chore(deps): bump react-dom from 19.2.0 to 19.2.6 in /ui/mobile (#463)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.0 to 19.2.6.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:12:01 -04:00
dependabot[bot] 0310b1fa9a chore(deps): bump @tauri-apps/plugin-dialog (#462)
Bumps [@tauri-apps/plugin-dialog](https://github.com/tauri-apps/plugins-workspace) from 2.6.0 to 2.7.0.
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.6.0...log-v2.7.0)

---
updated-dependencies:
- dependency-name: "@tauri-apps/plugin-dialog"
  dependency-version: 2.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:58 -04:00
dependabot[bot] 9daa8c3078 chore(deps): update asyncio-mqtt requirement from >=0.11.0 to >=0.16.2 (#460)
Updates the requirements on [asyncio-mqtt](https://github.com/sbtinstruments/asyncio-mqtt) to permit the latest version.
- [Release notes](https://github.com/sbtinstruments/asyncio-mqtt/releases)
- [Changelog](https://github.com/empicano/aiomqtt/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sbtinstruments/asyncio-mqtt/commits)

---
updated-dependencies:
- dependency-name: asyncio-mqtt
  dependency-version: 0.16.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:53 -04:00
dependabot[bot] ffa808ed4b chore(deps-dev): bump eslint from 10.0.2 to 10.2.1 in /ui/mobile (#459)
Bumps [eslint](https://github.com/eslint/eslint) from 10.0.2 to 10.2.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.2...v10.2.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:49 -04:00
dependabot[bot] 59dbb76757 chore(deps-dev): bump @typescript-eslint/eslint-plugin in /ui/mobile (#458)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.56.1 to 8.59.3.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.3/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:46 -04:00
dependabot[bot] 4ecc053a27 chore(deps-dev): bump typescript in /v2/crates/wifi-densepose-desktop/ui (#456)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:41 -04:00
dependabot[bot] 5170b99aca chore(deps): bump codecov/codecov-action from 4 to 6 (#454)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:36 -04:00
dependabot[bot] c16dc9f80a chore(deps): bump actions/setup-python from 5 to 6 (#453)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:33 -04:00
dependabot[bot] 04ccfcde56 chore(deps-dev): bump prettier from 3.8.1 to 3.8.3 in /ui/mobile (#452)
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:30 -04:00
dependabot[bot] 4d45add824 chore(deps): bump react-dom and @types/react-dom (#451)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together.

Updates `react-dom` from 18.3.1 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

Updates `@types/react-dom` from 18.3.7 to 19.2.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:26 -04:00
dependabot[bot] 562cb7461f chore(deps): bump anchore/scan-action from 3 to 7 (#450)
Bumps [anchore/scan-action](https://github.com/anchore/scan-action) from 3 to 7.
- [Release notes](https://github.com/anchore/scan-action/releases)
- [Changelog](https://github.com/anchore/scan-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/scan-action/compare/v3...v7)

---
updated-dependencies:
- dependency-name: anchore/scan-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:22 -04:00
dependabot[bot] fad6828697 chore(deps): bump docker/metadata-action from 5 to 6 (#449)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:18 -04:00
dependabot[bot] 807bf0b32a chore(deps): bump docker/build-push-action from 5 to 7 (#448)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:15 -04:00
dependabot[bot] 4b602c79dd chore(deps): bump actions/setup-node from 4 to 6 (#447)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:11:11 -04:00
dependabot[bot] 76321ce4bc chore(deps): bump zustand from 5.0.11 to 5.0.12 in /ui/mobile (#474)
Bumps [zustand](https://github.com/pmndrs/zustand) from 5.0.11 to 5.0.12.
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.11...v5.0.12)

---
updated-dependencies:
- dependency-name: zustand
  dependency-version: 5.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:10:09 -04:00
dependabot[bot] 1690aea22a chore(deps): update websockets requirement from >=10.4 to >=15.0.1 (#472)
Updates the requirements on [websockets](https://github.com/python-websockets/websockets) to permit the latest version.
- [Release notes](https://github.com/python-websockets/websockets/releases)
- [Commits](https://github.com/python-websockets/websockets/compare/10.4...15.0.1)

---
updated-dependencies:
- dependency-name: websockets
  dependency-version: 15.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:10:05 -04:00
dependabot[bot] a80617ee84 chore(deps): bump console from 0.15.11 to 0.16.3 in /v2 (#471)
Bumps [console](https://github.com/console-rs/console) from 0.15.11 to 0.16.3.
- [Release notes](https://github.com/console-rs/console/releases)
- [Changelog](https://github.com/console-rs/console/blob/main/CHANGELOG.md)
- [Commits](https://github.com/console-rs/console/compare/0.15.11...0.16.3)

---
updated-dependencies:
- dependency-name: console
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:10:01 -04:00
dependabot[bot] 75dc302952 chore(deps): bump @react-navigation/bottom-tabs in /ui/mobile (#470)
Bumps [@react-navigation/bottom-tabs](https://github.com/react-navigation/react-navigation/tree/HEAD/packages/bottom-tabs) from 7.15.3 to 7.15.10.
- [Release notes](https://github.com/react-navigation/react-navigation/releases)
- [Changelog](https://github.com/react-navigation/react-navigation/blob/@react-navigation/bottom-tabs@7.15.10/packages/bottom-tabs/CHANGELOG.md)
- [Commits](https://github.com/react-navigation/react-navigation/commits/@react-navigation/bottom-tabs@7.15.10/packages/bottom-tabs)

---
updated-dependencies:
- dependency-name: "@react-navigation/bottom-tabs"
  dependency-version: 7.15.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:09:58 -04:00
dependabot[bot] afc86c6fc4 chore(deps): bump thiserror from 1.0.69 to 2.0.18 in /v2 (#469)
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 1.0.69 to 2.0.18.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.69...2.0.18)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.18
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:09:54 -04:00
dependabot[bot] fc654034b3 chore(deps): bump axios from 1.13.6 to 1.15.2 in /ui/mobile (#467)
Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:09:50 -04:00
dependabot[bot] c4653b8bc6 chore(deps-dev): update pytest-benchmark requirement (#465)
Updates the requirements on [pytest-benchmark](https://github.com/ionelmc/pytest-benchmark) to permit the latest version.
- [Changelog](https://github.com/ionelmc/pytest-benchmark/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/ionelmc/pytest-benchmark/compare/v4.0.0...v5.2.3)

---
updated-dependencies:
- dependency-name: pytest-benchmark
  dependency-version: 5.2.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:09:45 -04:00
dependabot[bot] d214855228 chore(deps): bump react-native from 0.83.2 to 0.85.2 in /ui/mobile (#473)
Bumps [react-native](https://github.com/facebook/react-native/tree/HEAD/packages/react-native) from 0.83.2 to 0.85.2.
- [Release notes](https://github.com/facebook/react-native/releases)
- [Changelog](https://github.com/facebook/react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react-native/commits/v0.85.2/packages/react-native)

---
updated-dependencies:
- dependency-name: react-native
  dependency-version: 0.85.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:08:12 -04:00
dependabot[bot] e6710e8988 chore(deps): bump ndarray-linalg from 0.16.0 to 0.18.1 in /v2 (#477)
Bumps [ndarray-linalg](https://github.com/rust-ndarray/ndarray-linalg) from 0.16.0 to 0.18.1.
- [Release notes](https://github.com/rust-ndarray/ndarray-linalg/releases)
- [Commits](https://github.com/rust-ndarray/ndarray-linalg/compare/ndarray-linalg-v0.16.0...ndarray-linalg-v0.18.1)

---
updated-dependencies:
- dependency-name: ndarray-linalg
  dependency-version: 0.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:08:08 -04:00
dependabot[bot] ab9799adc3 chore(deps): bump tower-http from 0.5.2 to 0.6.8 in /v2 (#483)
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.5.2 to 0.6.8.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.5.2...tower-http-0.6.8)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:08:04 -04:00
dependabot[bot] bdb4484259 chore(deps): bump tch from 0.14.0 to 0.24.0 in /v2 (#482)
Bumps [tch](https://github.com/LaurentMazare/tch-rs) from 0.14.0 to 0.24.0.
- [Release notes](https://github.com/LaurentMazare/tch-rs/releases)
- [Changelog](https://github.com/LaurentMazare/tch-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/LaurentMazare/tch-rs/commits)

---
updated-dependencies:
- dependency-name: tch
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:08:01 -04:00
dependabot[bot] ba370c7b08 chore(deps): bump tabled from 0.15.0 to 0.20.0 in /v2 (#481)
Bumps [tabled](https://github.com/zhiburt/tabled) from 0.15.0 to 0.20.0.
- [Changelog](https://github.com/zhiburt/tabled/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zhiburt/tabled/commits)

---
updated-dependencies:
- dependency-name: tabled
  dependency-version: 0.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:57 -04:00
dependabot[bot] 3fdd310f89 chore(deps): bump tauri-plugin-dialog from 2.6.0 to 2.7.1 in /v2 (#480)
Bumps [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) from 2.6.0 to 2.7.1.
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.6.0...log-v2.7.1)

---
updated-dependencies:
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:53 -04:00
dependabot[bot] 98e7eeda42 chore(deps): bump ruvector-core from 2.0.5 to 2.2.0 in /v2 (#479)
Bumps [ruvector-core](https://github.com/ruvnet/ruvector) from 2.0.5 to 2.2.0.
- [Release notes](https://github.com/ruvnet/ruvector/releases)
- [Changelog](https://github.com/ruvnet/RuVector/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ruvnet/ruvector/compare/v2.0.5...v2.2.0)

---
updated-dependencies:
- dependency-name: ruvector-core
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:37 -04:00
dependabot[bot] 5615edb24e chore(deps): bump ruvector-temporal-tensor from 2.0.4 to 2.0.6 in /v2 (#476)
Bumps [ruvector-temporal-tensor](https://github.com/ruvnet/ruvector) from 2.0.4 to 2.0.6.
- [Release notes](https://github.com/ruvnet/ruvector/releases)
- [Changelog](https://github.com/ruvnet/RuVector/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ruvnet/ruvector/commits)

---
updated-dependencies:
- dependency-name: ruvector-temporal-tensor
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:33 -04:00
dependabot[bot] 9cc9419db9 chore(deps): update aiosqlite requirement from >=0.19.0 to >=0.22.1 (#478)
Updates the requirements on [aiosqlite](https://github.com/omnilib/aiosqlite) to permit the latest version.
- [Changelog](https://github.com/omnilib/aiosqlite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omnilib/aiosqlite/compare/v0.19.0...v0.22.1)

---
updated-dependencies:
- dependency-name: aiosqlite
  dependency-version: 0.22.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:30 -04:00
dependabot[bot] d544b8f070 chore(deps): update aiohttp requirement from >=3.8.0 to >=3.13.5 (#475)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 18:07:26 -04:00
rUv d33962eff2 fix(docker): UDP relay for multi-source ESP32 on Docker Desktop Windows (#502)
Docker Desktop on Windows demultiplexes inbound UDP from multiple source
IPs onto a single virtual socket, silently dropping packets from all but
one ESP32 node. This makes multi-node sensing setups appear to work
(WebSocket connects, packets flow on the host) while only one node's CSI
ever reaches the container.

Adds scripts/udp-relay.py (stdlib only) which collapses multi-source UDP
to a single loopback source so Docker's forwarding accepts every packet.
Verified locally: 6 packets from 3 distinct source ports all arrive at
the receiver from a single relay socket.

Updates docker/docker-compose.yml with an inline comment pointing
Windows users at the relay + 5006:5005 mapping. Linux/macOS hosts are
unaffected and need no changes.

Also documents the workaround alongside fixes for #188 (UI 404 from
relative --ui-path) and #438 (boot loop on --edge-tier 1/2 against
pre-v0.4.3.1 firmware) as new sections 9-11 of docs/TROUBLESHOOTING.md.
Supersedes the docs-only PR #413.

Closes #374, #386
Refs #188, #438, #301
2026-05-17 18:01:44 -04:00
Chaitanya Tata e22a24714a firmware/esp32-hello-world: ESP32-C6 target and ESP-IDF v6 build fixes (#524)
- Default sdkconfig.defaults to esp32c6
- Fix removed SOC_* macros for ESP-IDF v6; probe_peripherals split for S3 vs C6.
- Banner and WiFi/BLE/power strings are target-aware; add CHIP_ESP32C6 name.
- Ignore esp32-hello-world/sdkconfig.old from idf.py set-target.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:00:45 -04:00
Chaitanya Tata cee414f3c0 firmware/esp32-csi-node: IDF 6 build, HE CSI config, unicore DSP, provision chip detect (#522)
* firmware/esp32-csi-node: fix IDF 6 build (PSA SHA-256, explicit REQUIRES)

- rvf_parser: use psa_hash_* / psa_hash_compute; mbedTLS 4 has no public
  mbedtls/sha256.h on the IDF include path.
- main/CMakeLists: declare REQUIRES for WiFi, netif, HTTP, OTA, drivers, lwip,
  mbedtls per ESP-IDF v6 component dependency checks; optional wasm3 when
  CONFIG_WASM_ENABLE.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: fix CSI config for Wi-Fi 6 (ESP32-C6)

When CONFIG_SOC_WIFI_HE_SUPPORT is set, wifi_csi_config_t is the
wifi_csi_acquire_config_t bitfield layout. The legacy bool fields
(lltf_en, htltf_en, ...) only apply to ESP32-S3-class targets.

Initialize acquire fields for HE targets; add MAC v3-only members when
CONFIG_SOC_WIFI_MAC_VERSION_NUM >= 3.

Verified: idf.py build for esp32c6 and esp32s3 (ESP-IDF v6.1).

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: pin edge DSP task for unicore (ESP32-C6)

edge_processing_init used xTaskCreatePinnedToCore(..., core 1). ESP32-C6
runs FreeRTOS unicore (portNUM_PROCESSORS == 1), so core 1 trips the
xTaskCreatePinnedToCore range assert right after CSI init.

Use core 1 only when SMP is available; otherwise pin to core 0.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: provision NVS with chip auto-detect

provision.py always passed --chip esp32s3 to esptool, so flashing NVS on
ESP32-C6 failed. Default --chip to auto (esptool v5) and add an explicit
--chip override. Use write-flash instead of deprecated write_flash.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:00:40 -04:00
Chaitanya Tata f853c74563 v2: pin Rust 1.89 and fix sensing-server UI path when run from v2 (#523)
* v2: pin Rust 1.89 for sensing-server dependency chain

ruvector-core 2.0.5, hnsw_rs 0.3.4, and mmap-rs 0.7 require newer Cargo/rustc
than 1.82 (edition2024 manifest, is_multiple_of, stable avx512f target_feature
on x86_64). Add v2/rust-toolchain.toml so cargo build -p
wifi-densepose-sensing-server picks a compatible toolchain.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* sensing-server: default UI path for cwd v2/ and coalesce fallbacks

The previous default ../../ui resolves to a non-existent directory when
the binary is run from v2/ (common), so /ui/* returned 404 and the
dashboard appeared broken. Default to ../ui and try ../ui, ./ui,
../../ui when the configured path is missing.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:00:36 -04:00
Timothy Schwarz 8b297dd706 fix(sensing-server): handle WebSocket Lagged + add ping keepalive (#484)
Root cause: broadcast channel Lagged error caused instant disconnect
when clients fell behind 256 frames (10Hz * 50-200KB = easy to lag).
Client reconnects, immediately lags again, rapid cycling ensues.

Sensing handler: Lagged error now continues (skips missed frames)
instead of breaking. Added 30s ping interval for proxy keepalive.
Pose handler: same Lagged handling + Pong match arm.

CHANGELOG updated under Unreleased/Fixed.

Co-authored-by: Deploy Bot <deploy@example.com>
2026-05-17 17:57:02 -04:00
rUv 9d4f7820b2 docs(adr): ADR-098 — evaluate midstream for RuView's CSI/WS/mesh pipeline (Rejected) (#553)
`vendor/midstream` is a git submodule of RuView but no `v2/crates/*` depends
on a `midstreamer-*` crate and no Rust source uses one — i.e. it is vendored
but not consumed, the same state `vendor/rvcsi` was in before ADR-097.

ADR-098 evaluates whether to change that. The candidate seams (from the
prompt) were:

  1. Streaming / pub-sub for the WS fan-out (today: `tokio::sync::broadcast`
     at `wifi-densepose-sensing-server/src/main.rs:4769`).
  2. CSI → DSP → event pipeline (today: rvcsi-events::EventPipeline, just
     adopted by ADR-097).
  3. Multi-source merging / TDM for the ESP32 mesh (ADR-029, ADR-073).
  4. Backpressure / flow control between the UDP receiver and downstream
     consumers (firmware `stream_sender` ENOMEM; host-side bounded
     broadcast channel).

Reading all six midstream workspace crates end-to-end
(`vendor/midstream/crates/{temporal-compare,nanosecond-scheduler,
temporal-attractor-studio,temporal-neural-solver,strange-loop,
quic-multistream}/src/*.rs` — ~3,455 LOC) shows midstream's identity
unambiguously: `Cargo.toml:16` calls itself "Real-time LLM streaming with
inflight analysis", the README frames it as analyzing *LLM token streams*
in real time, and zero hits across the workspace for `csi|wifi|sensing|
sensor`. midstream's abstractions are LLM-token / dashboard-telemetry
shaped; RuView's pipeline is RF-frame / event-detector shaped.

Decisions:

  D1 — WS fan-out: keep `tokio::sync::broadcast::channel::<String>(256)`.
       midstream offers no equivalent in-process broadcast primitive.
  D2 — CSI pipeline: keep `rvcsi-events::EventPipeline` (deterministic,
       single-frame-at-a-time, replayable per ADR-095 D9). midstream's
       attractor / LTL crates operate on multi-dimensional trajectories,
       not validated single CSI frames.
  D3 — TDM / aggregator: keep `wifi-densepose-hardware::aggregator` +
       firmware-side TDM. midstream has no UDP merger and no cross-device
       wall-clock scheduler.
  D4 — Backpressure: the firmware ENOMEM rate-limit and the bounded host
       `broadcast` channel are correct at each end; midstream's QUIC
       primitives don't help the actual UDP+WS topology.
  D5 — Carve-out: `midstreamer-temporal-compare` (DTW / LCS / Levenshtein)
       is a plausible future-evaluation option if a *second* DTW use case
       appears in RuView. RuvSense already has one (`gesture.rs`).
  D6 — Carve-out: `midstreamer-scheduler` (deadline-aware, EDF / LLF /
       RM) is a plausible future option if the cluster-Pi aggregator ever
       takes over real-time scheduling. Today that lives in firmware.
  D7 — Submodule: keep `vendor/midstream` pinned at `30fe5eb` as reference
       material; do not advance the pin per-release (unlike vendor/rvcsi
       under ADR-097 D7) because there is no in-build consumer.
  D8 — Docs: cross-reference, don't import. ADR-098 added to
       `docs/adr/README.md`.

Status: Rejected (with named re-evaluation triggers in §6 — second DTW use
case, host-side real-time scheduler, midstream gains a CSI adapter, or a
QUIC-to-external-client requirement that WS can't service).
2026-05-17 17:49:21 -04:00
rUv b2fe452e74 docs(tutorials): Pi 5 + Hailo cluster rvcsi tutorial (#546)
* docs(tutorials): add Pi 5 + Hailo cluster rvcsi tutorial

Field-tested walkthrough for building a 4-node Raspberry Pi 5 + 2×
Hailo-8 multistatic Wi-Fi CSI cognitive RF observer using rvcsi. Built
against the v0-appliance v0.5.0-cognitive-rf-observer milestone — 446k+
observed fingerprints, 16 stable RF states, 2nd-order Markov running at
39% top-1 ceiling (1.06× over 1st-order, 16× chance baseline).

Covers:
  - Pi 5 + Hailo hardware bring-up (BOM ~$580 + workstation)
  - nexmon_csi native ARM build recipe (cross-compile is a dead end)
  - Per-node services + per-host topology (15 expected services across 4 hosts)
  - Workstation pipeline: 3 daemons + 7 timers, brain HTTP + SQLite
  - 12 brain categories from spatial-vitals through rfmem-fleet
  - cog-query CLI: 34 subcommands, 4 JSON modes, --post for 2
  - Calibration recipe: walk → cluster → warm-start IDs → Markov chain
  - 13-axis anomaly detector w/ composite info score (1.0–8.0)
  - Fleet-health triad: check-drift + replica-status + fleet-status
  - Troubleshooting table for the painful lessons (clock skew, cp -r footgun,
    self-loop dominance in Markov argmax, etc.)

Pairs with a detailed cookbook gist (linked from intro + steps 3, 4,
and the Reference section):
https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(tutorials): clarify rvcsi naming + add ADR-207 cutover note

Two amendments per ADR-207's "naming defect — fix immediately regardless"
action item:

1. Intro callout: when the tutorial was first written, "rvcsi" was a
   naming convention only (no upstream library dep). As of 2026-05-13
   the v0-appliance accepted ADR-207 Option D and shipped a Rust
   binary built on the real rvcsi-runtime. Both stacks can coexist on
   a mixed cluster during cutover.

2. Per-node services section: explicit note that cog-csi-emitter +
   cog-csi-adapter + cog-rvcsi-stream are being consolidated into one
   cog-rvcsi-pi Rust binary, with deploy + rollback commands and
   scope (per-Pi cutover, mixed clusters OK).

The tutorial's overall instructions remain correct for both pre- and
post-cutover deployments — fleet-status, the operator surface, and
the architectural model are unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:41:39 -04:00
rUv 88da304631 chore(scripts): probe-fft-platform.py — root-cause aid for #560 (#607)
The verify.py "platform-independent for IEEE 754 compliant systems"
docstring at archive/v1/data/proof/verify.py:172 is incorrect — scipy's
pocketfft uses SIMD vector kernels (AVX2/AVX-512 on x86_64, NEON on
Apple Silicon) that reorder FP operations differently across builds, so
the SHA-256 of the production pipeline diverges at ULP precision per
platform. That divergence is what bug report #560 caught on macOS arm64.

This script reproduces verify.py's hash-relevant scipy.fft.fft + Hamming-
window calls in isolation on a deterministic synthetic input, without
dragging in src.app / pydantic Settings. Run on each platform and diff
the JSON output:

  python3 scripts/probe-fft-platform.py

- If two machines print the same first8_doppler_bytes_hex and the same
  first4_psd_floats but different sha256, the divergence is in later FFT
  bins (SIMD reordering).
- If even the first values differ, it's true ULP-level divergence at
  every bin (NEON vs x86_64, or different scipy pocketfft builds).

Captured empirical evidence across Windows (Intel AVX-512), Linux x86_64
(ruvultra), and Apple Silicon (ruv-mac-mini) — Win + Linux agree on first
PSD values but produce different SHA-256s; Mac arm64 differs at the first
bins at ~1 ULP precision (~2e-14 on a value of ~94).

This commit ships only the diagnostic. The architectural fix for #560
(quantize-before-hash in features_to_bytes(), then regenerate
expected_features.sha256 on a canonical CI platform) is left as a
separate maintainer decision because it changes a published trust-anchor
artifact and merits a deliberate call.

Supersedes the probe portion of PR #577 (the verify path fix from #577
already shipped via PR #590).
2026-05-17 17:34:28 -04:00
rUv 880a3a41d3 chore(ci): add fix-markers for recent merges (#559, #561, #588, #593, #590-CI) (#606)
Six new entries in scripts/fix-markers.json so the regression guard
(.github/workflows/fix-regression-guard.yml + scripts/check_fix_markers.py)
catches a future revert of any of these fixes:

- RuView#559 — ./verify points at archive/v1/ paths
- RuView#561 — README app flash offset 0x20000 + ota_data_initial.bin at 0xf000
                + canonical provision.py path
- RuView#588-SEC020 — provision.py prints (set)/(empty), not '*' * len(pw)
                (forbids the asterisk-run pattern that leaks password length)
- RuView#593 — vital_signs.rs uses phase_circular_variance for wrapped phases
- RuView#590-fuzz-stub — esp_stubs.h declares wifi_ps_type_t / WIFI_PS_NONE
                / esp_wifi_set_ps (keeps Fuzz Testing job green)
- RuView#590-swarm-test — qemu_swarm.py passes --force-partial to provision.py
                (keeps Swarm Test ADR-062 job green)

Verified: `python scripts/check_fix_markers.py` reports All 17 fix markers
present.
2026-05-17 17:33:07 -04:00
DavidKrame 68b042faf6 fix(archive/v1): middleware inherits BaseHTTPMiddleware to fix 500 errors (#570) 2026-05-17 17:32:22 -04:00
Rahul 4698f54fa0 fix(ui): map sensing websocket port for docker (#572) 2026-05-17 17:32:13 -04:00
rUv ea62ec4667 docs(firmware): truth-up Tier 2 wording — slot-capacity heuristic, not learned person counter (#573)
@xiaofuchen's code audit in #568 was correct: the firmware's
`pkt.n_persons` is `s_top_k_count / 2` (clamped) — a subcarrier-slot
partition, not a learned classifier. The README's old wording
('Multi-person estimation', 'Presence sensing') reads stronger than
`edge_processing.c:481-548` actually supports. Same-direction fix as
commit bd4f81749 (which retracted the 92.9% PCK@20 claim because
ADR-079's eval phases are still Pending) and ADR-099 §D8 (which
honestly amended the 10× latency target because it's unreachable on
1-D scalar features).

Three things this commit changes:

1. **Headline-table 'Presence sensing' -> 'Presence indicator (heuristic)'.**
   Adds an explicit caveat that strong RF interference can false-positive
   without re-calibration, with a link to the detailed Tier-2 section.
   The marketing word 'sensing' implied a classifier; the code is a
   variance threshold.

2. **Tier-2 bullet 'Multi-person estimation' -> 'Multi-person slot count'.**
   Now reads:

     '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.'

   Links directly to `main/edge_processing.c:481-548` so the user can
   verify the claim against the code.

3. **New 'What this firmware does NOT do (Tier 2 caveats)' subsection.**
   Three explicit non-claims:

   - No trained neural model on the ESP32 — the person count is
     arithmetic, not inference.
   - No pose estimation on the ESP32; pose comes from the host's Rust
     server, and only runs learned inference when --model <rvf-file> is
     passed. Without a trained model, the host runs signal-based
     heuristics, not keypoint inference. Same point as #509 / #506.
   - Presence indicator false-positives under fans/microwaves/AP TX
     swings without re-running the 60 s ambient calibration. Notes the
     concrete remedy (power-cycle in an empty room).

Closes #568.
2026-05-17 17:31:51 -04:00
@aaronjmars 3685d16a49 fix(security): host-header allowlist on sensing-server HTTP + WS — DNS rebinding (#580)
The sensing-server binds to 127.0.0.1 by default with no `Host` header
validation on either router. A foreign page can lower its DNS TTL,
re-resolve to 127.0.0.1 after the browser has accepted the origin, and
then read live pose + vital signs from /api/v1/* + /ws/sensing as
same-origin against the attacker's hostname. When `RUVIEW_API_TOKEN` is
unset (the documented LAN-mode default from #443/#547) the attacker
can also drive state-mutating POSTs (recording/start, models/load,
adaptive/train, calibration/start, sona/activate).

Defense: a small `host_validation` axum middleware that pins the `Host`
header to a configurable allowlist. The loopback names (`localhost`,
`127.0.0.1`, `[::1]`, each with or without a port) are always in the
set, so default 127.0.0.1 deployments keep working from the local
browser without any configuration change. Operators who bind to a
routable address extend the set with one or more `--allowed-host`
flags or a comma-separated `SENSING_ALLOWED_HOSTS` env var.
Reverse-proxy deployments that already canonicalise `Host` opt out
with `--disable-host-validation`.

The layer is wired into both the dedicated WebSocket router on
`--ws-port` (8765) and the main HTTP router on `--http-port` (8080),
so /ws/sensing on either listener is covered. Rejection responses are
`421 Misdirected Request` (the correct status for a request that
arrived at a server that does not consider the supplied `Host`
authoritative); missing `Host` is `400 Bad Request`.

CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS).
Severity: high.

Tests: 13 new unit tests on the middleware (loopback defaults,
case-insensitivity, IPv6 bracketing, port stripping, env-var/CLI
merge, foreign-host rejection on /health + /ws/*, disabled-allowlist
escape hatch). Full suite: 220/220 pass under
`cargo test -p wifi-densepose-sensing-server --no-default-features`.

Co-authored-by: Aeon <aeon@aaronjmars.com>
2026-05-17 17:27:00 -04:00
NgoQuocViet2001 8a155e07ec docs: explain mesh data path to dashboard and Observatory (#602) 2026-05-17 17:05:51 -04:00
github-actions[bot] 540ecb4538 chore: update vendor submodules (#604)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-17 17:04:14 -04:00
Akhilesh Arora 10684972d7 fix(vital_signs): use circular variance for wrapped phases (#595)
process_frame computed arithmetic mean + variance on phase values from
atan2(), which are wrapped to (-pi, pi]. Phases close across the +/-pi
discontinuity produced ~pi^2 variance instead of ~1e-6, feeding wrap
noise into the heart-rate FFT buffer.

Replace inline math with a standard circular variance helper
(1 - mean resultant length). Add 4 unit tests, one through the
production path of process_frame.

Closes #593
2026-05-17 17:02:53 -04:00
rUv 27a6edba8b feat(examples/three.js): cinematic skinned realtime pose demo + folder reorg (#584)
* feat(examples/three.js): cinematic skinned realtime pose demo + ESP32 CSI bridge

Five-stage example progression exploring three.js helpers (ADR-097 surface) as
a viewer for live RuView sensor data:

1. helpers-demo.html              — clean ADR-097 helper reference (GridHelper,
                                    PolarGridHelper, BoxHelper, AxesHelper),
                                    file://-safe, no backend
2. helpers-cinematic.html         — same scene + UnrealBloomPass + pseudo-CSI
                                    sonar pings + tomography sweep + procedural
                                    cyber floor + ambient drift particles
3. helpers-skinned.html           — replaces sphere skeleton with Mixamo X Bot
                                    via GLTFLoader from threejs.org CDN, plays
                                    bundled animations with additive blending
4. helpers-skinned-fbx.html       — same but loads a local Mixamo FBX (needs
                                    serve-demo.py — file:// can't fetch local
                                    siblings). Drop X Bot.fbx alongside.
5. helpers-skinned-realtime.html  — webcam → MediaPipe Pose Heavy →
                                    poseWorldLandmarks → direct quaternion
                                    retargeting onto the Mixamo skeleton.
                                    Real ESP32-S3 CSI streamed over WebSocket
                                    from ruvultra (Tailscale, port 8766).

Supporting:
  - serve-demo.py             threaded HTTP server with no-cache headers
                               (fixes net::ERR_EMPTY_RESPONSE on the FBX path)
  - ruvultra-csi-bridge.py    ESP32 RuView firmware tick → WebSocket bridge,
                               runs as systemd-run unit on ruvultra

Bugs found + fixed along the way (all documented in code comments):
  - FBX exports yield TWO parallel Bone trees with identical names; only the
    SkinnedMesh.skeleton.bones one drives visible deformation. model.traverse
    finds orphans.
  - Mixamo FBX nests a zero-length wrapper bone above the real bone, same name.
    bone.children[0].getWorldPosition == bone.getWorldPosition → restDir is
    (0,0,0) → setFromUnitVectors collapses to identity. Walk past same-named
    same-position wrappers when computing tail.
  - AnimationMixer.update() with a "stopped" action still mutates bones unless
    enabled=false is set.

Retargeting layer in helpers-skinned-realtime.html:
  - 12 bones direct quaternion retarget (arms × 2, legs × 2, spine × 3, neck)
  - Hips root rotation from shoulder/hip line basis (torso twist + lean)
  - Neck aims at ear-midpoint (kp 7+8), not nose (kp 0), to remove the
    forward bias of the protruding-nose anchor
  - One Euro Filter per landmark per axis (Casiez 2012) — adaptive low-pass
  - Visibility-weighted per-bone slerp gain — occluded limbs relax to rest
  - URL toggles: ?mirror= ?yflip= ?zflip= ?cnn=0/1/2 ?csi=ws://...

Live CSI integration:
  - Bridge parses adaptive_ctrl tick lines (motion/presence/rssi/yield)
  - Browser fans single ESP32 reading across 4 UI nodes with phase-shifted
    wobble (0.88–1.00 × sin(t·0.55 + offsetᵢ))
  - EMA α=0.06 (~3 sec time constant), HUD update throttled 3 Hz

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(examples/three.js): organize into demos/screenshots/server/assets + add README

Flatten the 13-file flat layout into purposeful subfolders so the demo
collection has a clean top-level entry point (README.md) and the file roles
are obvious from a directory listing.

Layout:
  demos/         01..05 — numbered for the progression (helpers → cinematic →
                          skinned → skinned-fbx → skinned-realtime)
  screenshots/   one PNG per demo, matching the demo's filename prefix
  server/        serve-demo.py + ruvultra-csi-bridge.py
  assets/        X Bot.fbx (gitignored, used by demos 04 and 05)

Touched files (beyond the renames):
- 04-skinned-fbx.html, 05-skinned-realtime.html: MODEL_URL now resolves
  '../assets/X%20Bot.fbx' instead of './X%20Bot.fbx'
- server/serve-demo.py: chdir() walks 3 levels up to repo root (was 2), and
  the URL banner now lists all 5 demos
- .gitignore: comment refresh — points at assets/ and screenshots/
- 05-skinned-realtime.html also picks up in-flight fps-tune work from this
  branch (Holistic script, SMOOTH_K URL param, slerp gain scaling) since
  those edits and the rename hit the same file

Verified end-to-end:
- python examples/three.js/server/serve-demo.py
- all 5 demos return 200, X Bot.fbx returns 200 from new asset/ path
- demos 04 + 05 render the X Bot mesh; 0 JS errors via browser eval
- screenshots reproduced match the originals

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:01:02 -04:00
rUv 174e2365f0 fix: bug triage for #559, #561, #588 + CI fixes for fuzz/swarm tests (#590)
* fix: bug triage from issues #559, #561, #588

- verify: point at archive/v1/ proof paths (v1/ was removed)         (#559)
- firmware README: app flash offset 0x10000 -> 0x20000, include
  ota_data_initial.bin at 0xf000, correct provision.py path from
  scripts/ to firmware/esp32-csi-node/                                (#561)
- provision.py: drop password-length leak in console output; print
  (set)/(empty) instead of len(password) asterisks                    (#588)

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: fix Fuzz Testing + Swarm Test (ADR-062) workflow regressions

Both have been red on main for ~5 weeks; root-causing them so PR #590
can land green rather than merging on top of pre-existing breakage.

- esp_stubs.h: add wifi_ps_type_t enum (WIFI_PS_NONE/MIN/MAX) and
  esp_wifi_set_ps() stub. csi_collector.c:346 added a real
  esp_wifi_set_ps(WIFI_PS_NONE) call to disable modem sleep
  (RuView#521 fix); the host-native fuzz target couldn't link.
- scripts/qemu_swarm.py: pass --force-partial to provision.py.
  The per-node TDM/channel overlay intentionally omits WiFi
  credentials (those live in the base flash image), but the
  issue #391 wifi-trio guard now rejects calls missing the
  --ssid/--password trio. --force-partial is exactly the opt-in
  for this case.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:00:37 -04:00
rUv bf30844835 Update README.md 2026-05-14 22:14:36 -04:00
Deploy Bot ce7983eb43 feat(sensing-server): adaptive person count — RollingP95 + dedup_factor runtime API
RollingP95 adaptive normalizer (ADR-044 §5.2):
- Streaming P95 estimator (600-sample / ~30 s window) replaces fixed-scale
  denominators (variance/300, motion/250, spectral/500) that saturated against
  live ESP32 values, collapsing dynamic range to zero.
- Cold-start (<60 samples) falls back to legacy denominators — day-0 behaviour
  is preserved.
- Three new fields on AppStateInner: p95_variance, p95_motion_band_power,
  p95_spectral_power (all RollingP95::new(600, 60)).
- compute_person_score() refactored to accept &AppStateInner; all three call
  sites (wifi, wifi-fallback, simulated) updated.
- 5 unit tests in rolling_p95_tests module.

dedup_factor runtime API (ADR-044 §5.3):
- New field dedup_factor: f64 (default 3.0) on AppStateInner.
- fuse_or_fallback() gains dedup_factor param; fallback switches from max() to
  sum/dedup_factor (ceiling), matching the fork's sum-based aggregation.
- RuntimeConfig struct + load/save_runtime_config() for data/config.json
  persistence across restarts.
- Three new REST endpoints:
    GET  /api/v1/config/dedup-factor
    POST /api/v1/config/dedup-factor
    POST /api/v1/config/ground-truth (auto-tune from known person count)

Explicitly NOT included:
- lambda=5.0 (upstream keeps its 0.1 default — deployment-specific tuning)
- CC intensity threshold 0.3 and min-cluster-size 4 hardcodes
- max_cc_size filter removal
2026-04-28 15:32:34 -04:00
136 changed files with 15522 additions and 1106 deletions
+7 -7
View File
@@ -33,7 +33,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -166,7 +166,7 @@ jobs:
- name: Set up Python ${{ matrix.python-version }}
continue-on-error: true
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -198,7 +198,7 @@ jobs:
- name: Upload coverage reports
continue-on-error: true
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v6
with:
file: ./coverage.xml
flags: unittests
@@ -226,7 +226,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -285,7 +285,7 @@ jobs:
- name: Extract metadata
continue-on-error: true
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -296,7 +296,7 @@ jobs:
- name: Build and push Docker image
continue-on-error: true
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
target: production
@@ -341,7 +341,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
+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@v4
- uses: actions/setup-node@v6
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@v4
uses: actions/setup-node@v6
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@v4
uses: actions/setup-node@v6
with:
node-version: '20'
@@ -85,7 +85,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.11'
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/ruvnet/nvsim-server
tags: |
@@ -47,7 +47,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build + push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: v2
file: v2/crates/nvsim-server/Dockerfile
+5 -5
View File
@@ -32,7 +32,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -99,7 +99,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -170,7 +170,7 @@ jobs:
- name: Build Docker image for scanning
continue-on-error: true
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
target: production
@@ -197,7 +197,7 @@ jobs:
- name: Run Grype vulnerability scanner
continue-on-error: true
uses: anchore/scan-action@v3
uses: anchore/scan-action@v7
id: grype-scan
with:
image: 'wifi-densepose:scan'
@@ -343,7 +343,7 @@ jobs:
- name: Set up Python
continue-on-error: true
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
+13 -3
View File
@@ -50,6 +50,12 @@ jobs:
with:
submodules: recursive
# QEMU is required so the amd64 GitHub runner can cross-build the
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
# by the runner, not built on a separate arm64 host).
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -68,7 +74,7 @@ jobs:
- name: Compute tags
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/ruvnet/wifi-densepose
@@ -81,7 +87,7 @@ jobs:
- name: Build + push
id: build
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile.rust
@@ -90,7 +96,11 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
# as part of the docker publish refresh; arm64 was never actually wired
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
platforms: linux/amd64,linux/arm64
# ---------------------------------------------------------------------
# Smoke-test the freshly-pushed image:
+20 -3
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -57,7 +57,18 @@ jobs:
"
- name: Run pipeline verification
working-directory: v1
working-directory: archive/v1
env:
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
# order is otherwise non-deterministic across CI runs (issue #560
# follow-up: 9- and 6-decimal quantization were not enough because
# the divergence is from threading order, not SIMD reordering).
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
OMP_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
VECLIB_MAXIMUM_THREADS: "1"
NUMEXPR_NUM_THREADS: "1"
run: |
echo "=== Running pipeline verification ==="
python data/proof/verify.py
@@ -65,7 +76,13 @@ jobs:
echo "Pipeline verification PASSED."
- name: Run verification twice to confirm determinism
working-directory: v1
working-directory: archive/v1
env:
OMP_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
VECLIB_MAXIMUM_THREADS: "1"
NUMEXPR_NUM_THREADS: "1"
run: |
echo "=== Second run for determinism confirmation ==="
python data/proof/verify.py
+3
View File
@@ -13,6 +13,9 @@ firmware/esp32-csi-node/managed_components/
firmware/esp32-csi-node/dependencies.lock
firmware/esp32-csi-node/sdkconfig.defaults.bak
# ESP-IDF set-target backup (local only)
firmware/esp32-hello-world/sdkconfig.old
# Claude Flow swarm runtime state
.swarm/
+77
View File
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
- `POST /api/v1/recording/start` (`recording.rs``session_name`)
- `GET /api/v1/recording/download/:id` (`recording.rs``id`)
- `DELETE /api/v1/recording/delete/:id` (`recording.rs``id`)
- `POST /api/v1/models/load` (`model_manager.rs``model_id`)
- `training_api.rs` `load_recording_frames` (`dataset_id`s)
Pre-fix, unauthenticated callers could read `../../etc/passwd`-style paths, write arbitrary JSONL files, load attacker-controlled `.rvf` model files, or delete arbitrary files the server process could touch. 9 unit tests in `path_safety::tests` exercise the rejection envelope (empty, too-long, path separators, parent-dir traversal, null byte, whitespace/specials, non-ASCII).
### Fixed
- **WebSocket `/ws/sensing` now reports `esp32:offline` when ESP32 hardware goes stale** (closes #618). `broadcast_tick_task` was re-emitting the cached `latest_update` with a frozen `source: "esp32"` field forever after the hardware lost power or network. The REST `/health` endpoint already called `effective_source()` (which returns `"esp32:offline"` after `ESP32_OFFLINE_TIMEOUT` = 5 s with no UDP frames), but the WS broadcast path was the one consumer that didn't. Result: the UI's "LIVE — ESP32 HARDWARE Connected" banner stayed green long after the hardware went away, and `vital_signs`/`features`/`classification` re-broadcasted the last-seen values indefinitely. Fix: clone the cached `latest_update` per tick, overwrite `source` with `s.effective_source()`, then serialize and broadcast. UI can now switch to an offline state on the same 5-second budget the REST surface uses.
- **Proof replay (`archive/v1/data/proof/verify.py`) is now cross-platform deterministic** (closes #560). Three changes together: (1) `features_to_bytes()` now `np.round(.., HASH_QUANTIZATION_DECIMALS=6)`s each feature array before packing as little-endian f64, collapsing ULP-level drift from scipy.fft pocketfft SIMD reordering; (2) the `Verify Pipeline Determinism` workflow pins `OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`, `VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1` — multi-threaded BLAS reductions were a deeper source of non-determinism than SIMD reordering, and 6-decimal quantization alone wasn't enough across Azure VM microarchitectures; (3) `expected_features.sha256` regenerated under the new conditions. CI now passes the determinism check (same hash across consecutive runs on canonical Linux x86_64 CI runner: `667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7`). `scripts/probe-fft-platform.py` updated to mirror `HASH_QUANTIZATION_DECIMALS=6` for cross-machine spot-checks.
- **`archive/v1/src/services/pose_service.py:223` calls the right method on `PhaseSanitizer`** (closes #612). The call was `self.phase_sanitizer.sanitize(phase_data)`, but `PhaseSanitizer`'s full-pipeline entry point is named `sanitize_phase()` (`unwrap_phase` + `remove_outliers` + `smooth_phase` chained, see `archive/v1/src/core/phase_sanitizer.py:266`). The shorter `sanitize` name doesn't exist on the class, so any path that reached this branch raised `AttributeError` and crashed the pose service mid-frame.
- **`adaptive_classifier.rs:94` no longer panics on NaN feature values** (closes #611).
`sorted.sort_by(|a, b| a.partial_cmp(b).unwrap())` returned `None` and panicked
whenever a single `NaN` reached the classifier from real ESP32 hardware (silent
DSP div-by-zero, empty buffer). One bad frame killed the entire sensing-server
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
same file already used at lines 149-150 and 155. Per-frame hot path; this was
a real production crash vector.
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
and missed seven additional production sites that use comparator variants
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
the same crash class — a single `NaN` in CSI-derived state panics the whole
sensing-server. Fixed:
- `adaptive_classifier.rs:205``AdaptiveModel::classify()` argmax over softmax
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
logits → softmax and still reaches this site even after the #613 IQR fix.
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
(training/per-class accuracy reporting).
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
catches an empty iterator; it cannot rescue a comparator panic.
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
### Removed
- **Stub crates `wifi-densepose-api`, `wifi-densepose-db`, `wifi-densepose-config`** (closes #578).
Each was a single-line doc-comment placeholder with an empty `[dependencies]`
section and zero references from any source file or `Cargo.toml`. The names
were reserved early for an envisioned REST/database/config split that never
materialised; the functionality they would provide is covered today by
`wifi-densepose-sensing-server` (Axum REST/WS), per-crate config + CLI args,
and the project's real-time-only (no-persistent-state) posture. Removing them
from the workspace prevents `cargo` from listing dead crates and shipping
empty published artifacts. If any of these names is needed in the future,
they can be reintroduced with a real implementation.
### Added
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
New `wifi_densepose_sensing_server::introspection` module wires
@@ -108,6 +162,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
### Added
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
feature normalization to whatever distribution the deployment produces. Replaces
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
Exposes the multi-node person-count deduplication divisor at runtime via REST:
- `GET /api/v1/config/dedup-factor` — read current value.
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.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,
@@ -127,6 +197,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
saturation, hyperfine spectroscopy, or pulsed protocols become required.
### Fixed
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
Clients would reconnect, immediately lag again, and rapid-cycle every 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
+8 -14
View File
@@ -14,9 +14,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
| `wifi-densepose-api` | REST API (Axum) |
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
| `wifi-densepose-config` | Configuration management |
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
@@ -135,17 +132,14 @@ Crates must be published in dependency order:
2. `wifi-densepose-vitals` (no internal deps)
3. `wifi-densepose-wifiscan` (no internal deps)
4. `wifi-densepose-hardware` (no internal deps)
5. `wifi-densepose-config` (no internal deps)
6. `wifi-densepose-db` (no internal deps)
7. `wifi-densepose-signal` (depends on core)
8. `wifi-densepose-nn` (no internal deps, workspace only)
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
10. `wifi-densepose-train` (depends on signal, nn)
11. `wifi-densepose-mat` (depends on core, signal, nn)
12. `wifi-densepose-api` (no internal deps)
13. `wifi-densepose-wasm` (depends on mat)
14. `wifi-densepose-sensing-server` (depends on wifiscan)
15. `wifi-densepose-cli` (depends on mat)
5. `wifi-densepose-signal` (depends on core)
6. `wifi-densepose-nn` (no internal deps, workspace only)
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
8. `wifi-densepose-train` (depends on signal, nn)
9. `wifi-densepose-mat` (depends on core, signal, nn)
10. `wifi-densepose-wasm` (depends on mat)
11. `wifi-densepose-sensing-server` (depends on wifiscan)
12. `wifi-densepose-cli` (depends on mat)
### Validation & Witness Verification (ADR-028)
+50 -17
View File
@@ -15,7 +15,7 @@
## **See through walls with WiFi** ##
**Turn ordinary WiFi into a spacial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
### π RuView is a WiFi sensing platform that turns radio signals into spatial intelligence.
@@ -32,7 +32,7 @@ Built on [RuVector](https://github.com/ruvnet/ruvector/) and [Cognitum Seed](htt
The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.
RuView also supports pose estimation (17 COCO keypoints via the WiFlow architecture), trained entirely without cameras using 10 sensor signals — a technique pioneered from the original *DensePose From WiFi* research at Carnegie Mellon University.
RuView **ships pretrained CSI weights on Hugging Face** at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — a self-supervised contrastive CSI encoder (128-dim embeddings, 12.2M training steps, 60K frames) + a presence-detection head reporting 100% accuracy on the validation set + per-node LoRA adapters. Models are released as `.safetensors`, 4-bit/8-bit/2-bit quantized `.bin` (4 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.
### Built for low-power edge applications
@@ -47,18 +47,26 @@ RuView also supports pose estimation (17 COCO keypoints via the WiFlow architect
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-ruvector.svg)](https://crates.io/crates/wifi-densepose-ruvector)
> | What | How | Speed |
> |------|-----|-------|
> | 🦴 **Pose estimation** | CSI subcarrier amplitude/phase → 17 COCO keypoints | 171K emb/s (M4 Pro) |
> | 🫁 **Breathing detection** | Bandpass 0.1-0.5 Hz → zero-crossing BPM | 6-30 BPM |
> | 💓 **Heart rate** | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM |
> | 👤 **Presence sensing** | Trained model + PIR fusion — 100% accuracy | 0.012 ms latency |
> | 🧱 **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
> | 🧠 **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $140 total BOM |
> | 🎯 **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
> | 📷 **Camera-supervised training** | MediaPipe + ESP32 CSI → **35%+ PCK@20 target** (ADR-079; eval phases pending) | ~19 min on laptop (pipeline) |
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
> | 🌐 **3D point cloud** *(optional fusion)* | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame |
> | What | Status | How | Speed |
> |------|--------|-----|-------|
> | 🫁 **Breathing rate** | ✅ Works today | Bandpass 0.1-0.5 Hz → zero-crossing BPM, circular variance on wrapped phase ([#593](https://github.com/ruvnet/RuView/issues/593)) | 6-30 BPM |
> | 💓 **Heart rate** | ✅ Works today | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM (needs good SNR) |
> | 👤 **Presence detection** | ✅ Heuristic in server · 🤗 Trained head on HF (loader wiring pending) | Live server uses phase-variance vs adaptive threshold (60 s ambient calibration). A trained `presence-head.json` reporting 100% validation accuracy is published in [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) but the sensing-server's `--model` loader only accepts binary RVF today — JSONL adapter pending. | <1 ms heuristic |
> | 🧬 **CSI embeddings** | 🤗 Trained encoder on HF | 128-dim contrastive encoder, **164,183 emb/s** on M4 Pro. Usable today from Python / training via `model.safetensors`; sensing-server consumption pending the same JSONL loader gap as above. | 8 KB q4 fits ESP32 SRAM |
> | 🚶 **Motion / activity** | ✅ Works today | Motion-band power + phase acceleration | Real-time |
> | 🤸 **Fall detection** | ✅ Works today | Phase acceleration > threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
> | 🧮 **Multi-person slot count** | ⚠️ Heuristic, not learned | Subcarrier diversity divided by 2 (capped). **Not** a learned counter — see [firmware README](firmware/esp32-csi-node/README.md#tier-2--full-pipeline-stable) "Tier 2 caveats". Adaptive normalisation in [#491](https://github.com/ruvnet/RuView/pull/491). | Real-time |
> | 🦴 **17-keypoint pose estimation** | 🔬 Pipeline only, no shipped weights | Training infrastructure complete (WiFlow + AETHER + MERIDIAN heads); the published HF model is presence + embeddings, not keypoints. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509). | Pending data collection |
> | 🧱 **Through-wall sensing** | ✅ Works today | Fresnel zone geometry + multipath modeling | Up to ~5m signal-dependent |
> | 🧠 **Edge intelligence** | ✅ Works today | Optional Cognitum Seed for persistent vector store + kNN + witness chain | $140 total BOM |
> | 🎯 **Camera-free pre-training** | ✅ Shipped weights on HF | Self-supervised contrastive encoder, 12.2M training steps on 60K frames. See [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). | 84 s/epoch retrain on M4 Pro |
> | 📷 **Camera-supervised fine-tune** | 🔬 Pipeline only | MediaPipe + ESP32 CSI paired training, [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md). Target **35%+ PCK@20**. 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.
```bash
# Option 1: Docker (simulated data, no hardware needed)
@@ -88,10 +96,10 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
>
> | Option | Hardware | Cost | Full CSI | Capabilities |
> |--------|----------|------|----------|-------------|
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence indicator, motion, breathing rate, heart rate, fall detection, slot-count multi-person heuristic + persistent vector store, kNN search, witness chain, MCP proxy. (Pose pending weights — see [#509](https://github.com/ruvnet/RuView/issues/509).) |
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
>
@@ -115,6 +123,31 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
## 🤗 Pretrained model on Hugging Face
Pretrained CSI weights live at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 12.2M training steps on 60K frames / 610K contrastive triplets, **100% presence accuracy** on the validation set, 4-bit quantized variant fits in 8 KB. The release includes a contrastive **CSI encoder** producing 128-dim embeddings (164,183 emb/s on M4 Pro) and a **presence-detection head**. Per-node LoRA adapters are included for environment-specific fine-tuning.
```bash
# Download the model bundle
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wifi-densepose-pretrained
```
**What works today vs. what's pending wiring:**
| Consumer | Format used | Status |
|----------|-------------|--------|
| Python training / evaluation / embedding extraction | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| Inspect / re-export the bundle | `model.rvf.jsonl` (line-by-line JSON) | ✅ Works — plain JSONL |
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Loader does not yet accept the JSONL container |
**Known gap:** the HF model ships in JSONL RVF format, but `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format. Pointing `--model` at `model.rvf.jsonl` currently errors with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` and the live pipeline degrades to null output rather than falling back to heuristic mode — so for the live sensing-server, run **without** `--model` until a JSONL adapter lands (or the model is re-published as binary RVF). Use the weights from Python / training in the meantime.
**Quantization choices** (all in the HF repo): `model-q2.bin` (4 KB) · `model-q4.bin` ⭐ recommended (8 KB) · `model-q8.bin` (16 KB) · `model.safetensors` full (48 KB)
The separate **17-keypoint pose-estimation model** is not in this release — pipeline is implemented but keypoint weights are still pending. Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases 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:
@@ -1 +1 @@
8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6
667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7
+34 -4
View File
@@ -164,18 +164,44 @@ def frame_to_csi_data(frame, signal_meta):
)
# Quantization precision for cross-platform hash stability (issue #560).
#
# The bytes packed below feed SHA-256. Without quantization, the hash diverges
# across SIMD backends (Intel AVX2/AVX-512 vs ARM NEON vs different x86 micro-
# architectures in the same CI pool) because scipy.fft's pocketfft kernels
# reorder vectorized FP operations differently per build. IEEE 754 guarantees
# per-operation determinism, not associativity under reordering.
#
# Empirically: 9 decimals was NOT enough to collapse the divergence — two
# back-to-back Ubuntu 24.04 / Python 3.11 / scipy 1.17 CI runs landed on
# different Azure VM microarchitectures (likely Skylake vs Cascade Lake)
# and produced two different SHA-256s even after np.round(.., 9). The DSP
# pipeline (preprocess → biquad bandpass → FFT → PSD → variance accumulation)
# amplifies the ~1e-14 raw FFT divergence by several orders of magnitude
# downstream — the actual drift at features_to_bytes() input can reach 1e-7
# or worse.
#
# 6 decimals (parts per million) gives ~6 orders of magnitude headroom over
# observed pipeline-amplified ULP drift and is still far below any meaningful
# signal change (CSI phase precision is ~1e-3 rad; PSD bins differ by orders
# of magnitude). Round to this precision, then hash.
HASH_QUANTIZATION_DECIMALS = 6
def features_to_bytes(features):
"""Convert CSIFeatures to a deterministic byte representation.
We serialize each numpy array to bytes in a canonical order
using little-endian float64 representation. This ensures the
hash is platform-independent for IEEE 754 compliant systems.
Each feature array is quantized to ``HASH_QUANTIZATION_DECIMALS`` decimal
places before being packed as little-endian float64. The quantization is
what makes the resulting SHA-256 hash actually platform-independent — the
raw float values diverge at ULP precision across scipy.fft SIMD backends
(issue #560), even though all platforms compute the "correct" answer.
Args:
features: CSIFeatures instance.
Returns:
bytes: Canonical byte representation.
bytes: Canonical, quantized byte representation.
"""
parts = []
@@ -189,6 +215,10 @@ def features_to_bytes(features):
features.power_spectral_density,
]:
flat = np.asarray(array, dtype=np.float64).ravel()
# Quantize before packing so SIMD-level FP reordering across
# Intel AVX vs Apple Silicon NEON pocketfft kernels does not
# leak into the SHA-256 input.
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
# Pack as little-endian double (8 bytes each)
parts.append(struct.pack(f"<{len(flat)}d", *flat))
+7 -5
View File
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
from fastapi import Request, Response, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from jose import JWTError, jwt
from passlib.context import CryptContext
@@ -155,16 +156,17 @@ class UserManager:
return False
class AuthenticationMiddleware:
class AuthenticationMiddleware(BaseHTTPMiddleware):
"""Authentication middleware for FastAPI."""
def __init__(self, settings: Settings):
def __init__(self, app, settings: Settings):
super().__init__(app)
self.settings = settings
self.token_manager = TokenManager(settings)
self.user_manager = UserManager()
self.enabled = settings.enable_authentication
async def __call__(self, request: Request, call_next: Callable) -> Response:
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request through authentication middleware."""
start_time = time.time()
+7 -5
View File
@@ -11,6 +11,7 @@ from collections import defaultdict, deque
from dataclasses import dataclass
from fastapi import Request, Response, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from src.config.settings import Settings
@@ -299,15 +300,16 @@ class RateLimiter:
}
class RateLimitMiddleware:
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Rate limiting middleware for FastAPI."""
def __init__(self, settings: Settings):
def __init__(self, app, settings: Settings):
super().__init__(app)
self.settings = settings
self.rate_limiter = RateLimiter(settings)
self.enabled = settings.enable_rate_limiting
async def __call__(self, request: Request, call_next: Callable) -> Response:
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request through rate limiting middleware."""
if not self.enabled:
return await call_next(request)
+5 -1
View File
@@ -220,7 +220,11 @@ class PoseService:
# Apply phase sanitization if we have phase data
if hasattr(detection_result.features, 'phase_difference'):
phase_data = detection_result.features.phase_difference
sanitized_phase = self.phase_sanitizer.sanitize(phase_data)
# PhaseSanitizer's full-pipeline method is sanitize_phase,
# not sanitize (issue #612). The shorter name was an
# AttributeError waiting to fire on any code path that
# reaches this branch.
sanitized_phase = self.phase_sanitizer.sanitize_phase(phase_data)
# Combine amplitude and phase data
return np.concatenate([amplitude_data, sanitized_phase])
+12 -1
View File
@@ -9,7 +9,18 @@ services:
ports:
- "3000:3000" # REST API
- "3001:3001" # WebSocket
- "5005:5005/udp" # ESP32 UDP
# ESP32 UDP. On Linux/macOS this works with multiple ESP32 nodes out of
# the box. On Docker Desktop for Windows, multi-source UDP is collapsed
# to one source IP at the WSL/Hyper-V boundary, so all-but-one node's
# frames are silently dropped (issue #374, #386).
#
# Windows workaround: change this to "5006:5005/udp" and run the host
# relay so every datagram arrives from the same loopback source:
#
# python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
#
# See docs/TROUBLESHOOTING.md §9 for details.
- "5005:5005/udp"
environment:
- RUST_LOG=info
# CSI_SOURCE controls the data source for the sensing server.
+72
View File
@@ -109,3 +109,75 @@ ssh thyhack@100.90.238.87
**Symptom:** Plugging into the right USB-C port (when facing the board with USB-C toward you) shows no serial device on the host.
**Fix:** Use the left USB-C port. On most ESP32-S3-DevKitC boards, the left port is the USB-to-UART bridge (CP2102/CH340) used for flashing and serial monitor. The right port is the native USB (USB-JTAG) which requires different drivers and isn't used by the RuView firmware.
---
## 9. Docker Desktop on Windows drops UDP from multiple ESP32 nodes
**Symptom:** Two or more ESP32 nodes are flashed, provisioned, and visibly transmit on the network — `tcpdump`/Wireshark on the Windows host shows datagrams from every node — but inside the Docker container only one source IP arrives. `/api/v1/sensing/latest` shows a single node and the live UI freezes or only tracks one body. Reported in #374 (4-node bench) and reproduced in #386 (6-node demo, RuView v0.7.0).
**Root cause:** Docker Desktop on Windows runs the engine inside a WSL2 / Hyper-V VM. Inbound UDP from the host LAN is forwarded through `vpnkit` / `vEthernet` and the multi-source-IP datagrams are demultiplexed onto a single virtual socket. The first source-IP "wins"; subsequent unique sources are silently dropped at the VM boundary. This is a Docker Desktop limitation, not a sensing-server bug — `host.docker.internal` and `--network host` do not help (host networking is not implemented for the Linux engine on Windows).
**Fix:** Run the bundled UDP relay on the host so every forwarded datagram arrives from the same loopback source IP, which Docker passes through unchanged.
```powershell
# 1. Start the relay (PowerShell or any terminal)
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
# 2. Edit docker/docker-compose.yml — change the ESP32 UDP mapping from
# - "5005:5005/udp"
# to
# - "5006:5005/udp"
# 3. Bring the stack up
docker compose -f docker/docker-compose.yml up
```
ESP32 nodes still target the host on `--target-ip <host>:5005` — no firmware re-provisioning is needed. The relay is `scripts/udp-relay.py` (stdlib only, no extra deps). Verify with `--verbose` that each node's source IP appears at least once before forwarding stabilises on a single ephemeral relay port.
**Prevention:** Linux and macOS hosts are unaffected; the relay only needs to run on Docker Desktop for Windows. If Docker Desktop ships per-source UDP forwarding (tracked at [docker/for-win#1144](https://github.com/docker/for-win/issues/1144) and related), this workaround can be retired.
**Prior art:** PR #413 (`txhno`) proposed a docs-only writeup of the same workaround; this entry supersedes it.
---
## 10. `404` on the visualization page when running sensing-server
**Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188.
**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404.
**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`).
```bash
# Option A — absolute path (recommended for production)
sensing-server --source esp32 --udp-port 5005 --http-port 3000 \
--ws-port 3001 --ui-path /absolute/path/to/ui
# Option B — run from the crate dir (works for local dev / cargo run)
cd v2/crates/wifi-densepose-sensing-server
cargo run -- --source esp32
# Option C — Docker (no path config needed)
docker compose -f docker/docker-compose.yml up sensing-server
```
**Prevention:** Track future work in #188 to fall back to a path resolved relative to the executable when the cwd-relative path doesn't exist, so the binary works regardless of where it's launched.
---
## 11. Boot loop on `--edge-tier 1` or `--edge-tier 2`
**Symptom:** ESP32-S3 boots normally with `--edge-tier 0`, but flashing the same firmware with `--edge-tier 1` or `2` produces a boot loop. Serial output reaches `cpu_start` and `heap_init`, then resets repeatedly. Reported in #438 against firmware `v0.4.3.1-esp32-3-g66e2fa083-dir`.
**Root cause:** Edge tiers 1 and 2 enable the on-device DSP pipeline on Core 1. In the affected build, the `edge_dsp` task ran a tight per-frame loop without yielding, so the FreeRTOS task watchdog tripped on Core 1 and panicked. Tier 0 is passthrough only and doesn't activate the pipeline, so the watchdog never fires there.
**Fix:** Flash the [v0.4.3.1-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) release or later — the DSP task yield fixes have shipped on `main` since the build in the report.
```bash
# Verify what version you're on (look for "App version" in serial output on boot)
python -m serial.tools.miniterm COM7 115200
# Expect: "App version: v0.4.3.1-esp32" or higher
```
If the boot loop persists on a release build, capture a full serial trace including the watchdog backtrace and reopen #438 with the new build hash.
+191
View File
@@ -0,0 +1,191 @@
# ADR-098: Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline
| Field | Value |
|-------|-------|
| **Status** | Rejected (with crate-level carve-outs for future evaluation) |
| **Date** | 2026-05-13 |
| **Deciders** | ruv |
| **Codename** | **midstream-in-RuView** |
| **Relates to** | ADR-095 (rvCSI platform), ADR-096 (rvCSI crate topology), ADR-097 (adopt rvCSI as RuView's CSI runtime), ADR-012 (ESP32 CSI mesh), ADR-029 (RuvSense multistatic / TDM), ADR-031 (RuView sensing-first RF mode), ADR-043 (sensing-server UI API completion) |
| **midstream repo** | [github.com/ruvnet/midstream](https://github.com/ruvnet/midstream) — vendored at `vendor/midstream`, currently pinned at [`30fe5eb`](https://github.com/ruvnet/midstream/commit/30fe5eb7a1f1494aa1ad00d54160088a565ec766) |
| **Outcome** | Do **not** adopt as a system component. Two of midstream's six workspace crates (`temporal-compare`, `nanosecond-scheduler`) are plausible future-use building blocks; the rest do not fit. `vendor/midstream` is retained as a reference-only submodule. |
---
## 1. Context
`vendor/midstream` is a git submodule of RuView (`.gitmodules:1-4`) but, like `vendor/rvcsi` was before ADR-097, it is **vendored but not consumed**: no `v2/crates/*/Cargo.toml` depends on a `midstreamer-*` crate, no Rust source contains `use midstreamer_…`, and the ESP32 firmware and TypeScript dashboard have no midstream imports.
This ADR settles the standing question of *whether RuView should consume midstream at all*, and if so, where. The user-facing prompt enumerated four candidate seams to evaluate:
1. Streaming / pub-sub for the WebSocket fan-out (today: `tokio::sync::broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`).
2. Stream processing for the CSI → DSP → event pipeline (today: synchronous `EventPipeline` at `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs`, freshly adopted via ADR-097).
3. Multi-source merging / TDM coordination for the ESP32 mesh (ADR-029, ADR-073).
4. Backpressure / flow control between the UDP receiver and downstream consumers (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638` `udp_receiver_task`; firmware-side `stream_sender` ENOMEM backoff at `firmware/esp32-csi-node/main/csi_collector.c:223-228`).
To evaluate each, we read midstream's workspace `Cargo.toml` (`vendor/midstream/Cargo.toml:1-99`), the `README.md` and `BENCHMARKS_SUMMARY.md`, and every crate's `lib.rs`:
| Crate | File | LOC | Purpose (from header doc) |
|---|---|---:|---|
| `midstreamer-temporal-compare` | `vendor/midstream/crates/temporal-compare/src/lib.rs:1-697` | 697 | DTW, LCS, Levenshtein, generic pattern matching on `Sequence<T>` of `TemporalElement<T>` |
| `midstreamer-scheduler` | `vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:1-406` | 406 | Priority + deadline-aware task scheduler (RM, EDF, LLF) for low-latency real-time tasks |
| `midstreamer-attractor` | `vendor/midstream/crates/temporal-attractor-studio/src/lib.rs:1-482` | 482 | Phase-space reconstruction, Lyapunov exponents, attractor classification |
| `midstreamer-neural-solver` | `vendor/midstream/crates/temporal-neural-solver/src/lib.rs:1-509` | 509 | LTL / CTL / MTL temporal-logic verification with neural reasoning |
| `midstreamer-strange-loop` | `vendor/midstream/crates/strange-loop/src/lib.rs:1-496` | 496 | Multi-level meta-learning, self-referential systems |
| `midstreamer-quic` | `vendor/midstream/crates/quic-multistream/src/lib.rs:1-255`, `native.rs:1-303`, `wasm.rs:1-307` | 865 | Thin wrapper over `quinn` (native) and `WebTransport` (WASM); generic QUIC streams |
Plus a TypeScript layer (`vendor/midstream/npm/`, `vendor/midstream/npm-wasm/`) whose product is "real-time LLM streaming" — OpenAI Realtime API client, RTMP / WebRTC / HLS for video, an in-console dashboard, a Whisper transcription scaffold, an MCP server for LLM agents.
The top-level identity is unambiguous: `Cargo.toml:16` describes the package as **`"Real-time LLM streaming with inflight analysis"`**, and the README (`vendor/midstream/README.md:45-80`) frames midstream as a platform that "analyzes [LLM] responses **as they stream in real-time** — enabling instant insights, pattern detection, and intelligent decision-making" — i.e. the streaming domain is **LLM tokens and dashboard telemetry**, not RF signals. A search for any of `csi`, `wifi`, `sensing`, or `sensor` across `vendor/midstream/crates/*/src/*.rs` returns zero hits.
This shapes the conclusion: midstream's *abstractions* (DTW pattern matching, attractor analysis, LTL verification, meta-learning) were chosen for a fundamentally different problem domain than CSI, and its *transport* (QUIC) is a thin `quinn` wrapper rather than a sensing-aware backplane. The candidate seams enumerated above are either already filled by simpler primitives in RuView, or filled better by rvCSI under ADR-097.
### 1.1 What this ADR is *not*
- Not a judgment on midstream's quality. It has 139 passing tests and clean Rust; it is well-engineered for its target domain.
- Not a decision to drop `vendor/midstream`. The submodule pin is cheap to keep, and the carve-outs in §3 may justify revisiting it.
- Not a position on the *standalone* midstream product (LLM streaming, OpenAI Realtime, dashboards). That product is unaffected by this ADR.
---
## 2. Decision
**Reject midstream as a system component of RuView.** The four candidate seams are either filled (well) by existing RuView primitives, or are filled by rvCSI's freshly-adopted `EventPipeline` and `RfMemoryStore`. The eight decisions below are the architectural contract.
### D1 — Streaming / pub-sub for the WebSocket fan-out: no change
RuView's sensing-server currently fans out updates to WebSocket clients via `tokio::sync::broadcast::channel::<String>(256)` (`v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`). midstream offers no equivalent in-process broadcast primitive — its TypeScript dashboard fan-out is HTTP-server based (`vendor/midstream/npm/src/dashboard.ts`), and its Rust `midstreamer-quic` crate is a generic point-to-point QUIC wrapper (`vendor/midstream/crates/quic-multistream/src/native.rs:31-69`), not a pub-sub bus.
Tokio's `broadcast` channel is the standard Rust idiom for this pattern, costs effectively nothing per subscriber, integrates with the rest of the Axum + Tokio stack already in use (`v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47`), and is what `rvcsi-runtime` itself uses for event distribution (`vendor/rvcsi/crates/rvcsi-runtime/src/lib.rs`). **Keep `tokio::sync::broadcast`.**
*Consequences:* zero migration; zero new dependency surface; the WebSocket handlers at `main.rs:1989,2030` continue to work unchanged.
### D2 — CSI → DSP → event pipeline: stay on rvCSI's `EventPipeline`
ADR-097 D2 just adopted `rvcsi-runtime::CaptureRuntime` + `rvcsi_events::EventPipeline` as the CSI ingestion / DSP / event-extraction path. `EventPipeline` is **deterministic, synchronous, single-frame-at-a-time** (`vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`: *"Feed it frames with `EventPipeline::process_frame` and drain the tail with `EventPipeline::flush`"*) — and that determinism is load-bearing for ADR-095 D9 (replayability) and ADR-095 D13 (quality scoring against learned baselines).
midstream's stream-processing primitives are designed for the opposite shape: `temporal-attractor-studio` (phase-space reconstruction, Lyapunov exponents) and `temporal-neural-solver` (LTL formula verification) operate on **trajectories** of multi-dimensional states over hundreds-to-thousands of samples (`vendor/midstream/README.md:528-531`: *"Attractor detection: <5ms for 1000-point series"*) — that is closer to RuView's existing RuvSense modules (`v2/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs`, `intention.rs`) than to anything the runtime DSP layer needs.
Replacing rvCSI's event detectors with midstream constructs would (a) break determinism, (b) re-introduce a parallel CSI-processing implementation — exactly the duplication ADR-097 was opened to remove — and (c) force RuView to invent a `Sequence<T: temporal-compare::TemporalElement>` shim around `CsiFrame` for marginal benefit. **Stay on `rvcsi-events::EventPipeline`.**
*Consequences:* the determinism / replay guarantees of ADR-095 D9 and ADR-097 D6 remain intact; the work to land `rvcsi-adapter-esp32` (ADR-097 D4, P3) is not duplicated.
### D3 — TDM / multi-source merging: stay on the existing aggregator
The ESP32 mesh's multi-source merging is in `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220` — a `UdpSocket`-backed aggregator (`mod.rs:74,85`) that receives parsed `CsiFrame`s from N nodes and forwards them on a `SyncSender<CsiFrame>` to the consumer. The TDM coordination (slot assignment, channel hopping, dwell time) lives in firmware (`firmware/esp32-csi-node/main/`) and is governed by ADR-029 and ADR-073. midstream offers nothing for either side: it has no UDP merger, no slot scheduler, and no firmware-side primitives.
`midstreamer-scheduler` is conceptually adjacent — it does priority + deadline-aware scheduling (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`: `RateMonotonic`, `EarliestDeadlineFirst`, `LeastLaxityFirst`, `FixedPriority`) — but its target is **in-process tokio tasks on a 4-thread executor** (`vendor/midstream/README.md:466-477`: *"4 worker threads"*, *"<50 ns scheduling latency"*), not the cross-device, wall-clock-anchored TDM that RuvSense needs. **Keep the existing `wifi-densepose-hardware` aggregator and firmware-side TDM.**
*Consequences:* ADR-029 stays as-is; the work to migrate the parser to `rvcsi-adapter-esp32` (ADR-097 D4) is unaffected.
### D4 — UDP receiver backpressure / flow control: existing solutions are correct at each end
There are two distinct backpressure problems in RuView, and neither benefits from midstream:
- **Firmware side (`firmware/esp32-csi-node/main/csi_collector.c:64,223-228`):** lwIP pbuf exhaustion produces `ENOMEM` when the ESP32 tries to UDP-send faster than the network drains. The fix in code is a rate-limit on `stream_sender_send` *inside the CSI callback*. This is a C-level firmware concern with no Rust analogue — midstream cannot run on the ESP32.
- **Host side (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638-3640`, `4769`):** `udp_receiver_task` reads from `UdpSocket` and pushes onto `broadcast::channel::<String>(256)`. The bounded channel is itself the backpressure mechanism: lagged subscribers see `RecvError::Lagged`, the buffer wraps, no producer ever blocks. The 256-slot capacity is sized to one second of frame envelopes at the target rate; the per-second packet-yield collapse symptom (`adaptive_controller_decide.c:26-28`) is detected and surfaced by ADR-039 / ADR-081's `pkt_yield_per_sec` accessor, not by transport-layer flow control.
midstream's `quic-multistream` provides per-stream prioritization (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`), which is a useful flow-control primitive *for QUIC* but not for the UDP-CSI / WS-fan-out topology RuView actually uses. Adopting QUIC end-to-end would mean (a) replacing the ESP32's UDP sender — which would need a QUIC stack on a memory-constrained Xtensa MCU and is out of scope for this project — or (b) terminating QUIC at the aggregator only, which provides no benefit the current bounded `broadcast` channel doesn't. **Keep the existing two-tier backpressure.**
*Consequences:* the ENOMEM rate-limit at `csi_collector.c:223-228` and the bounded `broadcast::channel::<String>(256)` at `main.rs:4769` continue to be the load-bearing primitives.
### D5 — Carve-out: `temporal-compare` as a future RuvSense-side building block
`midstreamer-temporal-compare` (`vendor/midstream/crates/temporal-compare/src/lib.rs:1-697`) is a clean DTW / LCS / Levenshtein implementation with an LRU cache. RuView's gesture detector at `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs` already does DTW template matching, and the longitudinal analysis at `ruvsense/longitudinal.rs` could plausibly benefit from cached pattern matching. If we ever need a *separate* DTW implementation that is decoupled from RuvSense's internal types, `temporal-compare` is a reasonable starting point — but only if and when that need arises.
We **do not adopt it today** because RuvSense's gesture matcher already exists, works, and uses RuView-native types, and pulling in `dashmap`, `lru`, and a generic `TemporalElement<T>` abstraction would be net-negative right now. **Tracked as a future evaluation, not a decision.**
*Consequences:* zero today; one named option for a future ADR if a "second" DTW pattern appears.
### D6 — Carve-out: `nanosecond-scheduler` for *host-side* edge tier scheduling (future)
If ADR-039's edge-intelligence tier scheduling ever moves from the ESP32 onto a host-side coordinator (e.g. a Raspberry Pi running the cluster aggregator), `nanosecond-scheduler`'s deadline-aware policies (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`) could plausibly host that scheduler. Today the scheduling is firmware-side and the C-level RTOS handles it; there is nothing to schedule in Rust at the granularity midstream offers.
Again: **not a current decision, just an option kept open.**
*Consequences:* zero today.
### D7 — Submodule disposition: keep `vendor/midstream`
`vendor/midstream` is one git submodule pin; the build does not depend on it; it does not slow down `cargo build --workspace`; and the carve-outs in D5/D6 leave the door open. Removing the submodule would also remove the reference material that justified the carve-outs.
**Keep the submodule, no per-release pin advancement.** Unlike `vendor/rvcsi` (whose pin is bumped per RuView release under ADR-097 D7), `vendor/midstream` has no in-build consumer to validate against. If D5 or D6 ever activates, *that* ADR will start the per-release pin process. Until then the pin can drift freely.
*Consequences:* one line of `.gitmodules` (`.gitmodules:1-4`) stays; `git submodule update --init` remains a no-op for normal RuView development.
### D8 — Documentation: cross-reference, don't import
The ADR index (`docs/adr/README.md`) gets ADR-098 added under "Architecture and infrastructure". No other docs are updated. The README on the RuView side is untouched; midstream is not part of the RuView platform story.
*Consequences:* one row added to the ADR index; no churn elsewhere.
---
## 3. Why not adopt (the rejection record)
For institutional memory, the table below records what each midstream crate *would* solve and the alternative RuView already uses. This is the answer to "but we vendored midstream — what is it for?"
| midstream crate | Plausible RuView seam | Already filled by | Verdict |
|---|---|---|---|
| `midstreamer-temporal-compare` (DTW, LCS, Levenshtein) | Gesture template matching (`ruvsense/gesture.rs`); longitudinal biomechanics drift | RuvSense's existing DTW gesture matcher | Carve-out only (D5) — not adopted today |
| `midstreamer-scheduler` (nanosecond priority + deadline) | ESP32 edge-tier scheduling (ADR-039); RuvSense TDM (ADR-029) | Firmware-side RTOS (ESP32); ADR-029's wall-clock-anchored TDM | Carve-out only (D6) — wrong scope today |
| `midstreamer-attractor` (Lyapunov, phase-space) | RF-field stability detection in `ruvsense/field_model.rs`, `longitudinal.rs` | Welford stats + biomechanics drift (longitudinal.rs); SVD eigenstructure (field_model.rs) | Not adopted — RuvSense's approach is calibrated to RF signal scale and the project's existing dataset, not generic dynamical-systems theory |
| `midstreamer-neural-solver` (LTL / CTL / MTL verification) | Adversarial signal detection (`ruvsense/adversarial.rs`); coherence-gate decisions | Multi-link consistency checks (adversarial.rs); `coherence_gate.rs` state machine | Not adopted — RuView's adversarial detector is not a formal-verification problem; it's a multi-link physical-consistency check |
| `midstreamer-strange-loop` (meta-learning, self-modification) | None in RuView's scope | RuView is not a self-modifying learner; AETHER (ADR-024) is contrastive embedding, not meta-learning | Not adopted — out of scope |
| `midstreamer-quic` (QUIC native + WASM) | Sensing-server → external client transport (alternative to WS) | `tokio::sync::broadcast` + Axum WebSocket + UDP (`main.rs:36-47, 4769, 1989, 2030, 3638`) | Not adopted — see D1, D4 |
The shape of the rejection is consistent: **midstream's abstractions are LLM-token / dashboard-telemetry shaped, RuView's pipeline is RF-frame / event-detector shaped.** Where the two share vocabulary ("streaming", "temporal", "real-time"), the implementations diverge sharply — and the case-by-case analysis above shows that the closer one looks at each seam, the worse the fit gets.
---
## 4. Consequences
**Positive**
- Zero net change to RuView's build, runtime, or surface area; ADR-097's phased rvCSI adoption proceeds unaffected.
- The decision space around midstream is now bounded and documented; future contributors and AI agents see "ADR-098 already evaluated this; here is why not" before re-opening the question.
- The two crate-level carve-outs (D5, D6) are explicit, so if the relevant seams appear later, the evaluation can pick up from this ADR rather than start over.
- `vendor/midstream` (the submodule) remains as reference material, but is correctly marked as not part of the build path.
**Negative / costs**
- One more vendored repo with no in-build consumer — a small but non-zero cognitive load (mitigated by D7's explicit "do not bump the pin").
- If midstream's published crates evolve materially (e.g. a CSI-aware feature lands), the reasoning in §3 needs revisiting; this is the standard "rejected ADRs go stale" risk and applies to every Rejected ADR in the index.
**Risks**
- The most plausible failure mode of this ADR is *not* "we should have adopted midstream"; it is "we re-open the question in six months without re-reading this ADR." Mitigated by indexing ADR-098 in `docs/adr/README.md` and by the per-crate table in §3 being precise enough to short-circuit the next evaluator.
---
## 5. Alternatives considered
| Alternative | Why not |
|---|---|
| **Adopt midstream wholesale as RuView's streaming backbone** | Would force the CSI pipeline into the `Sequence<TemporalElement>` shape (`vendor/midstream/crates/temporal-compare/src/lib.rs:42-70`) and the `quic-multistream` transport (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`) — both are designed for LLM tokens / arbitrary streams, not validated RF frames with quality scoring. Conflicts directly with ADR-095 D5 (one `CsiFrame` schema), D6 (validate before crossing boundaries), and D9 (deterministic replay). |
| **Replace `tokio::sync::broadcast` with midstream's QUIC fan-out** | Solves no observed problem. `broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769` handles N WebSocket subscribers at zero per-subscriber cost; the lagged-subscriber semantics (`RecvError::Lagged`) are exactly what an event-feed wants. QUIC adds TLS + congestion control + per-stream priority — useful for *external* clients across a network, but the sensing-server's clients connect over WS on the same host or LAN. |
| **Replace `EventPipeline` with `temporal-attractor-studio` / `temporal-neural-solver`** | `EventPipeline` is deterministic by contract (`vendor/rvcsi/crates/rvcsi-events/src/lib.rs:20`) and ADR-097 just made it RuView's event source of truth. Attractor analysis and LTL verification operate on entirely different abstractions; using them as event detectors would re-invent rvCSI's pipeline in a less-determined way. |
| **Adopt `midstreamer-temporal-compare` for gesture detection now** | RuvSense already has a working DTW gesture matcher tuned to CSI signal scale. Swapping it for a generic `TemporalElement<T>` matcher buys cleanliness but costs a re-tune and a new dep tree (`dashmap`, `lru`). Tracked as D5 for if/when a *second* DTW use case shows up. |
| **Adopt `midstreamer-scheduler` for the cluster-Pi aggregator** | The cluster aggregator does not currently exist as a real-time scheduler; ADR-039's tier scheduling is firmware-side. Until the host-side schedule appears, importing a deadline-aware scheduler is solution-looking-for-a-problem. Tracked as D6. |
| **Drop the `vendor/midstream` submodule entirely** | Cheap to keep, useful as the reference material this ADR cites. D7 keeps it on the explicit understanding that the pin is not advanced. |
---
## 6. Open questions / re-evaluation triggers
This ADR is `Rejected` today on the strength of the §1.1 / §3 analysis. The following events would justify re-opening it:
1. **A second DTW / LCS / Levenshtein use case appears in RuView** (e.g. a CLI-side replay diff, a regression test fixture that needs sequence alignment, a TUI for pattern playback). Then re-evaluate `midstreamer-temporal-compare` per D5.
2. **A host-side real-time scheduler enters RuView's scope** (e.g. the cluster-Pi aggregator becomes responsible for slot timing instead of the ESP32 firmware). Then re-evaluate `midstreamer-scheduler` per D6.
3. **midstream ships a CSI-aware adapter or RF-scale `Sequence<T>` extension** — i.e. midstream's own scope grows to include sensing primitives. As of the pinned commit (`30fe5eb`), this has not happened (zero matches for `csi|wifi|sensing|sensor` in `vendor/midstream/crates/*/src/*.rs`).
4. **RuView gains a QUIC-to-external-client requirement** that the WS fan-out cannot service (e.g. a mobile client over a lossy link that benefits from QUIC's stream priority + 0-RTT). Then re-evaluate `midstreamer-quic` per D1 / D4.
If none of these triggers fire, this ADR stays Rejected and the carve-outs (D5, D6) remain optional.
---
## 7. References
- [ADR-095 — rvCSI Edge RF Sensing Platform](ADR-095-rvcsi-edge-rf-sensing-platform.md) — sets the single-`CsiFrame` schema, deterministic replay, and quality-scoring constraints that midstream's abstractions conflict with.
- [ADR-096 — rvCSI Crate Topology, the napi-c Shim, the napi-rs Surface](ADR-096-rvcsi-ffi-crate-layout.md) — the crate topology that rvCSI fills the candidate seams with.
- [ADR-097 — Adopt rvCSI as RuView's primary CSI runtime](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) — phased adoption (P1-P5) that this ADR explicitly does not duplicate.
- [ADR-012 — ESP32 CSI Sensor Mesh](ADR-012-esp32-csi-sensor-mesh.md) — the multi-source TDM context for D3.
- [ADR-029 — RuvSense Multistatic Sensing Mode](ADR-029-ruvsense-multistatic-sensing-mode.md) — the wall-clock-anchored TDM that `midstreamer-scheduler` is the wrong shape for.
- [ADR-039 — ESP32 Edge Intelligence Pipeline](ADR-039-esp32-edge-intelligence.md) — the firmware-side tier scheduling that would need to move host-side before D6 activates.
- [`github.com/ruvnet/midstream`](https://github.com/ruvnet/midstream) — 5 published crates on crates.io (`temporal-compare`, `nanosecond-scheduler`, `temporal-attractor-studio`, `temporal-neural-solver`, `strange-loop`) + 1 local crate (`quic-multistream`); 139 passing tests.
- `vendor/midstream` (submodule) — pinned at `30fe5eb` (`vendor/midstream/Cargo.toml:16` describes the package as *"Real-time LLM streaming with inflight analysis"*).
- RuView code paths cited in §1: `v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47,1989,2030,3638-3640,4769`; `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220`; `firmware/esp32-csi-node/main/csi_collector.c:64,223-228`; `firmware/esp32-csi-node/main/adaptive_controller_decide.c:26-28`.
- RuvSense code paths cited in §3: `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs`, `longitudinal.rs`, `field_model.rs`, `adversarial.rs`, `coherence_gate.rs`.
- rvCSI code paths cited in §2: `vendor/rvcsi/crates/rvcsi-events/src/lib.rs:1-37`, `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`.
+1
View File
@@ -108,6 +108,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) | rvCSI — Edge RF Sensing Runtime Platform | Proposed |
| [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) | rvCSI — Crate Topology, the napi-c Shim, and the napi-rs Node Surface | Proposed |
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

@@ -0,0 +1,466 @@
# Pi 5 + Hailo Cluster: Building a Cognitive RF Observer with rvcsi
A field-tested tutorial for turning a 4-node Raspberry Pi 5 cluster into a
multistatic Wi-Fi CSI cognitive RF observer that learns room states,
predicts the next one, and flags anomalies — entirely from radio.
**Estimated time:** 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.*
+108 -3
View File
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
5. [REST API Reference](#rest-api-reference)
6. [WebSocket Streaming](#websocket-streaming)
@@ -28,13 +29,14 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
8. [Vital Sign Detection](#vital-sign-detection)
9. [CLI Reference](#cli-reference)
10. [Observatory Visualization](#observatory-visualization)
11. [Adaptive Classifier](#adaptive-classifier)
11. [Loading the Pretrained Model from Hugging Face](#loading-the-pretrained-model-from-hugging-face)
12. [Adaptive Classifier](#adaptive-classifier)
- [Recording Training Data](#recording-training-data)
- [Training the Model](#training-the-model)
- [Using the Trained Model](#using-the-trained-model)
12. [Training a Model](#training-a-model)
13. [Training a Model](#training-a-model)
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
13. [RVF Model Containers](#rvf-model-containers)
14. [RVF Model Containers](#rvf-model-containers)
14. [Hardware Setup](#hardware-setup)
- [ESP32-S3 Mesh](#esp32-s3-mesh)
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
@@ -331,6 +333,46 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
### Connect Mesh Data to the Dashboard and Observatory
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
```bash
# From a source build
cd v2
cargo run -p wifi-densepose-sensing-server -- \
--source esp32 \
--udp-port 5005 \
--http-port 3000 \
--ws-port 3001 \
--ui-path ../../ui
# Docker
docker run --rm \
-e CSI_SOURCE=esp32 \
-p 3000:3000 \
-p 3001:3001 \
-p 5005:5005/udp \
ruvnet/wifi-densepose:latest
```
Open the UI from the sensing server, not from a local file:
| View | URL |
|------|-----|
| Dashboard | `http://localhost:3000/ui/index.html` |
| Observatory | `http://localhost:3000/ui/observatory.html` |
Use these checks before debugging the browser:
```bash
curl http://localhost:3000/health
curl http://localhost:3000/api/v1/nodes
curl http://localhost:3000/api/v1/sensing/latest
```
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
### Cognitum Seed Integration (ADR-069)
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
@@ -752,6 +794,67 @@ The Observatory is an immersive Three.js visualization that renders WiFi sensing
---
## Loading the Pretrained Model from Hugging Face
A pretrained CSI encoder + presence-detection head is published on Hugging Face at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained). It was trained on 60,630 frames / 610,615 contrastive triplets (12.2M steps, final loss 0.065) and reports 100% presence accuracy and ~164k embeddings/sec on an Apple M4 Pro.
What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
### Download
```bash
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained \
--local-dir models/wifi-densepose-pretrained
```
The download yields a small set of files (the `.rvf.jsonl` is the canonical container the sensing server reads):
```
models/wifi-densepose-pretrained/
model.rvf.jsonl # RVF container (encoder + presence head + lora)
model.safetensors # 48 KB — same encoder weights, safetensors format
model-q4.bin # 8 KB — recommended quantization for edge
presence-head.json # presence classifier head
config.json # sona-lora rank=8 alpha=16, target encoder + task_heads
```
### Using the weights
The HF artifact is in **JSONL RVF** format (one JSON object per line: `metadata`, `encoder`, `lora`). What you can do with it today:
| Consumer | Format it reads | Status |
|----------|-----------------|--------|
| Python / PyTorch training pipeline | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| RVF JSONL inspection / re-export | `model.rvf.jsonl` | ✅ Works — plain JSONL, parse line-by-line |
| Sensing-server `--model <PATH>` flag | binary RVF (`RVFS` magic) | ⚠️ Does **not** accept the JSONL file yet — see gap below |
**Known gap (tracked):** `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` only parses the binary RVF segment format (magic `0x52564653`). Pointing `--model` at `model.rvf.jsonl` causes the progressive loader to error with `invalid magic at offset 0: expected 0x52564653, got 0x7974227B` (`0x7974227B` is the ASCII bytes `{"ty…` from the JSONL header), and the live pipeline degrades to null output rather than falling back to heuristic mode. Until a JSONL adapter lands (or the model is re-published as binary RVF), run the sensing-server **without** `--model` and consume the HF weights from Python or the training pipeline.
```bash
# Works today — Python side (training, evaluation, embedding extraction):
python -c "
from safetensors.torch import load_file
state = load_file('models/wifi-densepose-pretrained/model.safetensors')
print({k: tuple(v.shape) for k, v in state.items()})
"
# Sensing server — run heuristic for now:
cargo run -p wifi-densepose-sensing-server --release -- \
--source esp32 --udp-port 5005 --http-port 3000
```
See [RVF Model Containers](#rvf-model-containers) for the binary format the loader expects, and [Training a Model](#training-a-model) for using the encoder as a starting point for environment-specific fine-tuning.
---
## Adaptive Classifier
The adaptive classifier (ADR-048) learns your environment's specific WiFi signal patterns from labeled recordings. It replaces static threshold-based classification with a trained logistic regression model that uses 15 features (7 server-computed + 8 subcarrier-derived statistics).
@@ -1744,6 +1847,8 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still
- Verify the sensing server is running: `curl http://localhost:3000/health`
- Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL)
- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005` instead; the Observatory reads the server WebSocket, not the standalone aggregator output
- Verify the ESP32 nodes are provisioned to the IP address of the machine running `sensing-server`
- Hard refresh with Ctrl+Shift+R to clear cached settings
- The auto-detect probes `/health` on the same origin — cross-origin won't work
+10
View File
@@ -0,0 +1,10 @@
# Mixamo FBX downloads — too large + license boundary. Get your own from
# mixamo.com (FBX Binary + T-Pose / Without Skin), drop into assets/.
*.fbx
# Diagnostic / debug screenshots from a dev session. Official screenshots
# live in screenshots/ and are committed; these underscore-prefixed ones
# are scratch.
_diag-*.png
_demo-mode-shot*.png
_PROOF-*.png
+77
View File
@@ -0,0 +1,77 @@
# three.js demos
Five progressively richer browser demos of the ADR-097 sensing-helpers scene,
ending with a live MediaPipe-Pose → Mixamo X Bot retargeting pipeline driven
by a real ESP32 CSI feed.
## Run them
```bash
python examples/three.js/server/serve-demo.py
# then open one of the URLs the script prints
```
`server/serve-demo.py` is a tiny `ThreadingHTTPServer` with aggressive
no-cache headers — the stdlib `http.server` is single-threaded and times out
on the parallel script + FBX fetches the demos make.
## Demos
| # | File | What it shows |
|---|------|---------------|
| 01 | [`demos/01-helpers.html`](demos/01-helpers.html) | Plain ADR-097 helpers in the point-cloud viewer |
| 02 | [`demos/02-cinematic.html`](demos/02-cinematic.html) | Cinematic camera + pseudo-CSI visualization on top of #01 |
| 03 | [`demos/03-skinned.html`](demos/03-skinned.html) | GLTF skinned mesh + additive animation blending |
| 04 | [`demos/04-skinned-fbx.html`](demos/04-skinned-fbx.html) | Mixamo X Bot loaded from FBX in the ADR-097 scene |
| 05 | [`demos/05-skinned-realtime.html`](demos/05-skinned-realtime.html) | Webcam → MediaPipe Pose Heavy → Mixamo IK retarget, live ESP32 CSI overlay |
| Screenshot | |
|---|---|
| ![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
@@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · ADR-097 · three.js helpers in the point cloud viewer</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-panel: rgba(0, 0, 0, 0.88);
--amber: #e8a634;
--amber-dim: #4a3a1a;
--amber-hot: #ffc04d;
--grid-major: #444444;
--grid-minor: #222222;
--green: #4f4;
--blue: #4cf;
--text-mute: #888;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--amber);
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
canvas { display: block; }
/* Top-left HUD */
#info {
position: absolute;
top: 16px;
left: 16px;
padding: 14px 16px;
background: var(--bg-panel);
border: 1px solid var(--amber);
border-radius: 8px;
min-width: 280px;
max-width: 340px;
font-size: 12px;
line-height: 1.55;
z-index: 10;
backdrop-filter: blur(6px);
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
}
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
#info .row .k { color: var(--text-mute); }
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
#info .row .v.live { color: var(--green); }
/* Bottom-left helper toggle panel */
#controls {
position: absolute;
bottom: 16px;
left: 16px;
padding: 12px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
z-index: 10;
backdrop-filter: blur(6px);
min-width: 220px;
}
#controls h2 {
margin: 0 0 8px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-mute);
font-weight: 600;
}
#controls label {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
user-select: none;
}
#controls label:hover { color: var(--amber-hot); }
#controls input[type=checkbox] {
accent-color: var(--amber);
width: 14px;
height: 14px;
cursor: pointer;
}
#controls .helper-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-left: auto;
}
/* Bottom-right ADR badge */
#adr-badge {
position: absolute;
bottom: 16px;
right: 16px;
padding: 8px 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11px;
color: var(--text-mute);
z-index: 10;
backdrop-filter: blur(6px);
}
#adr-badge a { color: var(--amber); text-decoration: none; }
#adr-badge a:hover { color: var(--amber-hot); }
/* Top-right legend */
#legend {
position: absolute;
top: 16px;
right: 16px;
padding: 12px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 11px;
z-index: 10;
backdrop-filter: blur(6px);
min-width: 200px;
}
#legend h2 {
margin: 0 0 8px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-mute);
font-weight: 600;
}
#legend .item {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
}
#legend .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
#legend .label { font-size: 11px; line-height: 1.3; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<div id="info">
<h1>RuView · Helpers Demo</h1>
<div class="sub">ADR-097 · three.js helpers for the point cloud viewer</div>
<div class="row"><span class="k">Scene</span><span class="v live">● SYNTHETIC</span></div>
<div class="row"><span class="k">Skeleton</span><span class="v">17 kpts · COCO</span></div>
<div class="row"><span class="k">Point cloud</span><span class="v" id="pc-count">— pts</span></div>
<div class="row"><span class="k">Sensor nodes</span><span class="v">4 · multistatic</span></div>
<div class="row"><span class="k">Frame rate</span><span class="v" id="fps">— fps</span></div>
<div class="row"><span class="k">Bbox volume</span><span class="v" id="bbox-vol">— m³</span></div>
</div>
<div id="controls">
<h2>Helpers</h2>
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="helper-swatch" style="background:#444"></span></label>
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="helper-swatch" style="background:#4a3a1a"></span></label>
<label><input type="checkbox" id="t-bbox" checked>BoxHelper<span class="helper-swatch" style="background:#e8a634"></span></label>
<label><input type="checkbox" id="t-axes" checked>AxesHelper<span class="helper-swatch" style="background:linear-gradient(90deg,#f44,#4f4,#4cf)"></span></label>
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="helper-swatch" style="background:#4cf"></span></label>
</div>
<div id="legend">
<h2>Scene</h2>
<div class="item"><span class="dot" style="background:#ffff00"></span><span class="label">COCO-17 keypoints (yellow)</span></div>
<div class="item"><span class="dot" style="background:#ffffff"></span><span class="label">Bones (white lines)</span></div>
<div class="item"><span class="dot" style="background:#4cf"></span><span class="label">Face point cloud (cyan→white)</span></div>
<div class="item"><span class="dot" style="background:#e8a634"></span><span class="label">ESP32 sensor nodes</span></div>
</div>
<div id="adr-badge">
ADR-097 · <a href="https://threejs.org/examples/#webgl_helpers" target="_blank" rel="noopener">three.js helpers</a>
</div>
<script>
// =====================================================================
// RuView · ADR-097 · three.js helpers demo
// --------------------------------------------------------------------
// Self-contained, no backend. Demonstrates how `GridHelper`,
// `PolarGridHelper`, `BoxHelper`, and `AxesHelper` slot into the
// RuView point cloud viewer (`v2/crates/wifi-densepose-pointcloud
// /src/viewer.html`). Open this file in a browser — no build step.
//
// The scene contains:
// 1. A synthetic walking, breathing 17-keypoint skeleton.
// 2. A face-shaped point cloud attached to the skeleton head.
// 3. Four multistatic sensor-node markers arranged around the room.
// 4. All four ADR-097 helpers, toggleable from the bottom-left panel.
//
// Coordinate frame matches the production viewer:
// +X = right, +Y = up, +Z = away from camera.
// Floor at y = -1.5, person hip at y = 0, head reaches ~ y = 0.7.
// =====================================================================
const COCO_BONES = [
// head
[0, 1], [0, 2], [1, 3], [2, 4],
// torso
[5, 6], [5, 11], [6, 12], [11, 12],
// left arm
[5, 7], [7, 9],
// right arm
[6, 8], [8, 10],
// left leg
[11, 13], [13, 15],
// right leg
[12, 14], [14, 16],
];
// Static "T-pose" skeleton in local frame, animated each frame.
// 17 keypoints in COCO order. Units: meters.
const SKELETON_BASE = {
0: [ 0.00, 0.65, 0.00], // nose
1: [-0.04, 0.68, 0.04], // L eye
2: [ 0.04, 0.68, 0.04], // R eye
3: [-0.08, 0.64, 0.00], // L ear
4: [ 0.08, 0.64, 0.00], // R ear
5: [-0.18, 0.45, 0.00], // L shoulder
6: [ 0.18, 0.45, 0.00], // R shoulder
7: [-0.22, 0.20, 0.00], // L elbow
8: [ 0.22, 0.20, 0.00], // R elbow
9: [-0.26, -0.05, 0.00], // L wrist
10: [ 0.26, -0.05, 0.00], // R wrist
11: [-0.10, 0.00, 0.00], // L hip
12: [ 0.10, 0.00, 0.00], // R hip
13: [-0.12, -0.40, 0.00], // L knee
14: [ 0.12, -0.40, 0.00], // R knee
15: [-0.12, -0.80, 0.00], // L ankle
16: [ 0.12, -0.80, 0.00], // R ankle
};
// ---------------------------------------------------------------------
// Scene + camera + renderer
// ---------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
scene.fog = new THREE.Fog(0x0a0a0a, 6, 14);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.05, 100);
camera.position.set(3.0, 1.4, 4.2);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1.5;
controls.maxDistance = 12;
controls.maxPolarAngle = Math.PI * 0.92;
// ---------------------------------------------------------------------
// ADR-097 helpers — wired to checkbox toggles
// ---------------------------------------------------------------------
// GridHelper — Cartesian floor reference. Establishes "down" and
// scale: 4 m × 4 m floor, 20 divisions = 0.2 m grid spacing.
const gridHelper = new THREE.GridHelper(4, 20, 0x444444, 0x222222);
gridHelper.position.y = -1.5;
scene.add(gridHelper);
// PolarGridHelper — multistatic geometry reference. 16 radial
// divisions (angular bins) × 4 concentric circles, centered on
// the fusion target. Matches the bin count in
// signal/src/ruvsense/multistatic.rs:attention_weight().
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0x4a3a1a, 0x2a1f10);
polarHelper.position.y = -1.499; // a hair above grid to avoid z-fight
scene.add(polarHelper);
// AxesHelper — XYZ tripod at origin. Red = X, green = Y, blue = Z.
const axesHelper = new THREE.AxesHelper(0.5);
axesHelper.position.set(0, -1.49, 0);
scene.add(axesHelper);
// BoxHelper — per-person bounding volume. Refreshed each frame
// after the skeleton is updated. Color = RuView amber.
let bboxHelper = null;
// ---------------------------------------------------------------------
// Skeleton — joint spheres + bone lines, animated
// ---------------------------------------------------------------------
const skeletonGroup = new THREE.Group();
scene.add(skeletonGroup);
const jointGeo = new THREE.SphereGeometry(0.025, 12, 12);
const jointMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const joints = [];
for (let i = 0; i < 17; i++) {
const sphere = new THREE.Mesh(jointGeo, jointMat);
const p = SKELETON_BASE[i];
sphere.position.set(p[0], p[1], p[2]);
sphere.userData.baseY = p[1];
sphere.userData.baseX = p[0];
sphere.userData.idx = i;
skeletonGroup.add(sphere);
joints.push(sphere);
}
const boneMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
const bones = [];
for (const [a, b] of COCO_BONES) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const line = new THREE.Line(geom, boneMat);
line.userData = { a, b };
skeletonGroup.add(line);
bones.push(line);
}
// ---------------------------------------------------------------------
// Face point cloud — synthetic ellipsoid attached to head keypoint
// ---------------------------------------------------------------------
const FACE_POINTS = 600;
const facePositions = new Float32Array(FACE_POINTS * 3);
const faceColors = new Float32Array(FACE_POINTS * 3);
const faceOffsets = new Float32Array(FACE_POINTS * 3); // canonical face shape, relative to nose
for (let i = 0; i < FACE_POINTS; i++) {
// Sample points roughly on a face-shaped ellipsoid (taller than wide).
const u = Math.random() * Math.PI * 2;
const v = (Math.random() - 0.5) * Math.PI;
const cu = Math.cos(u), su = Math.sin(u);
const cv = Math.cos(v), sv = Math.sin(v);
// ellipsoid radii (head-like proportions)
const rx = 0.085, ry = 0.105, rz = 0.075;
faceOffsets[i * 3 + 0] = rx * cv * cu;
faceOffsets[i * 3 + 1] = ry * sv;
faceOffsets[i * 3 + 2] = rz * cv * su;
// depth-encoded color: cyan at back, near-white at front (toward +Z = away from camera)
const depthT = (sv + 1) * 0.5;
faceColors[i * 3 + 0] = 0.30 + 0.70 * depthT; // R
faceColors[i * 3 + 1] = 0.80 + 0.20 * depthT; // G
faceColors[i * 3 + 2] = 1.00; // B
}
const faceGeom = new THREE.BufferGeometry();
faceGeom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
faceGeom.setAttribute('color', new THREE.BufferAttribute(faceColors, 3));
const faceMat = new THREE.PointsMaterial({
size: 0.012,
vertexColors: true,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
const facePoints = new THREE.Points(faceGeom, faceMat);
skeletonGroup.add(facePoints);
document.getElementById('pc-count').textContent = FACE_POINTS + ' pts';
// ---------------------------------------------------------------------
// Multistatic sensor nodes — 4 ESP32 markers around the room
// ---------------------------------------------------------------------
const nodeGroup = new THREE.Group();
scene.add(nodeGroup);
const NODE_POSITIONS = [
[-1.9, 1.3, 1.9], // back-left high
[ 1.9, 1.3, 1.9], // back-right high
[-1.9, 1.3, -1.9], // front-left high
[ 1.9, 1.3, -1.9], // front-right high
];
const nodeBboxHelpers = [];
const nodeGeo = new THREE.BoxGeometry(0.12, 0.06, 0.18);
const nodeMat = new THREE.MeshBasicMaterial({ color: 0xe8a634 });
const nodeAntennaGeo = new THREE.ConeGeometry(0.018, 0.08, 8);
const nodeAntennaMat = new THREE.MeshBasicMaterial({ color: 0xffc04d });
NODE_POSITIONS.forEach((pos, i) => {
const group = new THREE.Group();
group.position.set(pos[0], pos[1], pos[2]);
const body = new THREE.Mesh(nodeGeo, nodeMat);
group.add(body);
// little antenna sticking up
const antenna = new THREE.Mesh(nodeAntennaGeo, nodeAntennaMat);
antenna.position.y = 0.07;
group.add(antenna);
// pulsing emissive ring (visualizes RX activity)
const ringGeo = new THREE.RingGeometry(0.10, 0.13, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xe8a634, side: THREE.DoubleSide, transparent: true, opacity: 0.4 });
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.04;
ring.userData.phase = i * 0.5;
group.add(ring);
group.userData.ring = ring;
// sight-line from node to scene origin (visualizes multistatic geometry)
const sightGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(-pos[0], -pos[1], -pos[2]),
]);
const sightMat = new THREE.LineDashedMaterial({
color: 0xe8a634, transparent: true, opacity: 0.18,
dashSize: 0.1, gapSize: 0.06,
});
const sightLine = new THREE.Line(sightGeo, sightMat);
sightLine.computeLineDistances();
group.add(sightLine);
nodeGroup.add(group);
// ADR-097 §3.3 — per-node BoxHelper. Demonstrates that helpers
// compose naturally: one box per detected object.
const bbox = new THREE.BoxHelper(group, 0x4cf);
scene.add(bbox);
nodeBboxHelpers.push(bbox);
});
// ---------------------------------------------------------------------
// Animation — synthetic motion model
// ---------------------------------------------------------------------
let frameStart = performance.now();
let frameCount = 0;
let fpsAvg = 0;
function applyPose(t) {
// Body sway (slow), breathing (chest expansion), arm/leg swing (walking).
const swayX = Math.sin(t * 0.35) * 0.05;
const swayZ = Math.cos(t * 0.27) * 0.04;
const breathe = Math.sin(t * 1.4) * 0.012; // chest in/out
const walkPhase = t * 1.9; // walk cycle
skeletonGroup.position.set(swayX, 0, swayZ);
skeletonGroup.rotation.y = Math.sin(t * 0.22) * 0.18;
for (let i = 0; i < 17; i++) {
const base = SKELETON_BASE[i];
let dx = 0, dy = 0, dz = 0;
// breathing — shoulders + nose rise a little
if (i === 0 || i === 1 || i === 2) dy = breathe * 0.6;
if (i === 5 || i === 6) dy = breathe;
// arm swing (opposite of legs)
if (i === 7) { dz = Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
if (i === 9) { dz = Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
if (i === 8) { dz = -Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
if (i === 10){ dz = -Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
// leg swing
if (i === 13){ dz = -Math.sin(walkPhase) * 0.08; }
if (i === 15){ dz = -Math.sin(walkPhase) * 0.15; dy = Math.max(0, Math.cos(walkPhase)) * 0.04; }
if (i === 14){ dz = Math.sin(walkPhase) * 0.08; }
if (i === 16){ dz = Math.sin(walkPhase) * 0.15; dy = Math.max(0, -Math.cos(walkPhase)) * 0.04; }
joints[i].position.set(base[0] + dx, base[1] + dy, base[2] + dz);
}
// update bone line vertices from current joint positions
for (const line of bones) {
const { a, b } = line.userData;
const pa = joints[a].position;
const pb = joints[b].position;
const pos = line.geometry.attributes.position;
pos.array[0] = pa.x; pos.array[1] = pa.y; pos.array[2] = pa.z;
pos.array[3] = pb.x; pos.array[4] = pb.y; pos.array[5] = pb.z;
pos.needsUpdate = true;
}
// attach face point cloud to the nose keypoint (kpt 0)
const nose = joints[0].position;
const positions = faceGeom.attributes.position;
const headTurn = Math.sin(t * 0.6) * 0.35; // y-axis nod
const cosH = Math.cos(headTurn), sinH = Math.sin(headTurn);
for (let i = 0; i < FACE_POINTS; i++) {
const ox = faceOffsets[i * 3 + 0];
const oy = faceOffsets[i * 3 + 1];
const oz = faceOffsets[i * 3 + 2];
// rotate offset around Y axis by headTurn
const rx = cosH * ox + sinH * oz;
const rz = -sinH * ox + cosH * oz;
positions.array[i * 3 + 0] = nose.x + rx;
positions.array[i * 3 + 1] = nose.y + oy;
positions.array[i * 3 + 2] = nose.z + rz;
}
positions.needsUpdate = true;
}
function updateNodes(t) {
nodeGroup.children.forEach((node, i) => {
const ring = node.userData.ring;
const phase = (t * 1.8 + ring.userData.phase) % (Math.PI * 2);
ring.material.opacity = 0.18 + 0.42 * Math.max(0, Math.cos(phase));
ring.scale.setScalar(1 + 0.18 * Math.max(0, Math.cos(phase)));
});
}
function updateBboxHelper() {
const want = document.getElementById('t-bbox').checked;
if (!want) {
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
return;
}
skeletonGroup.updateMatrixWorld(true);
if (!bboxHelper) {
bboxHelper = new THREE.BoxHelper(skeletonGroup, 0xe8a634);
scene.add(bboxHelper);
} else {
bboxHelper.setFromObject(skeletonGroup);
}
// compute volume for the HUD
const box = new THREE.Box3().setFromObject(skeletonGroup);
const size = box.getSize(new THREE.Vector3());
document.getElementById('bbox-vol').textContent =
(size.x * size.y * size.z).toFixed(3) + ' m³';
}
function tick() {
const now = performance.now();
const t = now * 0.001;
const dt = now - frameStart;
frameStart = now;
frameCount++;
if (frameCount % 30 === 0) {
fpsAvg = 1000 / dt;
document.getElementById('fps').textContent = fpsAvg.toFixed(0) + ' fps';
}
applyPose(t);
updateNodes(t);
updateBboxHelper();
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// ---------------------------------------------------------------------
// Controls wiring — checkbox toggles attach/detach helpers from scene
// ---------------------------------------------------------------------
function bindToggle(id, obj) {
const el = document.getElementById(id);
el.addEventListener('change', () => {
if (el.checked) {
if (!scene.children.includes(obj)) scene.add(obj);
} else {
scene.remove(obj);
}
});
}
bindToggle('t-grid', gridHelper);
bindToggle('t-polar', polarHelper);
bindToggle('t-axes', axesHelper);
// per-node bbox toggle (group of 4)
document.getElementById('t-nodebox').addEventListener('change', (e) => {
for (const bb of nodeBboxHelpers) {
if (e.target.checked) {
if (!scene.children.includes(bb)) scene.add(bb);
} else {
scene.remove(bb);
}
}
});
// ---------------------------------------------------------------------
// Resize
// ---------------------------------------------------------------------
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+854
View File
@@ -0,0 +1,854 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation blending</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #050507;
--bg-panel: rgba(8, 10, 14, 0.78);
--amber: #ffb840;
--amber-hot: #ffe09f;
--cyan: #4cf;
--magenta: #ff4cc8;
--text: #d8c69a;
--text-mute: #6b6155;
--border: rgba(255, 184, 64, 0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255, 184, 64, 0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
#info { top: 20px; left: 20px; min-width: 280px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.mag { color: var(--magenta); }
#anim {
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#anim .group { padding: 6px 0; border-bottom: 1px solid rgba(255,184,64,0.08); }
#anim .group:last-child { border-bottom: none; }
#anim .group-label { font-size: 10px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
#anim button {
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
}
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#anim .slider-row .label { width: 90px; color: var(--text-mute); }
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi { top: 20px; right: 20px; min-width: 260px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
}
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#helpers {
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#helpers label {
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
}
#helpers label:hover { color: var(--amber-hot); }
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
#loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(5, 5, 7, 0.96); z-index: 20; font-size: 13px; color: var(--amber);
letter-spacing: 2px; text-transform: uppercase;
}
#loading.hidden { display: none; }
#loading .text {
text-shadow: 0 0 12px var(--amber);
animation: loadPulse 1.4s ease-in-out infinite;
}
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
@keyframes scanFlash {
0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; }
}
.scan-flash {
position: fixed; inset: 0;
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
}
#titlecard {
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
text-transform: uppercase; opacity: 0.35; z-index: 10;
text-shadow: 0 0 12px var(--amber); pointer-events: none;
}
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
#adr-badge {
position: absolute; top: 50%; right: 20px; transform: translateY(-50%);
padding: 6px 10px; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; font-size: 9px; color: var(--text-mute); z-index: 10;
backdrop-filter: blur(8px); letter-spacing: 0.5px; max-width: 70px; text-align: center; line-height: 1.5;
}
#adr-badge a { color: var(--amber); text-decoration: none; display: block; }
#adr-badge a:hover { color: var(--amber-hot); }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="scan-flash" id="scan-flash"></div>
<div id="loading"><div class="text">▸ Loading skinned subject · Xbot.glb · 2.9 MB</div></div>
<div class="panel" id="info">
<h1>RuView · Skinned</h1>
<div class="sub">ADR-097 · GLTF skinned mesh · additive animation blending</div>
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
<div class="row"><span class="k">Model</span><span class="v">Xbot.glb · 14k tris</span></div>
<div class="row"><span class="k">Base anim</span><span class="v amber" id="base-name">walk</span></div>
<div class="row"><span class="k">Additive</span><span class="v mag" id="add-name">headShake · 0.40</span></div>
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
</div>
<div id="anim">
<h2>AnimationMixer</h2>
<div class="group">
<div class="group-label">Base · loops</div>
<button data-base="idle">idle</button>
<button data-base="walk" class="active">walk</button>
<button data-base="run">run</button>
</div>
<div class="group">
<div class="group-label">Additive · layered</div>
<button data-add="agree">agree</button>
<button data-add="headShake" class="active">headShake</button>
<button data-add="sad_pose">sad</button>
<button data-add="sneak_pose">sneak</button>
</div>
<div class="group">
<div class="slider-row">
<span class="label">add weight</span>
<input type="range" id="add-weight" min="0" max="1" step="0.01" value="0.40">
<span class="val" id="add-weight-val">0.40</span>
</div>
<div class="slider-row">
<span class="label">time scale</span>
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
<span class="val" id="time-scale-val">1.00</span>
</div>
</div>
</div>
<div class="panel" id="csi">
<h2>Per-node CSI</h2>
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0" style="width:0"></div></div><span class="val" id="val-0"></span></div>
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1" style="width:0"></div></div><span class="val" id="val-1"></span></div>
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2" style="width:0"></div></div><span class="val" id="val-2"></span></div>
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3" style="width:0"></div></div><span class="val" id="val-3"></span></div>
</div>
<div id="helpers">
<h2>ADR-097 helpers</h2>
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
</div>
<div id="titlecard">
RuView · Seldon Vault
<div class="sub">skinned · ADR-097 · CCDIKSolver next</div>
</div>
<div id="adr-badge">
<a href="https://threejs.org/examples/#webgl_animation_skinning_additive_blending" target="_blank" rel="noopener">additive blend</a>
<a href="https://threejs.org/examples/#webgl_animation_skinning_ik" target="_blank" rel="noopener" style="margin-top:4px;">skinning IK</a>
</div>
<script>
// =====================================================================
// RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation
// --------------------------------------------------------------------
// Replaces the procedural sphere-skeleton of helpers-cinematic.html
// with a real rigged + skinned humanoid loaded from Xbot.glb. Plays
// a base loop (walk / run / idle) and layers an additive pose on
// top (headShake / agree / sneak / sad) — mirrors the upstream
// three.js webgl_animation_skinning_additive_blending example.
//
// All ADR-097 helpers still wrap the loaded mesh — BoxHelper picks
// up the live AABB of the SkinnedMesh, the polar grid sits under
// the rig, and per-node BoxHelpers wrap the four ESP32 markers.
//
// Production path (next): swap canned GLTF animations for live
// COCO-17 keypoint output → CCDIKSolver targets on hands/feet/head.
// Reference: three.js webgl_animation_skinning_ik example.
// =====================================================================
const MODEL_URL = 'https://threejs.org/examples/models/gltf/Xbot.glb';
const NODE_POSITIONS = [
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
];
// ---------------------------------------------------------------------
// Scene
// ---------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.06);
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(3.2, 1.55, 4.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.80;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.9, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 2; controls.maxDistance = 12;
controls.maxPolarAngle = Math.PI * 0.92;
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
controls.autoRotateSpeed = 0.25;
// ---------------------------------------------------------------------
// Lights — the GLTF uses PBR materials so we actually need lighting
// here (unlike the all-emissive cinematic.html). Tuned to keep the
// amber/cyan mood: amber hemi + amber key + cyan rim lights from
// each node direction (visualizes "the nodes illuminate the subject").
// ---------------------------------------------------------------------
const hemiLight = new THREE.HemisphereLight(0x553a18, 0x080606, 0.7);
hemiLight.position.set(0, 4, 0);
scene.add(hemiLight);
const keyLight = new THREE.DirectionalLight(0xffc070, 0.95);
keyLight.position.set(2.5, 3.8, 2.5);
keyLight.castShadow = true;
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.bias = -0.0008;
scene.add(keyLight);
// cyan rim lights, one per ESP32 node — keeps the "sensed by the mesh" mood
const rimLights = [];
NODE_POSITIONS.forEach(pos => {
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
scene.add(rim);
rimLights.push(rim);
});
// ---------------------------------------------------------------------
// Post-processing — same composer as cinematic.html
// ---------------------------------------------------------------------
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.45, 0.40, 0.78,
);
composer.addPass(bloom);
const filmShader = {
uniforms: {
tDiffuse: { value: null },
time: { value: 0 }, grain: { value: 0.04 },
vignette: { value: 0.32 }, aberration: { value: 0.0018 },
},
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
varying vec2 vUv;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 off = (vUv - 0.5) * aberration;
float r = texture2D(tDiffuse, vUv + off).r;
float g = texture2D(tDiffuse, vUv).g;
float b = texture2D(tDiffuse, vUv - off).b;
vec3 col = vec3(r, g, b);
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
col *= mix(1.0 - vignette, 1.0, v);
gl_FragColor = vec4(col, 1.0);
}`,
};
const filmPass = new THREE.ShaderPass(filmShader);
composer.addPass(filmPass);
// ---------------------------------------------------------------------
// Floor — same procedural cyber grid (toned down for skinned scene)
// ---------------------------------------------------------------------
const floorMat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
void main() {
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
scan = pow(scan, 14.0);
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
gl_FragColor = vec4(col * falloff, falloff * 0.55);
}`,
transparent: true, depthWrite: false,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
scene.add(floor);
// shadow-receiving ground (invisible, just catches the shadow)
const shadowGround = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.55 })
);
shadowGround.rotation.x = -Math.PI / 2;
shadowGround.position.y = 0.001;
shadowGround.receiveShadow = true;
scene.add(shadowGround);
// ---------------------------------------------------------------------
// ADR-097 helpers
// ---------------------------------------------------------------------
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
scene.add(gridHelper);
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
polarHelper.position.y = 0.002;
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
scene.add(polarHelper);
let bboxHelper = null;
let skeletonHelper = null;
// ---------------------------------------------------------------------
// Multistatic sensor nodes — same as cinematic
// ---------------------------------------------------------------------
const nodeGroup = new THREE.Group();
scene.add(nodeGroup);
const nodeBboxHelpers = [];
const nodeRings = [];
const nodeAnchors = [];
const nodeBodyGeo = new THREE.BoxGeometry(0.14, 0.06, 0.20);
const nodeBodyMat = new THREE.MeshBasicMaterial({ color: 0xffb840 });
const antennaGeo = new THREE.ConeGeometry(0.018, 0.10, 8);
const antennaMat = new THREE.MeshBasicMaterial({ color: 0xffe09f });
NODE_POSITIONS.forEach((pos, i) => {
const group = new THREE.Group();
group.position.set(pos[0], pos[1], pos[2]);
const body = new THREE.Mesh(nodeBodyGeo, nodeBodyMat);
group.add(body);
const antenna = new THREE.Mesh(antennaGeo, antennaMat);
antenna.position.y = 0.08; group.add(antenna);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.11, 0.14, 32),
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide, transparent: true,
opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
);
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
ring.userData.phase = i * 0.7;
group.add(ring); nodeRings.push(ring);
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.025, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xffe09f })
);
core.position.y = 0.04; group.add(core);
nodeGroup.add(group); nodeAnchors.push(group);
const bbox = new THREE.BoxHelper(group, 0x4cf);
bbox.material.transparent = true; bbox.material.opacity = 0.45;
scene.add(bbox); nodeBboxHelpers.push(bbox);
});
// ---------------------------------------------------------------------
// GLTF — load the rigged Xbot model
// ---------------------------------------------------------------------
let model = null;
let mixer = null;
let headBone = null;
const baseActions = {}; // idle / walk / run
const additiveActions = {}; // sneak_pose / sad_pose / agree / headShake
let currentBase = 'walk';
let currentAddName = 'headShake';
let addWeight = 0.40;
const loader = new THREE.GLTFLoader();
loader.load(MODEL_URL, (gltf) => {
model = gltf.scene;
model.position.y = 0;
model.traverse(obj => {
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
if (obj.isBone && /head/i.test(obj.name) && !headBone) headBone = obj;
});
scene.add(model);
skeletonHelper = new THREE.SkeletonHelper(model);
skeletonHelper.visible = false;
scene.add(skeletonHelper);
mixer = new THREE.AnimationMixer(model);
const baseNames = new Set(['idle', 'walk', 'run']);
const additiveNames = new Set(['sneak_pose', 'sad_pose', 'agree', 'headShake']);
for (let i = 0; i < gltf.animations.length; i++) {
let clip = gltf.animations[i];
const name = clip.name;
if (baseNames.has(name)) {
const action = mixer.clipAction(clip);
action.enabled = true;
action.setEffectiveTimeScale(1);
action.setEffectiveWeight(name === currentBase ? 1 : 0);
action.play();
baseActions[name] = action;
} else if (additiveNames.has(name)) {
THREE.AnimationUtils.makeClipAdditive(clip);
if (name.endsWith('_pose')) {
clip = THREE.AnimationUtils.subclip(clip, name, 2, 3, 30);
}
const action = mixer.clipAction(clip);
action.enabled = true;
action.setEffectiveTimeScale(1);
action.setEffectiveWeight(name === currentAddName ? addWeight : 0);
action.play();
additiveActions[name] = action;
}
}
// build the face point cloud anchored to head bone
buildFacePointCloud();
document.getElementById('loading').classList.add('hidden');
}, (xhr) => {
const pct = xhr.loaded / (xhr.total || 2930032) * 100;
const txt = document.querySelector('#loading .text');
if (txt) txt.textContent = `▸ Loading skinned subject · Xbot.glb · ${pct.toFixed(0)} %`;
}, (err) => {
console.error('GLTF load failed', err);
document.querySelector('#loading .text').textContent = '⚠ Load failed — see console';
});
function setBase(name) {
if (!baseActions[name]) return;
for (const k in baseActions) {
const a = baseActions[k];
const target = (k === name) ? 1 : 0;
a.crossFadeTo ? null : null; // (no-op — using simple weight crossfade)
a.setEffectiveWeight(target);
}
currentBase = name;
document.getElementById('base-name').textContent = name;
for (const btn of document.querySelectorAll('#anim [data-base]')) {
btn.classList.toggle('active', btn.dataset.base === name);
}
}
function setAdditive(name) {
for (const k in additiveActions) {
additiveActions[k].setEffectiveWeight(k === name ? addWeight : 0);
}
currentAddName = name;
document.getElementById('add-name').textContent = name + ' · ' + addWeight.toFixed(2);
for (const btn of document.querySelectorAll('#anim [data-add]')) {
btn.classList.toggle('active', btn.dataset.add === name);
}
}
// ---------------------------------------------------------------------
// Face point cloud — anchored to head bone via getWorldPosition each frame
// ---------------------------------------------------------------------
const FACE_POINTS = 480;
const facePositions = new Float32Array(FACE_POINTS * 3);
const faceOffsets = new Float32Array(FACE_POINTS * 3);
const facePhases = new Float32Array(FACE_POINTS);
let facePoints = null;
function buildFacePointCloud() {
for (let i = 0; i < FACE_POINTS; i++) {
const u = Math.random() * Math.PI * 2;
const v = (Math.random() - 0.5) * Math.PI * 0.95;
const cu = Math.cos(u), su = Math.sin(u);
const cv = Math.cos(v), sv = Math.sin(v);
faceOffsets[i*3+0] = 0.085 * cv * cu;
faceOffsets[i*3+1] = 0.108 * sv;
faceOffsets[i*3+2] = 0.072 * cv * su;
facePhases[i] = Math.random() * Math.PI * 2;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 } },
vertexShader: `
attribute float aPhase; uniform float time;
varying float vAlpha;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
vAlpha = 0.18 + 0.30 * shimmer;
gl_Position = projectionMatrix * mv;
gl_PointSize = (1.6 + shimmer * 1.0) * (200.0 / -mv.z);
}`,
fragmentShader: `
varying float vAlpha;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard;
float falloff = smoothstep(0.5, 0.0, d);
vec3 col = mix(vec3(0.18, 0.52, 0.72), vec3(0.55, 0.62, 0.72), 0.5);
gl_FragColor = vec4(col * (1.0 + falloff * 0.3), vAlpha * falloff);
}`,
transparent: true, depthWrite: false,
});
facePoints = new THREE.Points(geom, mat);
scene.add(facePoints);
}
// ---------------------------------------------------------------------
// Sonar pings + tomography sweep — same as cinematic.html
// ---------------------------------------------------------------------
const PING_POOL = 24;
const pings = [];
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
for (let i = 0; i < PING_POOL; i++) {
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
const mesh = new THREE.Mesh(pingGeo, mat);
mesh.visible = false; scene.add(mesh);
pings.push({ mesh, active: false, t0: 0, duration: 0,
origin: new THREE.Vector3(), target: new THREE.Vector3() });
}
let pingIndex = 0;
function emitPing(origin, target) {
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
p.active = true; p.t0 = performance.now() * 0.001;
p.duration = 0.55 + Math.random() * 0.20;
p.origin.copy(origin); p.target.copy(target);
p.mesh.position.copy(origin); p.mesh.visible = true;
p.mesh.material.opacity = 0;
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
}
const tomoMat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform float time, intensity; varying vec2 vUv;
void main() {
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
gl_FragColor = vec4(col, intensity * band * 0.75);
}`,
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
});
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
tomoPlane.rotation.y = Math.PI / 2;
tomoPlane.position.set(-2, 1.0, 0);
tomoPlane.visible = false;
scene.add(tomoPlane);
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
// ---------------------------------------------------------------------
// Pseudo-CSI driver — same as cinematic
// ---------------------------------------------------------------------
const csiAmp = [0, 0, 0, 0];
let csiCoherence = 0.5;
const csiNoise = [0, 0, 0, 0];
function tickCsi(t, targetWorld) {
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
let mean = 0; const amps = [];
for (let i = 0; i < 4; i++) {
const np = NODE_POSITIONS[i];
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
const r2 = dx*dx + dy*dy + dz*dz;
const fall = 1.0 / (1.0 + r2 * 0.18);
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
amps.push(a);
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
mean += a;
}
mean /= 4;
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
v = Math.sqrt(v / 4);
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
}
// ---------------------------------------------------------------------
// Per-frame updates
// ---------------------------------------------------------------------
const tmpVec = new THREE.Vector3();
let lastPingT = [0, 0, 0, 0];
function updateNodes() {
for (let i = 0; i < 4; i++) {
const ring = nodeRings[i];
const amp = csiAmp[i];
ring.material.opacity = 0.32 + 0.55 * amp;
ring.scale.setScalar(1 + 0.30 * amp);
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
}
}
function maybeEmitPings(t, modelCenter) {
if (!document.getElementById('t-pings').checked || !model) return;
for (let i = 0; i < 4; i++) {
const interval = 1.2 / (0.25 + csiAmp[i]);
if (t - lastPingT[i] > interval) {
lastPingT[i] = t;
const target = modelCenter.clone();
target.y += (Math.random() - 0.3) * 0.8;
target.x += (Math.random() - 0.5) * 0.2;
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
emitPing(origin, target);
}
}
}
function updatePings(t) {
for (const p of pings) {
if (!p.active) continue;
const u = (t - p.t0) / p.duration;
if (u >= 1) { p.active = false; p.mesh.visible = false; continue; }
p.mesh.position.lerpVectors(p.origin, p.target, u);
p.mesh.scale.setScalar(0.03 + u * 0.18);
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
}
}
function updateTomography(t) {
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
if (!tomoActive && t > tomoNextAt) {
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
const sf = document.getElementById('scan-flash');
sf.style.animation = 'none';
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
}
if (tomoActive) {
const dur = 2.4;
const e = (t - tomoT0) / dur;
if (e >= 1) {
tomoActive = false; tomoPlane.visible = false;
tomoNextAt = t + 4 + Math.random() * 5;
} else {
tomoPlane.position.x = -3 + e * 6;
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
tomoMat.uniforms.time.value = t;
}
}
}
function updateBbox() {
const want = document.getElementById('t-bbox').checked && model;
if (!want) {
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
document.getElementById('bbox-vol').textContent = '—';
return;
}
if (!bboxHelper) {
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
scene.add(bboxHelper);
} else bboxHelper.setFromObject(model);
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
}
function updateFaceCloud(t) {
if (!facePoints || !headBone) return;
const headWorld = new THREE.Vector3();
headBone.getWorldPosition(headWorld);
const pos = facePoints.geometry.attributes.position;
for (let i = 0; i < FACE_POINTS; i++) {
pos.array[i*3+0] = headWorld.x + faceOffsets[i*3+0];
pos.array[i*3+1] = headWorld.y + faceOffsets[i*3+1] + 0.06;
pos.array[i*3+2] = headWorld.z + faceOffsets[i*3+2];
}
pos.needsUpdate = true;
facePoints.material.uniforms.time.value = t;
}
let hudT = 0;
function updateHud(t, fps) {
if (t - hudT < 0.1) return;
hudT = t;
for (let i = 0; i < 4; i++) {
const pct = Math.round(csiAmp[i] * 100);
document.getElementById('bar-' + i).style.width = pct + '%';
document.getElementById('val-' + i).textContent = pct + '%';
}
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
}
// ---------------------------------------------------------------------
// UI wiring
// ---------------------------------------------------------------------
for (const btn of document.querySelectorAll('#anim [data-base]')) {
btn.addEventListener('click', () => setBase(btn.dataset.base));
}
for (const btn of document.querySelectorAll('#anim [data-add]')) {
btn.addEventListener('click', () => setAdditive(btn.dataset.add));
}
document.getElementById('add-weight').addEventListener('input', (e) => {
addWeight = parseFloat(e.target.value);
document.getElementById('add-weight-val').textContent = addWeight.toFixed(2);
if (additiveActions[currentAddName]) additiveActions[currentAddName].setEffectiveWeight(addWeight);
document.getElementById('add-name').textContent = currentAddName + ' · ' + addWeight.toFixed(2);
});
document.getElementById('time-scale').addEventListener('input', (e) => {
const ts = parseFloat(e.target.value);
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
if (mixer) mixer.timeScale = ts;
});
function bindToggle(id, obj) {
document.getElementById(id).addEventListener('change', e => {
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
else if (!e.target.checked) scene.remove(obj);
});
}
bindToggle('t-grid', gridHelper);
bindToggle('t-polar', polarHelper);
document.getElementById('t-skel').addEventListener('change', e => {
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
});
document.getElementById('t-nodebox').addEventListener('change', e => {
for (const bb of nodeBboxHelpers) {
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
else if (!e.target.checked) scene.remove(bb);
}
});
// ---------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------
const clock = new THREE.Clock();
let lastMs = performance.now();
let fpsEma = 60;
function tick() {
const nowMs = performance.now();
const dt = nowMs - lastMs;
lastMs = nowMs;
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
const t = nowMs * 0.001;
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
floorMat.uniforms.time.value = t;
filmShader.uniforms.time.value = t;
// get model center for CSI / ping targeting
const center = new THREE.Vector3();
if (model) {
const box = new THREE.Box3().setFromObject(model);
box.getCenter(center);
} else center.set(0, 0.9, 0);
tickCsi(t, center);
updateNodes();
maybeEmitPings(t, center);
updatePings(t);
updateTomography(t);
updateBbox();
updateFaceCloud(t);
controls.update();
composer.render();
updateHud(t, fpsEma);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloom.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
+961
View File
@@ -0,0 +1,961 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · Skinned (FBX) · Mixamo X Bot in the ADR-097 helpers scene</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.78);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
#info { top: 20px; left: 20px; min-width: 280px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.mag { color: var(--magenta); }
#anim {
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#anim .row { padding: 6px 0; font-size: 10px; }
#anim .row .label { color: var(--text-mute); margin-right: 8px; }
#anim button {
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
}
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; margin-top: 6px; border-top: 1px solid rgba(255,184,64,0.08); padding-top: 8px; }
#anim .slider-row .label { width: 90px; }
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#anim .empty-hint {
font-size: 10px; color: var(--text-mute); line-height: 1.5; margin-top: 4px;
padding: 8px; background: rgba(255,184,64,0.04); border-radius: 3px;
border-left: 2px solid var(--amber);
}
#anim .empty-hint a { color: var(--amber); text-decoration: none; }
#anim .empty-hint a:hover { color: var(--amber-hot); text-decoration: underline; }
#helpers {
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#helpers label {
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
}
#helpers label:hover { color: var(--amber-hot); }
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
#csi { top: 20px; right: 20px; min-width: 260px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
}
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(5,5,7,0.96); z-index: 20; font-size: 13px; color: var(--amber);
letter-spacing: 2px; text-transform: uppercase;
}
#loading.hidden { display: none; }
#loading .text { text-shadow: 0 0 12px var(--amber); animation: loadPulse 1.4s ease-in-out infinite; }
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
@keyframes scanFlash { 0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; } }
.scan-flash {
position: fixed; inset: 0;
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
}
#titlecard {
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
text-transform: uppercase; opacity: 0.35; z-index: 10;
text-shadow: 0 0 12px var(--amber); pointer-events: none;
}
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://unpkg.com/fflate@0.7.4/umd/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/curves/NURBSCurve.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="scan-flash" id="scan-flash"></div>
<div id="loading"><div class="text">▸ Loading skinned subject · X Bot.fbx</div></div>
<div class="panel" id="info">
<h1>RuView · Skinned (FBX)</h1>
<div class="sub">ADR-097 · Mixamo X Bot · loaded via FBXLoader</div>
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
<div class="row"><span class="k">Source</span><span class="v" id="src-name">X Bot.fbx</span></div>
<div class="row"><span class="k">Format</span><span class="v">FBX 7700 · 1.75 MB</span></div>
<div class="row"><span class="k">Bones</span><span class="v" id="bone-count"></span></div>
<div class="row"><span class="k">Animation</span><span class="v amber" id="anim-name"></span></div>
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
</div>
<div id="anim">
<h2>AnimationMixer</h2>
<div class="row">
<span class="label">clips</span>
<span id="clip-buttons"></span>
</div>
<div class="slider-row">
<span class="label">time scale</span>
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
<span class="val" id="time-scale-val">1.00</span>
</div>
<div class="empty-hint" id="empty-hint" style="display:none;">
<strong>No animations in this FBX.</strong><br>
Mixamo's "T-Pose / Without Skin" export rigs the model but has no clips.
Re-download with <em>"Original Pose"</em> + an animation selected
(e.g. <a href="https://www.mixamo.com/#/?page=1&query=walking&type=Motion%2CMotionPack" target="_blank" rel="noopener">Walking</a>) to get a clip, or drop another FBX with anim and reload.
</div>
</div>
<div class="panel" id="csi">
<h2>Per-node CSI</h2>
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0"></div></div><span class="val" id="val-0"></span></div>
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1"></div></div><span class="val" id="val-1"></span></div>
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2"></div></div><span class="val" id="val-2"></span></div>
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3"></div></div><span class="val" id="val-3"></span></div>
</div>
<div id="helpers">
<h2>ADR-097 helpers</h2>
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
<label><input type="checkbox" id="t-rays" checked>RF illumination cones<span class="swatch" style="color:#ffb840"></span></label>
</div>
<div id="titlecard">
RuView · Seldon Vault
<div class="sub">FBXLoader · Mixamo · ADR-097</div>
</div>
<script>
// =====================================================================
// RuView · Skinned (FBX) · Mixamo X Bot loaded via FBXLoader
// --------------------------------------------------------------------
// Sibling of helpers-skinned.html that loads a local .fbx file
// rather than the canonical GLB. Same cinematic atmosphere
// (UnrealBloomPass, sonar pings, tomography sweep, pseudo-CSI),
// same ADR-097 helpers wrapping the rigged mesh.
//
// Mixamo FBX caveats handled here:
// 1. Mixamo exports in cm (100 = 1 m). We auto-detect by the
// loaded model's bbox height and rescale to ~1.7 m human size.
// 2. PhongMaterial → StandardMaterial swap for cleaner shading
// under our amber key + cyan rim lights.
// 3. Bone name probing for the head (Mixamo: "mixamorigHead",
// legacy: "Bip01_Head", or any bone with /head/i match).
// 4. Graceful no-animations case — many Mixamo exports are
// rig-only.
// =====================================================================
const MODEL_URL = '../assets/X%20Bot.fbx';
const NODE_POSITIONS = [
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
];
// ---------------------------------------------------------------------
// Scene / camera / renderer
// ---------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.06);
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(3.2, 1.55, 4.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.80;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.9, 0);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 2; controls.maxDistance = 12;
controls.maxPolarAngle = Math.PI * 0.92;
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
controls.autoRotateSpeed = 0.25;
// ---------------------------------------------------------------------
// Lights — amber key + cyan rim from each ESP32 direction
// ---------------------------------------------------------------------
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 1.05);
keyLight.position.set(2.5, 3.8, 2.5);
keyLight.castShadow = true;
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.bias = -0.0008;
scene.add(keyLight);
const rimLights = [];
NODE_POSITIONS.forEach(pos => {
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
scene.add(rim); rimLights.push(rim);
});
// ---------------------------------------------------------------------
// Post-processing
// ---------------------------------------------------------------------
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.45, 0.40, 0.78,
);
composer.addPass(bloom);
const filmShader = {
uniforms: { tDiffuse: { value: null }, time: { value: 0 }, grain: { value: 0.04 },
vignette: { value: 0.32 }, aberration: { value: 0.0018 } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
varying vec2 vUv;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 off = (vUv - 0.5) * aberration;
float r = texture2D(tDiffuse, vUv + off).r;
float g = texture2D(tDiffuse, vUv).g;
float b = texture2D(tDiffuse, vUv - off).b;
vec3 col = vec3(r, g, b);
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
col *= mix(1.0 - vignette, 1.0, v);
gl_FragColor = vec4(col, 1.0);
}`,
};
const filmPass = new THREE.ShaderPass(filmShader);
composer.addPass(filmPass);
// ---------------------------------------------------------------------
// Floor (same procedural shader as cinematic / skinned-glb)
// ---------------------------------------------------------------------
const floorMat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
void main() {
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
scan = pow(scan, 14.0);
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
gl_FragColor = vec4(col * falloff, falloff * 0.55);
}`,
transparent: true, depthWrite: false,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const shadowGround = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.55 })
);
shadowGround.rotation.x = -Math.PI / 2;
shadowGround.position.y = 0.001;
shadowGround.receiveShadow = true;
scene.add(shadowGround);
// ---------------------------------------------------------------------
// ADR-097 helpers + sensor nodes (same as helpers-skinned.html)
// ---------------------------------------------------------------------
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
scene.add(gridHelper);
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
polarHelper.position.y = 0.002;
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
scene.add(polarHelper);
let bboxHelper = null;
let skeletonHelper = null;
const nodeBboxHelpers = [];
const nodeRings = [];
const nodeAnchors = [];
NODE_POSITIONS.forEach((pos, i) => {
const group = new THREE.Group();
group.position.set(pos[0], pos[1], pos[2]);
group.add(new THREE.Mesh(
new THREE.BoxGeometry(0.14, 0.06, 0.20),
new THREE.MeshBasicMaterial({ color: 0xffb840 })
));
const antenna = new THREE.Mesh(
new THREE.ConeGeometry(0.018, 0.10, 8),
new THREE.MeshBasicMaterial({ color: 0xffe09f })
);
antenna.position.y = 0.08; group.add(antenna);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.11, 0.14, 32),
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide,
transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
);
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
group.add(ring); nodeRings.push(ring);
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.025, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xffe09f })
);
core.position.y = 0.04; group.add(core);
scene.add(group); nodeAnchors.push(group);
const bbox = new THREE.BoxHelper(group, 0x4cf);
bbox.material.transparent = true; bbox.material.opacity = 0.45;
scene.add(bbox); nodeBboxHelpers.push(bbox);
});
// ---------------------------------------------------------------------
// God-ray cones — one per node, pointed at the subject. Visualizes
// "the four ESP32s are jointly illuminating the body with RF". Each
// cone has a volumetric-feeling gradient shader and is opacity-
// modulated by that node's csiAmp × csiCoherence (so when a node's
// signal degrades, its ray dims).
// ---------------------------------------------------------------------
const godRayMat = (color, idx) => new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
intensity: { value: 0.0 },
color: { value: new THREE.Color(color) },
seed: { value: idx * 17.3 },
},
vertexShader: `
varying vec2 vUv;
varying float vY;
void main() {
vUv = uv;
vY = position.y;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform float time, intensity, seed;
uniform vec3 color;
varying vec2 vUv;
varying float vY;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
// along the cone (uv.y goes 0=tip → 1=base), fade out at the base
float edgeFade = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.65, vUv.y);
// soft radial falloff (cone-edge transparency)
float radial = sin(vUv.y * 3.14159);
radial = pow(radial, 2.0);
// volumetric noise (slow scrolling)
float n = hash(floor(vUv * vec2(40.0, 60.0)) + vec2(seed, time * 0.4));
float scroll = 0.85 + 0.30 * sin(vUv.y * 32.0 - time * 1.4 + seed);
float a = edgeFade * radial * scroll * (0.55 + 0.45 * n);
gl_FragColor = vec4(color, a * intensity * 0.25);
}`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const godRays = [];
for (let i = 0; i < 4; i++) {
// cone with apex at node, expanding toward the subject
// height 4 m (more than enough to reach subject), radius 0.45 m at base
const geom = new THREE.ConeGeometry(0.45, 4.0, 28, 1, true);
// ConeGeometry tip is at +Y, base at -Y — rotate so tip is along -Y
// (we'll later orient each cone so its tip touches the node).
geom.translate(0, -2.0, 0); // shift so apex is at origin
const mat = godRayMat(0xffb840, i);
const cone = new THREE.Mesh(geom, mat);
scene.add(cone);
godRays.push({ mesh: cone, mat });
}
function updateGodRays(t) {
if (!model) return;
const want = document.getElementById('t-rays').checked;
const center = new THREE.Vector3();
const box = new THREE.Box3().setFromObject(model);
box.getCenter(center);
for (let i = 0; i < 4; i++) {
godRays[i].mesh.visible = want;
if (!want) continue;
const node = nodeAnchors[i];
const np = node.getWorldPosition(new THREE.Vector3());
const dir = new THREE.Vector3().subVectors(center, np);
const len = dir.length();
dir.normalize();
const ray = godRays[i];
ray.mesh.position.copy(np);
// align cone's -Y axis (apex direction after the geometry shift)
// to point along `dir`
ray.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir);
// stretch cone length to actual distance, keep base width reasonable
ray.mesh.scale.set(1, len / 4.0, 1);
ray.mat.uniforms.time.value = t;
// intensity follows that node's CSI amplitude * global coherence
const target = csiAmp[i] * csiCoherence * 1.4;
ray.mat.uniforms.intensity.value =
ray.mat.uniforms.intensity.value * 0.85 + target * 0.15;
}
}
// ---------------------------------------------------------------------
// FBX load with Mixamo-aware fixups
// ---------------------------------------------------------------------
let model = null;
let mixer = null;
let headBone = null;
let boneCount = 0;
const clipActions = {}; // by clip name
let currentClip = null;
const loader = new THREE.FBXLoader();
loader.load(MODEL_URL, (object) => {
model = object;
// 1. Scale fix — Mixamo defaults to cm; detect by bbox height and
// rescale so the rig reads as ~1.7 m human size.
const raw = new THREE.Box3().setFromObject(model);
const height = raw.max.y - raw.min.y;
if (height > 10) {
model.scale.setScalar(1 / 100); // cm → m
} else if (height > 5) {
model.scale.setScalar(1 / 50); // catch in-between rigs
}
// recenter on origin at floor
const b2 = new THREE.Box3().setFromObject(model);
model.position.y -= b2.min.y;
// 2. Material upgrade + shadow casting + head/bone scan
model.traverse((obj) => {
if (obj.isMesh) {
obj.castShadow = true;
obj.receiveShadow = true;
// Phong → Standard for cleaner shading under our PBR lights.
// Keep diffuse map + skinning intact.
if (obj.material && obj.material.isMeshPhongMaterial) {
const m = obj.material;
const upgraded = new THREE.MeshStandardMaterial({
map: m.map, normalMap: m.normalMap, color: m.color,
skinning: !!obj.isSkinnedMesh,
metalness: 0.0, roughness: 0.85,
});
obj.material = upgraded;
}
}
if (obj.isBone) {
boneCount++;
if (!headBone && /head/i.test(obj.name)) headBone = obj;
}
});
document.getElementById('bone-count').textContent = boneCount;
scene.add(model);
skeletonHelper = new THREE.SkeletonHelper(model);
skeletonHelper.visible = false;
scene.add(skeletonHelper);
// 3. Animations — Mixamo exports one clip per FBX (sometimes none)
const clips = object.animations || [];
if (clips.length === 0) {
document.getElementById('anim-name').textContent = 'none (rig-only)';
document.getElementById('empty-hint').style.display = 'block';
} else {
mixer = new THREE.AnimationMixer(model);
const btnHost = document.getElementById('clip-buttons');
for (const clip of clips) {
const action = mixer.clipAction(clip);
clipActions[clip.name] = action;
const btn = document.createElement('button');
btn.textContent = clip.name || 'clip-' + Object.keys(clipActions).length;
btn.addEventListener('click', () => playClip(clip.name));
btnHost.appendChild(btn);
}
playClip(clips[0].name);
}
// 4. Face point cloud
if (headBone) buildFacePointCloud();
document.getElementById('loading').classList.add('hidden');
}, (xhr) => {
const total = xhr.total || 1750032;
const pct = (xhr.loaded / total * 100).toFixed(0);
const txt = document.querySelector('#loading .text');
if (txt) txt.textContent = `▸ Loading skinned subject · X Bot.fbx · ${pct} %`;
}, (err) => {
console.error('FBX load failed', err);
const txt = document.querySelector('#loading .text');
if (txt) txt.textContent = '⚠ Load failed — see console';
});
function playClip(name) {
for (const k in clipActions) {
const a = clipActions[k];
if (k === name) {
a.reset(); a.play(); currentClip = name;
document.getElementById('anim-name').textContent = name;
for (const btn of document.querySelectorAll('#anim button[data-base], #anim button')) {
if (btn.dataset.base !== undefined || !btn.textContent) continue;
btn.classList.toggle('active', btn.textContent === name);
}
} else a.stop();
}
}
// ---------------------------------------------------------------------
// Face point cloud — anchored to head bone, same shimmer shader
// ---------------------------------------------------------------------
const FACE_POINTS = 220; // fewer points so each dot is visible as a tracked landmark
const facePositions = new Float32Array(FACE_POINTS * 3);
const faceOffsets = new Float32Array(FACE_POINTS * 3);
const facePhases = new Float32Array(FACE_POINTS);
let facePoints = null;
function buildFacePointCloud() {
// Front-hemisphere only — points scattered on the +Z half of an
// ellipsoid so the cloud reads as a FACE projection forward from
// the head bone, not a halo wrapping the skull. Local coords:
// +Z = forward (face direction), +Y = up, +X = right.
for (let i = 0; i < FACE_POINTS; i++) {
// theta in [0, 2π) around the local Z axis, phi in [0, π/2]
// (front hemisphere only — no points behind the head)
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(1 - Math.random() * 0.95); // dense near face front
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
// ellipsoid radii (taller than wide, slightly squashed F-B)
const rx = 0.085, ry = 0.108, rz = 0.075;
// local coords with +Z = face forward
faceOffsets[i*3+0] = rx * sinPhi * Math.cos(theta);
faceOffsets[i*3+1] = ry * sinPhi * Math.sin(theta) * 1.05; // taller
faceOffsets[i*3+2] = rz * cosPhi; // forward
facePhases[i] = Math.random() * Math.PI * 2;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 } },
vertexShader: `
attribute float aPhase; uniform float time;
varying float vAlpha;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
// Slow per-point shimmer + occasional "scan-lit" spike
// so the cloud reads as discrete tracked landmarks
// rather than a fluffy halo.
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
float spark = step(0.95, fract(sin(aPhase * 17.0 + time * 0.5) * 43.0));
vAlpha = 0.10 + 0.25 * shimmer + 0.55 * spark;
gl_Position = projectionMatrix * mv;
// 6× smaller — tracked dots, not a cloud
gl_PointSize = (1.0 + shimmer * 0.6 + spark * 1.5) * (32.0 / -mv.z);
}`,
fragmentShader: `
varying float vAlpha;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard;
float falloff = smoothstep(0.5, 0.0, d);
vec3 col = vec3(0.40, 0.78, 1.00);
gl_FragColor = vec4(col, vAlpha * falloff);
}`,
transparent: true, depthWrite: false,
});
facePoints = new THREE.Points(geom, mat);
scene.add(facePoints);
}
// ---------------------------------------------------------------------
// Pings + tomography + CSI driver — copied wholesale from skinned-glb
// ---------------------------------------------------------------------
const PING_POOL = 24;
const pings = [];
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
for (let i = 0; i < PING_POOL; i++) {
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
const mesh = new THREE.Mesh(pingGeo, mat); mesh.visible = false; scene.add(mesh);
pings.push({ mesh, active: false, t0: 0, duration: 0,
origin: new THREE.Vector3(), target: new THREE.Vector3() });
}
let pingIndex = 0;
function emitPing(origin, target) {
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
p.active = true; p.t0 = performance.now() * 0.001;
p.duration = 0.55 + Math.random() * 0.20;
p.origin.copy(origin); p.target.copy(target);
p.mesh.position.copy(origin); p.mesh.visible = true; p.mesh.material.opacity = 0;
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
}
const tomoMat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform float time, intensity; varying vec2 vUv;
void main() {
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
gl_FragColor = vec4(col, intensity * band * 0.75);
}`,
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
});
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
tomoPlane.rotation.y = Math.PI / 2;
tomoPlane.position.set(-2, 1.0, 0); tomoPlane.visible = false;
scene.add(tomoPlane);
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
const csiAmp = [0, 0, 0, 0];
let csiCoherence = 0.5;
const csiNoise = [0, 0, 0, 0];
function tickCsi(t, targetWorld) {
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
let mean = 0; const amps = [];
for (let i = 0; i < 4; i++) {
const np = NODE_POSITIONS[i];
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
const r2 = dx*dx + dy*dy + dz*dz;
const fall = 1.0 / (1.0 + r2 * 0.18);
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
amps.push(a);
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
mean += a;
}
mean /= 4;
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
v = Math.sqrt(v / 4);
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
}
let lastPingT = [0, 0, 0, 0];
// Subject hit-flash: when a sonar ping lands, briefly raise the
// emissive on every mesh in the model. Decays each frame.
let subjectFlash = 0;
const modelMeshes = [];
function collectModelMeshes() {
if (!model || modelMeshes.length) return;
model.traverse(o => {
if (o.isMesh && o.material && o.material.isMeshStandardMaterial) {
o.material.emissive = new THREE.Color(0xffb840);
o.material.emissiveIntensity = 0;
modelMeshes.push(o);
}
});
}
function updateSubjectFlash() {
collectModelMeshes();
subjectFlash *= 0.86;
for (const m of modelMeshes) {
m.material.emissiveIntensity = subjectFlash;
}
}
// Subtle root motion — even with a stationary Idle clip, give the
// figure a gentle drift + look-around so it doesn't feel pinned.
function updateRootMotion(t) {
if (!model) return;
model.position.x = Math.sin(t * 0.18) * 0.06;
model.position.z = Math.cos(t * 0.13) * 0.05;
model.rotation.y = Math.sin(t * 0.11) * 0.18;
}
function updateNodes() {
for (let i = 0; i < 4; i++) {
const ring = nodeRings[i];
const amp = csiAmp[i];
ring.material.opacity = 0.32 + 0.55 * amp;
ring.scale.setScalar(1 + 0.30 * amp);
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
}
}
function maybeEmitPings(t, modelCenter) {
if (!document.getElementById('t-pings').checked || !model) return;
for (let i = 0; i < 4; i++) {
const interval = 1.2 / (0.25 + csiAmp[i]);
if (t - lastPingT[i] > interval) {
lastPingT[i] = t;
const target = modelCenter.clone();
target.y += (Math.random() - 0.3) * 0.8;
target.x += (Math.random() - 0.5) * 0.2;
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
emitPing(origin, target);
}
}
}
function updatePings(t) {
for (const p of pings) {
if (!p.active) continue;
const u = (t - p.t0) / p.duration;
if (u >= 1) {
p.active = false; p.mesh.visible = false;
// ping landed — flash the subject (drives emissiveIntensity)
subjectFlash = Math.min(0.42, subjectFlash + 0.18);
continue;
}
p.mesh.position.lerpVectors(p.origin, p.target, u);
p.mesh.scale.setScalar(0.03 + u * 0.18);
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
}
}
function updateTomography(t) {
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
if (!tomoActive && t > tomoNextAt) {
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
const sf = document.getElementById('scan-flash');
sf.style.animation = 'none';
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
}
if (tomoActive) {
const dur = 2.4;
const e = (t - tomoT0) / dur;
if (e >= 1) {
tomoActive = false; tomoPlane.visible = false;
tomoNextAt = t + 4 + Math.random() * 5;
} else {
tomoPlane.position.x = -3 + e * 6;
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
tomoMat.uniforms.time.value = t;
}
}
}
function updateBbox() {
const want = document.getElementById('t-bbox').checked && model;
if (!want) {
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
document.getElementById('bbox-vol').textContent = '—';
return;
}
if (!bboxHelper) {
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
scene.add(bboxHelper);
} else bboxHelper.setFromObject(model);
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
}
const tmpHeadPos = new THREE.Vector3();
const tmpHeadQuat = new THREE.Quaternion();
const tmpHeadScl = new THREE.Vector3();
const tmpOffset = new THREE.Vector3();
function updateFaceCloud(t) {
if (!facePoints || !headBone) return;
// Decompose the head bone's world matrix so we can apply its
// orientation (face direction) to each local offset. This way
// the cloud rotates with the head — turn left/right and the
// face points stay in front of the face.
headBone.updateMatrixWorld(true);
headBone.matrixWorld.decompose(tmpHeadPos, tmpHeadQuat, tmpHeadScl);
// Mixamo head bone forward is along +Y in some rigs (head looks up the
// bone chain) — project the cloud along the model's actual forward
// vector, which for Mixamo X Bot facing camera is world +Z.
// Use the model's root rotation as the source of "forward".
const forward = new THREE.Vector3(0, 0, 1);
if (model) forward.applyQuaternion(model.getWorldQuaternion(new THREE.Quaternion()));
const up = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
const facingUp = up.clone();
// anchor the cloud just in front of the head
const anchor = tmpHeadPos.clone().addScaledVector(forward, 0.04);
anchor.y += 0.04; // nudge up so cloud sits over the face, not the chin
const pos = facePoints.geometry.attributes.position;
for (let i = 0; i < FACE_POINTS; i++) {
const ox = faceOffsets[i*3+0];
const oy = faceOffsets[i*3+1];
const oz = faceOffsets[i*3+2];
// map local (ox, oy, oz) into world via (right, up, forward)
tmpOffset.copy(right).multiplyScalar(ox)
.addScaledVector(facingUp, oy)
.addScaledVector(forward, oz);
pos.array[i*3+0] = anchor.x + tmpOffset.x;
pos.array[i*3+1] = anchor.y + tmpOffset.y;
pos.array[i*3+2] = anchor.z + tmpOffset.z;
}
pos.needsUpdate = true;
facePoints.material.uniforms.time.value = t;
}
let hudT = 0;
function updateHud(t, fps) {
if (t - hudT < 0.1) return;
hudT = t;
for (let i = 0; i < 4; i++) {
const pct = Math.round(csiAmp[i] * 100);
document.getElementById('bar-' + i).style.width = pct + '%';
document.getElementById('val-' + i).textContent = pct + '%';
}
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
}
// UI wiring
document.getElementById('time-scale').addEventListener('input', (e) => {
const ts = parseFloat(e.target.value);
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
if (mixer) mixer.timeScale = ts;
});
function bindToggle(id, obj) {
document.getElementById(id).addEventListener('change', e => {
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
else if (!e.target.checked) scene.remove(obj);
});
}
bindToggle('t-grid', gridHelper);
bindToggle('t-polar', polarHelper);
document.getElementById('t-skel').addEventListener('change', e => {
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
});
document.getElementById('t-nodebox').addEventListener('change', e => {
for (const bb of nodeBboxHelpers) {
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
else if (!e.target.checked) scene.remove(bb);
}
});
// ---------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------
const clock = new THREE.Clock();
let lastMs = performance.now();
let fpsEma = 60;
function tick() {
const nowMs = performance.now();
const dt = nowMs - lastMs;
lastMs = nowMs;
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
const t = nowMs * 0.001;
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
floorMat.uniforms.time.value = t;
filmShader.uniforms.time.value = t;
const center = new THREE.Vector3();
if (model) {
const box = new THREE.Box3().setFromObject(model);
box.getCenter(center);
} else center.set(0, 0.9, 0);
tickCsi(t, center);
updateRootMotion(t);
updateNodes();
updateGodRays(t);
maybeEmitPings(t, center);
updatePings(t);
updateSubjectFlash();
updateTomography(t);
updateBbox();
updateFaceCloud(t);
controls.update();
composer.render();
updateHud(t, fpsEma);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloom.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""ruvultra → browser CSI bridge.
Reads adaptive_ctrl tick lines from the ESP32-S3 RuView firmware on
/dev/ttyACM0 and forwards normalized per-node metrics over a WebSocket
that the helpers-skinned-realtime demo can subscribe to via Tailscale.
Sample serial line (1 Hz cadence from firmware):
I (22890561) adaptive_ctrl: medium tick: state=6 yield=15pps motion=1.00 presence=5.35 rssi=-33
Output JSON (per tick):
{
"ts": 1716830400.123,
"node": 0, # always 0 (single node), client expands to 4
"motion": 1.00, # raw firmware metric
"presence": 5.35,
"rssi": -33,
"yield_pps": 15,
"amp": 0.78 # synthesized CSI amplitude in [0..1] for the bar
}
Run on ruvultra:
python3 -u ruvultra-csi-bridge.py
"""
import asyncio
import builtins
import json
import re
import sys
import time
from contextlib import suppress
# Force every print to flush — we're often piped to a log file
_orig_print = builtins.print
def _print(*a, **kw):
kw.setdefault("flush", True)
return _orig_print(*a, **kw)
builtins.print = _print
import serial
import websockets
PORT = "/dev/ttyACM0"
BAUD = 115200
WS_HOST = "0.0.0.0"
WS_PORT = 8766
TICK_RE = re.compile(
r"adaptive_ctrl:\s*\w+\s+tick:\s*"
r"state=(?P<state>\d+)\s+"
r"yield=(?P<yield>\d+)pps\s+"
r"motion=(?P<motion>[\d.]+)\s+"
r"presence=(?P<presence>[\d.]+)\s+"
r"rssi=(?P<rssi>-?\d+)"
)
clients = set()
last_payload = None
def amp_from_metrics(motion, presence, rssi):
"""Map firmware metrics to a [0..1] CSI-style amplitude."""
rssi_norm = max(0.0, min(1.0, (rssi + 80) / 50)) # -80..-30 → 0..1
presence_norm = max(0.0, min(1.0, presence / 8.0)) # cap at 8
motion_norm = max(0.0, min(1.0, motion)) # already 0..1ish
return 0.40 * rssi_norm + 0.35 * presence_norm + 0.25 * motion_norm
async def serial_reader_loop():
global last_payload
print(f"[bridge] opening {PORT} @ {BAUD}")
while True:
try:
ser = serial.Serial(PORT, BAUD, timeout=1)
except (serial.SerialException, OSError) as e:
print(f"[bridge] serial open failed ({e}); retry in 3s")
await asyncio.sleep(3)
continue
print(f"[bridge] connected to {PORT}")
loop = asyncio.get_event_loop()
try:
while True:
line = await loop.run_in_executor(None, ser.readline)
if not line:
continue
try:
text = line.decode(errors="replace").strip()
except Exception:
continue
m = TICK_RE.search(text)
if not m:
continue
motion = float(m["motion"])
presence = float(m["presence"])
rssi = int(m["rssi"])
payload = {
"ts": time.time(),
"node": 0,
"state": int(m["state"]),
"yield_pps": int(m["yield"]),
"motion": motion,
"presence": presence,
"rssi": rssi,
"amp": amp_from_metrics(motion, presence, rssi),
}
last_payload = payload
msg = json.dumps(payload)
if clients:
dead = []
for ws in list(clients):
try:
await ws.send(msg)
except websockets.ConnectionClosed:
dead.append(ws)
for d in dead:
clients.discard(d)
print(
f"[tick] motion={motion:.2f} presence={presence:5.2f} "
f"rssi={rssi:+d} yield={int(m['yield']):3d}pps "
f"amp={payload['amp']:.2f} clients={len(clients)}"
)
except (serial.SerialException, OSError) as e:
print(f"[bridge] serial error ({e}); reopen in 1s")
with suppress(Exception):
ser.close()
await asyncio.sleep(1)
async def ws_handler(ws):
addr = ws.remote_address
clients.add(ws)
print(f"[ws] client connected: {addr} total={len(clients)}")
try:
if last_payload is not None:
await ws.send(json.dumps(last_payload))
await ws.wait_closed()
finally:
clients.discard(ws)
print(f"[ws] client gone: {addr} total={len(clients)}")
async def main():
print(f"[bridge] websocket on ws://{WS_HOST}:{WS_PORT}")
async with websockets.serve(ws_handler, WS_HOST, WS_PORT):
await serial_reader_loop()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+46
View File
@@ -0,0 +1,46 @@
"""Tiny threaded HTTP server for the three.js demos that fetch local files.
Why a sibling helper script instead of `python -m http.server`?
The stdlib SimpleHTTPServer is single-threaded; Chrome opens many parallel
connections (HTML + 9 script tags + FBX), the first eats the worker, the
rest time out with net::ERR_EMPTY_RESPONSE. ThreadingHTTPServer fixes it.
Usage:
python examples/three.js/server/serve-demo.py
open http://localhost:8765/examples/three.js/demos/05-skinned-realtime.html
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os, sys
PORT = int(os.environ.get("PORT", 8765))
# Always serve from the repo root regardless of where the script is launched.
# This file lives at examples/three.js/server/serve-demo.py — three levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so browser ALWAYS fetches the latest .html
# after we edit it. Otherwise stale code sticks around even on hard
# refresh and you debug a phantom.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
DEMOS = [
"01-helpers.html",
"02-cinematic.html",
"03-skinned.html",
"04-skinned-fbx.html",
"05-skinned-realtime.html",
]
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print("demos:")
for d in DEMOS:
print(f" http://127.0.0.1:{PORT}/examples/three.js/demos/{d}")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
+37 -11
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 sensing** | Phase variance + adaptive calibration | < 1 ms latency |
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
@@ -37,18 +37,22 @@ MSYS_NO_PATHCONV=1 docker run --rm \
### 2. Flash
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
`bootloader=0x0`, `partition-table=0x8000`, `otadata=0xf000`, `app (ota_0)=0x20000`.
```bash
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
### 3. Provision WiFi credentials (no reflash needed)
```bash
python scripts/provision.py --port COM7 \
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
```
@@ -129,11 +133,32 @@ Adds real-time health and safety monitoring.
- **Breathing rate** -- biquad IIR bandpass 0.1-0.5 Hz, zero-crossing BPM (6-30 BPM)
- **Heart rate** -- biquad IIR bandpass 0.8-2.0 Hz, zero-crossing BPM (40-120 BPM)
- **Presence detection** -- adaptive threshold calibration (60 s ambient learning)
- **Presence indicator** -- phase variance vs an adaptively-calibrated threshold (60 s ambient learning at boot). Heuristic, not a learned classifier — strong RF interferers (fans, microwaves, transmit-power swings) can push variance above threshold without anyone in the room. See "What this firmware does NOT do" below.
- **Fall detection** -- phase acceleration exceeds configurable threshold
- **Multi-person estimation** -- subcarrier group clustering (up to 4 persons)
- **Multi-person slot count** -- partitions the top-K subcarriers into `top_k / 2` groups (clamped to `[1, EDGE_MAX_PERSONS]`), computes per-group filtered breathing/heart-rate estimates, and reports the slot count as `pkt.n_persons`. This is a **slot-capacity heuristic**, not a learned counter — the reported count tracks subcarrier diversity, not actual occupancy. See [`edge_processing.c:481-548`](main/edge_processing.c#L481-L548).
- **Vitals packet** -- 32-byte UDP packet at 1 Hz (magic `0xC5110002`)
### What this firmware does NOT do (Tier 2 caveats)
- It does **not** run a trained neural model. The "person count" is an
arithmetic slot-capacity heuristic over the top-K subcarrier groups
(`firmware/esp32-csi-node/main/edge_processing.c:481`). It tracks
subcarrier diversity, not actual occupancy.
- It does **not** run pose estimation. Pose-related features in the host
UI come from the Rust `wifi-densepose-sensing-server` running a separate
pipeline. When no `.rvf` model file is loaded via `--model`, the server
drives the on-screen skeleton from signal-based heuristics (amplitude
variance, motion-band power), not from learned keypoint inference. The
repository does not ship pre-trained weights — see issues
[#509](../../issues/509) and [#506](../../issues/506) for context, and
[ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for
the planned training path (phases P7-P9 are `Pending`).
- The presence indicator is a calibrated variance threshold and **will
false-positive** under strong RF interference from non-human sources
(fans near the antenna, microwave duty cycles, neighbouring AP power
swings) without re-running the 60-second ambient calibration. If you
see ghost detections, re-calibrate by power-cycling in an empty room.
### Tier 3 -- WASM Programmable Sensing (Alpha)
Turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules -- compiled from Rust, packaged in signed RVF containers.
@@ -254,9 +279,10 @@ Find your serial port: `COM7` on Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.SLAB
```bash
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
### Serial Monitor
@@ -285,7 +311,7 @@ All settings can be changed at runtime via Non-Volatile Storage (NVS) without re
The easiest way to write NVS settings:
```bash
python scripts/provision.py --port COM7 \
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "MyWiFi" \
--password "MyPassword" \
--target-ip 192.168.1.20
+25 -2
View File
@@ -11,7 +11,26 @@ set(SRCS
"adaptive_controller.c"
)
set(REQUIRES "")
# ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps).
set(REQUIRES
esp_wifi
esp_netif
esp_event
nvs_flash
app_update
esp_http_server
esp_http_client
esp_app_format
esp_timer
esp_pm
esp_driver_uart
esp_driver_gpio
esp_driver_spi
esp_driver_i2c
driver
lwip
mbedtls
)
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
if(CONFIG_CSI_MOCK_ENABLED)
@@ -21,7 +40,11 @@ endif()
# ADR-045: AMOLED display support (compile-time optional)
if(CONFIG_DISPLAY_ENABLE)
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
set(REQUIRES esp_lcd esp_lcd_touch lvgl)
list(APPEND REQUIRES esp_lcd esp_lcd_touch lvgl)
endif()
if(CONFIG_WASM_ENABLE)
list(APPEND REQUIRES wasm3)
endif()
idf_component_register(
@@ -371,6 +371,30 @@ void csi_collector_init(void)
ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)");
#if CONFIG_SOC_WIFI_HE_SUPPORT
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
* (bitfields), not the legacy 802.11n bool layout used on ESP32-S3. */
wifi_csi_config_t csi_config;
memset(&csi_config, 0, sizeof(csi_config));
csi_config.enable = 1U;
csi_config.acquire_csi_legacy = 1U;
csi_config.acquire_csi_ht20 = 1U;
csi_config.acquire_csi_ht40 = 1U;
csi_config.acquire_csi_su = 1U;
csi_config.acquire_csi_mu = 1U;
csi_config.acquire_csi_dcm = 1U;
csi_config.acquire_csi_beamformed = 1U;
#if CONFIG_SOC_WIFI_MAC_VERSION_NUM >= 3
csi_config.acquire_csi_force_lltf = 1U;
csi_config.acquire_csi_vht = 1U;
csi_config.acquire_csi_he_stbc_mode = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
csi_config.val_scale_cfg = 0U;
#else
csi_config.acquire_csi_he_stbc = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
csi_config.val_scale_cfg = 0U;
#endif
csi_config.dump_ack_en = 0U;
#else
wifi_csi_config_t csi_config = {
.lltf_en = true,
.htltf_en = true,
@@ -380,6 +404,7 @@ void csi_collector_init(void)
.manu_scale = false,
.shift = false,
};
#endif
ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi_config));
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
@@ -2,8 +2,9 @@
* @file edge_processing.c
* @brief ADR-039 Edge Intelligence — dual-core CSI processing pipeline.
*
* Core 0 (WiFi task): Pushes raw CSI frames into lock-free SPSC ring buffer.
* Core 1 (DSP task): Pops frames, runs signal processing pipeline:
* Core 0 (WiFi path): Pushes raw CSI frames into lock-free SPSC ring buffer.
* Second core when present (DSP task): pops frames, runs signal processing pipeline.
* On unicore targets (e.g. ESP32-C6), the DSP task is pinned to core 0.
* 1. Phase extraction from I/Q pairs
* 2. Phase unwrapping (continuous phase)
* 3. Welford variance tracking per subcarrier
@@ -1050,7 +1051,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
return ESP_OK;
}
/* Start DSP task on Core 1. */
/* Pin DSP off WiFi's preferred core when SMP; else core 0 only (ESP32-C6). */
const BaseType_t dsp_core = (portNUM_PROCESSORS > 1) ? (BaseType_t)1 : (BaseType_t)0;
BaseType_t ret = xTaskCreatePinnedToCore(
edge_task,
"edge_dsp",
@@ -1058,14 +1061,14 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
NULL,
5, /* Priority 5 — above idle, below WiFi. */
NULL,
1 /* Pin to Core 1. */
);
dsp_core);
if (ret != pdPASS) {
ESP_LOGE(TAG, "Failed to create edge DSP task");
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "Edge DSP task created on Core 1 (stack=8192, priority=5)");
ESP_LOGI(TAG, "Edge DSP task created on core %d (stack=8192, priority=5)",
(int)dsp_core);
return ESP_OK;
}
@@ -8,3 +8,6 @@ dependencies:
## LCD touch abstraction
espressif/esp_lcd_touch: "^1.0"
## Onboard WS2812 LED Disabling
espressif/led_strip: "^3.0.0"
+18
View File
@@ -18,6 +18,7 @@
#include "nvs_flash.h"
#include "esp_app_desc.h"
#include "sdkconfig.h"
#include "led_strip.h"
#include "csi_collector.h"
#include "stream_sender.h"
@@ -149,6 +150,23 @@ void app_main(void)
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — v%s — Node ID: %d",
app_desc->version, g_nvs_config.node_id);
/* Turn off onboard WS2812 LED on GPIO 38 */
led_strip_handle_t led_strip;
led_strip_config_t strip_config = {
.strip_gpio_num = 38,
.max_leds = 1,
.led_model = LED_MODEL_WS2812,
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
.flags.invert_out = false,
};
led_strip_rmt_config_t rmt_config = {
.resolution_hz = 10 * 1000 * 1000, // 10MHz
.flags.with_dma = false,
};
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
led_strip_clear(led_strip);
}
/* Initialize WiFi STA (skip entirely under QEMU mock — no RF hardware) */
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
wifi_init_sta();
+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 >= 4) {
if (len >= sizeof(float)) {
/* Breathing rate as float32 (little-endian in payload). */
float br;
memcpy(&br, data, sizeof(float));
@@ -120,7 +120,7 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
break;
case MR60_TYPE_HEARTRATE:
if (len >= 4) {
if (len >= sizeof(float)) {
float hr;
memcpy(&hr, data, sizeof(float));
if (hr >= 0.0f && hr <= 250.0f) {
@@ -130,13 +130,13 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
break;
case MR60_TYPE_DISTANCE:
if (len >= 8) {
if (len >= sizeof(uint32_t) + sizeof(float)) {
/* Bytes 0-3: range flag (uint32 LE). 0 = no valid distance. */
uint32_t range_flag;
memcpy(&range_flag, data, sizeof(uint32_t));
if (range_flag != 0 && len >= 8) {
if (range_flag != 0) {
float dist;
memcpy(&dist, &data[4], sizeof(float));
memcpy(&dist, &data[sizeof(uint32_t)], sizeof(float));
s_state.distance_cm = dist;
}
}
+37 -8
View File
@@ -38,14 +38,24 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
/**
* ADR-050: Verify the Authorization header contains the correct PSK.
* Returns true if auth is disabled (no PSK provisioned) or if the
* Bearer token matches the stored PSK.
* Returns true only when a PSK is provisioned AND the Bearer token
* matches it. An unprovisioned node refuses all OTA requests
* (fail-closed, see RuView#596 audit). The OTA server still starts so
* the operator can `provision.py --ota-psk <hex>` over USB-CDC without
* a reflash, but the upload endpoint will reject every request until
* the PSK is set.
*/
static bool ota_check_auth(httpd_req_t *req)
{
if (s_ota_psk[0] == '\0') {
/* No PSK provisioned — auth disabled (permissive for dev). */
return true;
/* No PSK provisioned — fail closed. Previously this returned
* true ("permissive for dev"), which let any host on the WiFi
* push attacker-controlled firmware to a freshly-flashed node.
* Plain HTTP transport + no Secure Boot V2 + no signed-image
* verification meant a single LAN call could brick or back-
* door a node. Reject until provisioned. */
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk <hex>)");
return false;
}
char auth_header[128] = {0};
@@ -241,26 +251,45 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
return ESP_OK;
}
esp_err_t ota_update_init(void)
/**
* Load the OTA PSK from NVS into the module-local s_ota_psk cache and log
* the resulting posture. Called by both ota_update_init() and
* ota_update_init_ex() so the per-boot diagnostic prints no matter which
* entry point main.c uses — historically only ota_update_init() loaded the
* PSK, which left ota_update_init_ex() with an empty s_ota_psk and an
* invisible fail-closed posture (RuView#596 follow-up).
*/
static void ota_load_psk_from_nvs(void)
{
/* ADR-050: Load OTA PSK from NVS if provisioned. */
nvs_handle_t nvs;
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
size_t len = sizeof(s_ota_psk);
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
} else {
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until "
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
}
nvs_close(nvs);
} else {
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all "
"requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE);
}
}
esp_err_t ota_update_init(void)
{
/* ADR-050: Load OTA PSK from NVS if provisioned. */
ota_load_psk_from_nvs();
return ota_start_server(NULL);
}
esp_err_t ota_update_init_ex(void **out_server)
{
/* ADR-050: Load OTA PSK from NVS if provisioned. main.c uses this
* variant (not ota_update_init), so without this call s_ota_psk
* stayed empty forever and the fail-closed posture was invisible
* in serial logs. */
ota_load_psk_from_nvs();
return ota_start_server((httpd_handle_t *)out_server);
}
+25 -23
View File
@@ -10,7 +10,7 @@
#include <string.h>
#include "esp_log.h"
#include "mbedtls/sha256.h"
#include "psa/crypto.h"
static const char *TAG = "rvf";
@@ -125,9 +125,13 @@ esp_err_t rvf_parse(const uint8_t *data, uint32_t data_len, rvf_parsed_t *out)
/* ---- Verify build hash (SHA-256 of WASM payload) ---- */
uint8_t computed_hash[32];
int ret = mbedtls_sha256(wasm_data, hdr->wasm_len, computed_hash, 0);
if (ret != 0) {
ESP_LOGE(TAG, "SHA-256 computation failed: %d", ret);
size_t hash_len = 0;
psa_status_t psa_st = psa_hash_compute(PSA_ALG_SHA_256, wasm_data,
hdr->wasm_len, computed_hash,
sizeof(computed_hash), &hash_len);
if (psa_st != PSA_SUCCESS || hash_len != 32) {
ESP_LOGE(TAG, "SHA-256 computation failed: psa=%d len=%u",
(int)psa_st, (unsigned)hash_len);
return ESP_FAIL;
}
@@ -186,8 +190,7 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
/*
* Ed25519 verification.
*
* ESP-IDF v5.2 mbedtls does NOT include Ed25519 (Curve25519 is
* for ECDH/X25519 only). We use a SHA-256-HMAC integrity check:
* Legacy mbedtls Ed25519 is optional. We use a SHA-256 keyed digest:
*
* expected = SHA-256(pubkey || signed_region)
*
@@ -196,35 +199,34 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
* pubkey produces a different expected hash, so unauthorized
* publishers cannot forge a valid signature.
*
* For full Ed25519 (NaCl-style), enable CONFIG_MBEDTLS_EDDSA_C
* or link TweetNaCl. The RVF builder should match this scheme.
* For full Ed25519, enable CONFIG_MBEDTLS_EDDSA_C or equivalent.
* The RVF builder should match this scheme.
*/
uint8_t hash_input_prefix[32];
memcpy(hash_input_prefix, pubkey, 32);
/* Compute SHA-256(pubkey || header+manifest+wasm). */
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
int ret = mbedtls_sha256_starts(&ctx, 0);
if (ret != 0) {
mbedtls_sha256_free(&ctx);
/* Compute SHA-256(pubkey || header+manifest+wasm) via PSA Crypto. */
psa_hash_operation_t op = PSA_HASH_OPERATION_INIT;
psa_status_t st = psa_hash_setup(&op, PSA_ALG_SHA_256);
if (st != PSA_SUCCESS) {
return ESP_FAIL;
}
ret = mbedtls_sha256_update(&ctx, hash_input_prefix, 32);
if (ret != 0) {
mbedtls_sha256_free(&ctx);
st = psa_hash_update(&op, hash_input_prefix, 32);
if (st != PSA_SUCCESS) {
(void)psa_hash_abort(&op);
return ESP_FAIL;
}
ret = mbedtls_sha256_update(&ctx, data, signed_len);
if (ret != 0) {
mbedtls_sha256_free(&ctx);
st = psa_hash_update(&op, data, signed_len);
if (st != PSA_SUCCESS) {
(void)psa_hash_abort(&op);
return ESP_FAIL;
}
uint8_t expected[32];
ret = mbedtls_sha256_finish(&ctx, expected);
mbedtls_sha256_free(&ctx);
if (ret != 0) {
size_t out_len = 0;
st = psa_hash_finish(&op, expected, sizeof(expected), &out_len);
if (st != PSA_SUCCESS || out_len != 32) {
(void)psa_hash_abort(&op);
return ESP_FAIL;
}
+56 -24
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
"""
ESP32-S3 CSI Node Provisioning Script
ESP32 CSI node provisioning (ESP32-S3, ESP32-C6, other targets).
Writes WiFi credentials and aggregator target to the ESP32's NVS partition
so users can configure a pre-built firmware binary without recompiling.
Usage:
python provision.py --port COM7 --ssid "MyWiFi" --password "secret" --target-ip 192.168.1.20
python provision.py --port /dev/ttyUSB0 --chip esp32c6 --ssid "..." \\
--password "..." --target-ip 192.168.1.20
Requirements:
pip install 'esptool>=5.0' nvs-partition-gen
@@ -35,6 +37,39 @@ NVS_PARTITION_OFFSET = 0x9000
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
CONFIG_VALUE_CHECKS = [
("ssid", bool),
("password", lambda value: value is not None),
("target_ip", bool),
("target_port", lambda value: value is not None),
("node_id", lambda value: value is not None),
("tdm_slot", lambda value: value is not None),
("tdm_total", lambda value: value is not None),
("edge_tier", lambda value: value is not None),
("pres_thresh", lambda value: value is not None),
("fall_thresh", lambda value: value is not None),
("vital_win", lambda value: value is not None),
("vital_int", lambda value: value is not None),
("subk_count", lambda value: value is not None),
("channel", lambda value: value is not None),
("filter_mac", lambda value: value is not None),
("hop_channels", lambda value: value is not None),
("seed_url", lambda value: value is not None),
("seed_token", lambda value: value is not None),
("zone", lambda value: value is not None),
("swarm_hb", lambda value: value is not None),
("swarm_ingest", lambda value: value is not None),
]
def has_config_value(args):
"""Return True when args include at least one NVS-writing config value."""
return any(
check(getattr(args, name, None))
for name, check in CONFIG_VALUE_CHECKS
)
def build_nvs_csv(args):
"""Build an NVS CSV string for the csi_cfg namespace."""
buf = io.StringIO()
@@ -143,7 +178,7 @@ def generate_nvs_binary(csv_content, size):
os.unlink(p)
def flash_nvs(port, baud, nvs_bin):
def flash_nvs(port, baud, nvs_bin, chip):
"""Flash the NVS partition binary to the ESP32."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(nvs_bin)
@@ -152,16 +187,13 @@ def flash_nvs(port, baud, nvs_bin):
try:
cmd = [
sys.executable, "-m", "esptool",
"--chip", "esp32s3",
"--chip", chip,
"--port", port,
"--baud", str(baud),
# Keep underscore form — ESP-IDF v5.4 bundles esptool 4.10.0 which only
# accepts "write_flash". pip's esptool >=5.x accepts both (hyphenated
# form preferred) but keeps underscore working. Do not "correct" this.
"write_flash",
"write-flash",
hex(NVS_PARTITION_OFFSET), bin_path,
]
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port}...")
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port} (chip={chip})...")
subprocess.check_call(cmd)
print("NVS provisioning complete!")
finally:
@@ -170,10 +202,20 @@ def flash_nvs(port, baud, nvs_bin):
def main():
parser = argparse.ArgumentParser(
description="Provision ESP32-S3 CSI Node with WiFi and aggregator settings",
epilog="Example: python provision.py --port COM7 --ssid MyWiFi --password secret --target-ip 192.168.1.20",
description="Provision CSI node NVS (WiFi + aggregator); works on S3, C6, etc.",
epilog=(
"Example: python provision.py --port COM7 --ssid MyWiFi --password secret "
"--target-ip 192.168.1.20\n"
"ESP32-C6: same, or pass --chip esp32c6 if auto-detect fails "
"(default chip is auto for esptool v5+)."
),
)
parser.add_argument("--port", required=True, help="Serial port (e.g. COM7, /dev/ttyUSB0)")
parser.add_argument(
"--chip",
default="auto",
help="esptool target: auto (default), esp32s3, esp32c6, ... (must match connected chip)",
)
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
parser.add_argument("--ssid", help="WiFi SSID")
parser.add_argument("--password", help="WiFi password")
@@ -214,17 +256,7 @@ def main():
args = parser.parse_args()
has_value = any([
args.ssid, args.password is not None, args.target_ip,
args.target_port, args.node_id is not None,
args.tdm_slot is not None, args.tdm_total is not None,
args.edge_tier is not None, args.pres_thresh is not None,
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
args.seed_url is not None, args.zone is not None,
])
if not has_value:
if not has_config_value(args):
parser.error("At least one config value must be specified")
# Bug 2 (#391): Prevent silent wipe of WiFi credentials on partial invocations.
@@ -281,7 +313,7 @@ def main():
if args.ssid:
print(f" WiFi SSID: {args.ssid}")
if args.password is not None:
print(f" WiFi Password: {'*' * len(args.password)}")
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
if args.target_ip:
print(f" Target IP: {args.target_ip}")
if args.target_port:
@@ -337,11 +369,11 @@ def main():
with open(out, "wb") as f:
f.write(nvs_bin)
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
print(f"Flash manually: python -m esptool --chip esp32s3 --port {args.port} "
print(f"Flash manually: python -m esptool --chip {args.chip} --port {args.port} "
f"write-flash 0x9000 {out}")
return
flash_nvs(args.port, args.baud, nvs_bin)
flash_nvs(args.port, args.baud, nvs_bin, args.chip)
if __name__ == "__main__":
@@ -34,3 +34,11 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# Extra WiFi IRAM placement (defense-in-depth for RuView#396 SPI cache race)
CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y
# ADR-081: adaptive_controller runs emit_feature_state + stream_sender
# network I/O inside Timer Svc callbacks, exceeding the 2 KiB default.
# Without this, the device bootloops with
# "***ERROR*** A stack overflow in task Tmr Svc has been detected."
# Was present in sdkconfig.defaults.template but missing here — fixed
# in the v0.6.5-esp32 release.
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
@@ -153,6 +153,13 @@ typedef struct {
uint8_t primary;
} wifi_ap_record_t;
typedef enum {
WIFI_PS_NONE = 0,
WIFI_PS_MIN_MODEM = 1,
WIFI_PS_MAX_MODEM = 2,
} wifi_ps_type_t;
static inline esp_err_t esp_wifi_set_ps(wifi_ps_type_t type) { (void)type; return ESP_OK; }
static inline esp_err_t esp_wifi_set_promiscuous(bool en) { (void)en; return ESP_OK; }
static inline esp_err_t esp_wifi_set_promiscuous_rx_cb(void *cb) { (void)cb; return ESP_OK; }
static inline esp_err_t esp_wifi_set_promiscuous_filter(wifi_promiscuous_filter_t *f) { (void)f; return ESP_OK; }
@@ -0,0 +1,63 @@
import csv
import importlib.util
import io
import types
import unittest
from pathlib import Path
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
def make_args(**overrides):
values = {name: None for name, _ in provision.CONFIG_VALUE_CHECKS}
values["hop_dwell"] = 200
values.update(overrides)
return types.SimpleNamespace(**values)
def csv_rows(content):
return list(csv.DictReader(io.StringIO(content)))
class ProvisionConfigValueTests(unittest.TestCase):
def test_swarm_and_hopping_flags_count_as_config_values(self):
cases = [
{"hop_channels": "1,6,11"},
{"seed_token": "token-123"},
{"swarm_hb": 15},
{"swarm_ingest": 3},
]
for values in cases:
with self.subTest(values=values):
self.assertTrue(provision.has_config_value(make_args(**values)))
def test_operational_flags_alone_do_not_count_as_config_values(self):
self.assertFalse(provision.has_config_value(make_args()))
def test_swarm_and_hopping_values_are_written_to_csv(self):
args = make_args(
hop_channels="1,6,11",
hop_dwell=250,
seed_token="token-123",
swarm_hb=15,
swarm_ingest=3,
)
rows = csv_rows(provision.build_nvs_csv(args))
values_by_key = {row["key"]: row["value"] for row in rows}
self.assertEqual(values_by_key["hop_count"], "3")
self.assertEqual(values_by_key["chan_list"], "01060b")
self.assertEqual(values_by_key["dwell_ms"], "250")
self.assertEqual(values_by_key["seed_token"], "token-123")
self.assertEqual(values_by_key["swarm_hb"], "15")
self.assertEqual(values_by_key["swarm_ingest"], "3")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1 +1 @@
0.6.4
0.6.5
+1 -1
View File
@@ -1,4 +1,4 @@
# ESP32-S3 Hello World — Capability Discovery
# ESP32 Hello World — Capability Discovery (S3 / C6 targets)
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+93 -22
View File
@@ -1,11 +1,11 @@
/**
* @file main.c
* @brief ESP32-S3 Hello World — Full Capability Discovery
* @brief ESP32 Hello World — Full Capability Discovery
*
* Boots up, prints "Hello World!", then probes and reports every major
* hardware/software capability of the ESP32-S3: chip info, flash, PSRAM,
* WiFi (including CSI), Bluetooth, GPIOs, peripherals, FreeRTOS stats,
* and power management features. No WiFi connection required.
* Boots up, prints "Hello World!", then probes chip info, flash, PSRAM,
* WiFi (including CSI where enabled), 802.15.4/BLE on C6, GPIOs,
* peripherals, FreeRTOS stats, and power management. No WiFi connection
* required. Supports ESP32-S3 and ESP32-C6 (set IDF target accordingly).
*/
#include <stdio.h>
@@ -18,7 +18,6 @@
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_mac.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_timer.h"
@@ -33,7 +32,24 @@
#include "driver/temperature_sensor.h"
#include "sdkconfig.h"
static const char *TAG = "hello";
/*
* Peripheral counts: ESP-IDF v6+ dropped some SOC_* macros; values below
* match each target's HAL (esp_hal_* *_ll.h) where applicable.
*/
#if CONFIG_IDF_TARGET_ESP32S3
#define PROBE_I2S_CTRL_NUM 2
#define PROBE_RMT_CHAN_NUM 8
#define PROBE_MCPWM_GROUPS 2
#define PROBE_PCNT_UNITS 4
#define PROBE_TOUCH_CHAN_NUM ((int)(SOC_TOUCH_MAX_CHAN_ID - SOC_TOUCH_MIN_CHAN_ID + 1))
#elif CONFIG_IDF_TARGET_ESP32C6
#define PROBE_I2S_CTRL_NUM 1
#define PROBE_RMT_CHAN_NUM 4
#define PROBE_MCPWM_GROUPS 1
#define PROBE_PCNT_UNITS 4
#else
#error "hello-world: add PROBE_* peripheral counts for this IDF target in main.c"
#endif
/* ── Helpers ─────────────────────────────────────────────────────────── */
@@ -46,6 +62,7 @@ static const char *chip_model_str(esp_chip_model_t model)
case CHIP_ESP32C3: return "ESP32-C3";
case CHIP_ESP32H2: return "ESP32-H2";
case CHIP_ESP32C2: return "ESP32-C2";
case CHIP_ESP32C6: return "ESP32-C6";
default: return "Unknown";
}
}
@@ -168,7 +185,11 @@ static void probe_wifi_capabilities(void)
ESP_ERROR_CHECK(esp_wifi_start());
/* Protocol capabilities */
#if CONFIG_IDF_TARGET_ESP32C6
printf(" Protocols: 802.11 b/g/n/ax (Wi-Fi 6, 2.4 GHz)\n");
#else
printf(" Protocols: 802.11 b/g/n\n");
#endif
/* CSI (Channel State Information) */
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
@@ -246,7 +267,7 @@ static void probe_bluetooth(void)
esp_chip_info(&info);
if (info.features & CHIP_FEATURE_BLE) {
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
printf(" BLE: Supported (Bluetooth LE)\n");
printf(" - GATT Server/Client\n");
printf(" - Advertising & Scanning\n");
printf(" - Mesh Networking\n");
@@ -256,10 +277,16 @@ static void probe_bluetooth(void)
printf(" BLE: Not supported on this chip\n");
}
#if CONFIG_IDF_TARGET_ESP32C6
if (info.features & CHIP_FEATURE_IEEE802154) {
printf(" 802.15.4: Supported (Thread / Zigbee style MAC)\n");
}
#endif
if (info.features & CHIP_FEATURE_BT) {
printf(" BT Classic: Supported (A2DP, SPP, HFP)\n");
} else {
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
printf(" BT Classic: Not available (BLE-only on this chip)\n");
}
}
@@ -269,24 +296,52 @@ static void probe_peripherals(void)
printf(" GPIOs: %d total\n", SOC_GPIO_PIN_COUNT);
printf(" ADC:\n");
#if CONFIG_IDF_TARGET_ESP32C6
printf(" - SAR ADC: %d channels (12-bit, one controller)\n",
(int)SOC_ADC_CHANNEL_NUM(0));
#else
printf(" - ADC1: %d channels (12-bit SAR)\n", SOC_ADC_CHANNEL_NUM(0));
printf(" - ADC2: %d channels (shared with WiFi)\n", SOC_ADC_CHANNEL_NUM(1));
printf(" DAC: Not available on ESP32-S3\n");
printf(" Touch Sensors: %d channels (capacitive)\n", SOC_TOUCH_SENSOR_NUM);
printf(" SPI: %d controllers (SPI2/SPI3 for user)\n", SOC_SPI_PERIPH_NUM);
printf(" I2C: %d controllers\n", SOC_I2C_NUM);
printf(" I2S: %d controllers (audio/PDM/TDM)\n", SOC_I2S_NUM);
printf(" UART: %d controllers\n", SOC_UART_NUM);
#endif
printf(" DAC: Not available on this chip\n");
#if CONFIG_IDF_TARGET_ESP32S3
printf(" Touch Sensors: %d channels (capacitive)\n", PROBE_TOUCH_CHAN_NUM);
#elif CONFIG_IDF_TARGET_ESP32C6
printf(" Touch Sensors: Not available (no capacitive touch on ESP32-C6)\n");
#endif
printf(" SPI: %d controllers\n", SOC_SPI_PERIPH_NUM);
#if CONFIG_IDF_TARGET_ESP32S3
printf(" (SPI2/SPI3 typical for user apps)\n");
#endif
printf(" I2C: %d controllers\n", (int)SOC_I2C_NUM);
printf(" I2S: %d controller(s) (audio/PDM/TDM)\n", PROBE_I2S_CTRL_NUM);
printf(" UART: %d controllers\n", (int)SOC_UART_NUM);
#if CONFIG_IDF_TARGET_ESP32S3
printf(" USB: USB-OTG 1.1 (Host & Device)\n");
printf(" USB-Serial: Built-in USB-JTAG/Serial (this console)\n");
#elif CONFIG_IDF_TARGET_ESP32C6
printf(" USB: No native USB-OTG (use SPI/USB bridge or off-chip PHY)\n");
printf(" USB-Serial: Built-in USB Serial/JTAG (this console)\n");
#endif
#if CONFIG_IDF_TARGET_ESP32S3
printf(" TWAI (CAN): 1 controller (CAN 2.0B compatible)\n");
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
#elif CONFIG_IDF_TARGET_ESP32C6
printf(" TWAI (CAN): %d controller(s) (CAN 2.0B compatible)\n",
(int)SOC_TWAI_CONTROLLER_NUM);
#endif
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", PROBE_RMT_CHAN_NUM);
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
printf(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
printf(" MCPWM: %d group(s) (motor control)\n", PROBE_MCPWM_GROUPS);
printf(" PCNT: %d units (pulse counter / encoder)\n", PROBE_PCNT_UNITS);
#if CONFIG_IDF_TARGET_ESP32S3
printf(" LCD: Parallel 8/16-bit + SPI + I2C interfaces\n");
printf(" Camera: DVP 8/16-bit parallel interface\n");
printf(" SDMMC: SD/MMC host controller (1-bit / 4-bit)\n");
#elif CONFIG_IDF_TARGET_ESP32C6
printf(" PARLIO: Parallel TX/RX (e.g. LED matrix / custom buses)\n");
printf(" Camera: SPI / external bridge (no native DVP)\n");
printf(" SDIO: SDIO slave peripheral (see TRM for capabilities)\n");
#endif
}
static void probe_security(void)
@@ -309,17 +364,29 @@ static void probe_power(void)
{
print_separator("POWER MANAGEMENT");
#if CONFIG_IDF_TARGET_ESP32C6
printf(" Clock Modes:\n");
printf(" - 160 MHz (max CPU on ESP32-C6)\n");
printf(" - 120 MHz (balanced)\n");
printf(" - 80 MHz (low power)\n");
#else
printf(" Clock Modes:\n");
printf(" - 240 MHz (max performance)\n");
printf(" - 160 MHz (balanced)\n");
printf(" - 80 MHz (low power)\n");
#endif
printf(" Sleep Modes:\n");
printf(" - Modem Sleep (WiFi off, CPU active)\n");
printf(" - Light Sleep (CPU paused, fast wake)\n");
printf(" - Deep Sleep (RTC only, ~10 uA)\n");
printf(" - Hibernation (RTC timer only, ~5 uA)\n");
#if CONFIG_IDF_TARGET_ESP32C6
printf(" Wake Sources: GPIO, LP timer, UART, etc.\n");
printf(" LP domain: LP core / LP peripherals (see TRM)\n");
#else
printf(" Wake Sources: GPIO, timer, touch, ULP, UART\n");
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
printf(" ULP Coprocessor: FSM (runs in deep sleep)\n");
#endif
}
static void probe_temperature(void)
@@ -389,6 +456,9 @@ static void probe_csi_details(void)
void app_main(void)
{
esp_chip_info_t chip;
esp_chip_info(&chip);
/* NVS required for WiFi */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
@@ -401,7 +471,7 @@ void app_main(void)
printf("\n");
printf(" ╭─────────────────────────────────────────────────╮\n");
printf(" │ │\n");
printf(" │ HELLO WORLD from ESP32-S3! \n");
printf(" │ HELLO WORLD from %-24s\n", chip_model_str(chip.model));
printf(" │ │\n");
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
printf(" │ │\n");
@@ -422,8 +492,9 @@ void app_main(void)
probe_csi_details();
print_separator("DONE — ALL CAPABILITIES REPORTED");
printf("\n This ESP32-S3 is ready for WiFi-DensePose!\n");
printf(" Flash the full firmware (esp32-csi-node) to begin CSI sensing.\n\n");
printf("\n This %s is ready for WiFi-DensePose experiments.\n",
chip_model_str(chip.model));
printf(" For production CSI on S3, flash esp32-csi-node; C6 path may differ.\n\n");
/* Keep alive — blink a status message every 10 seconds */
int tick = 0;
@@ -1,5 +1,5 @@
# ESP32-S3 Hello World — SDK Configuration
CONFIG_IDF_TARGET="esp32s3"
# ESP32 Hello World — SDK Configuration (default: ESP32-C6)
CONFIG_IDF_TARGET="esp32c6"
# Flash: 4MB (this chip has Embedded Flash 4MB)
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
+1 -1
View File
@@ -5,7 +5,7 @@
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-mock>=3.10.0
pytest-benchmark>=4.0.0
pytest-benchmark>=5.2.3
# Linting and formatting
black>=23.0.0
+4 -4
View File
@@ -7,7 +7,7 @@ torchvision>=0.13.0
# API dependencies
fastapi>=0.95.0
uvicorn>=0.20.0
websockets>=10.4
websockets>=15.0.1
pydantic>=1.10.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
@@ -18,7 +18,7 @@ pydantic-settings>=2.0.0
# Database dependencies
sqlalchemy>=2.0.0
asyncpg>=0.28.0
aiosqlite>=0.19.0
aiosqlite>=0.22.1
redis>=4.5.0
# CLI dependencies
@@ -26,8 +26,8 @@ click>=8.0.0
alembic>=1.10.0
# Hardware interface dependencies
asyncio-mqtt>=0.11.0
aiohttp>=3.8.0
asyncio-mqtt>=0.16.2
aiohttp>=3.13.5
paramiko>=3.0.0
# Data processing dependencies
+103
View File
@@ -110,6 +110,109 @@
"require": ["VERIFY.sh", "witness-bundle"],
"rationale": "scripts/generate-witness-bundle.sh produces the self-contained, recipient-verifiable witness bundle (witness log + proof + test results + firmware hashes + VERIFY.sh). Part of the ADR-028 attestation chain.",
"ref": "docs/WITNESS-LOG-028.md"
},
{
"id": "RuView#559",
"title": "./verify wrapper points at archive/v1/ paths (post-v1-archive layout)",
"files": ["verify"],
"require": ["${SCRIPT_DIR}/archive/v1/data/proof", "${SCRIPT_DIR}/archive/v1/src"],
"rationale": "After v1 moved to archive/v1, the ./verify wrapper still pointed at the removed v1/ paths and failed before reaching verify.py on a fresh clone. Reverting to the un-prefixed paths reintroduces the FAIL-before-pipeline regression that #559 reported.",
"ref": "https://github.com/ruvnet/RuView/issues/559"
},
{
"id": "RuView#561",
"title": "ESP32 CSI firmware README documents the correct flash offsets (app at 0x20000, ota_data at 0xf000)",
"files": ["firmware/esp32-csi-node/README.md"],
"require": [
"0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin",
"0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin",
"firmware/esp32-csi-node/provision.py"
],
"forbid": [
"/0x10000 firmware\\/esp32-csi-node\\/build\\/esp32-csi-node\\.bin/",
"/python scripts\\/provision\\.py/"
],
"rationale": "Partition tables (partitions_display.csv, partitions_4mb.csv) put ota_0 at 0x20000. The README previously said 0x10000 and pointed at scripts/provision.py (an older copy). Reverting causes first-time users to misflash and miss WiFi provisioning.",
"ref": "https://github.com/ruvnet/RuView/issues/561"
},
{
"id": "RuView#588-SEC020",
"title": "provision.py prints a fixed (set)/(empty) marker, not a length-leaking asterisk run",
"files": ["scripts/provision.py", "firmware/esp32-csi-node/provision.py"],
"require": ["(set)' if args.password else '(empty)"],
"forbid": ["/'\\*' \\* len\\(args\\.password\\)/"],
"rationale": "Both provision.py scripts previously printed '*' * len(args.password), masking the value but leaking the password length. Flagged as SEC020 by Repobility. Fix replaces with a fixed (set)/(empty) marker.",
"ref": "https://github.com/ruvnet/RuView/issues/588"
},
{
"id": "RuView#593",
"title": "vital_signs.rs uses circular variance for wrapped atan2 phase values",
"files": ["v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs"],
"require": [
"phase_circular_variance",
"standard circular variance (1 - mean resultant length)",
"test_phase_variance_handles_wraparound"
],
"rationale": "Phases come from atan2 and are wrapped to (-pi, pi]. The original linear mean/variance treated two phases straddling +/-pi (physically ~0 rad apart) as ~2*pi apart, producing variance ~pi^2 instead of ~1e-6 and feeding that noise straight into the heart-rate FFT buffer. Caused jumpy vitals in #519 and +/-15 BPM jitter in #485.",
"ref": "https://github.com/ruvnet/RuView/issues/593"
},
{
"id": "RuView#590-fuzz-stub",
"title": "Fuzz host stubs declare WIFI_PS_NONE / wifi_ps_type_t / esp_wifi_set_ps()",
"files": ["firmware/esp32-csi-node/test/stubs/esp_stubs.h"],
"require": ["wifi_ps_type_t", "WIFI_PS_NONE", "esp_wifi_set_ps"],
"rationale": "csi_collector.c:346 calls esp_wifi_set_ps(WIFI_PS_NONE) per the RuView#521 fix. The host-native fuzz target compiles csi_collector.c against test/stubs/esp_stubs.h; missing these symbols red-greens the Fuzz Testing (ADR-061 Layer 6) job. Was red on main for ~5 weeks before PR #590.",
"ref": "https://github.com/ruvnet/RuView/pull/590"
},
{
"id": "RuView#590-swarm-test",
"title": "QEMU swarm test passes --force-partial to provision.py for per-node overlays",
"files": ["scripts/qemu_swarm.py"],
"require": ["--force-partial"],
"rationale": "The per-node TDM/channel overlay intentionally omits WiFi creds (those live in the base flash image). Without --force-partial the issue #391 wifi-trio guard in provision.py rejects the call and breaks the Swarm Test (ADR-062) job. Was red on main for ~5 weeks before PR #590.",
"ref": "https://github.com/ruvnet/RuView/pull/590"
},
{
"id": "RuView#615",
"title": "path_safety::safe_id gates user-controlled IDs at filesystem boundaries",
"files": [
"v2/crates/wifi-densepose-sensing-server/src/path_safety.rs",
"v2/crates/wifi-densepose-sensing-server/src/recording.rs",
"v2/crates/wifi-densepose-sensing-server/src/model_manager.rs",
"v2/crates/wifi-densepose-sensing-server/src/training_api.rs"
],
"require": [
"path_safety::safe_id",
"pub fn safe_id"
],
"rationale": "Five endpoints used to embed user-controlled identifiers (session_name, model_id, dataset_id, recording id) into format!() paths with no sanitization, allowing classic '../../etc/passwd' reads, writes, and deletes on the server filesystem. The safe_id helper enforces [A-Za-z0-9._-] only (no leading '.', max 64 chars) and must run before any user input reaches a format!() that builds a path. Removing the helper or skipping it at any of these call sites reintroduces the #615 attack surface.",
"ref": "https://github.com/ruvnet/RuView/issues/615"
},
{
"id": "RuView#596-ota-fail-closed",
"title": "ESP32 OTA upload fails closed when no PSK is provisioned",
"files": ["firmware/esp32-csi-node/main/ota_update.c"],
"require": [
"fail-closed, see RuView#596 audit",
"OTA rejected: no PSK in NVS"
],
"forbid": [
"/auth disabled \\(permissive for dev\\)/",
"/No PSK provisioned \\u2014 auth disabled/"
],
"rationale": "ota_check_auth previously returned true when s_ota_psk[0] == '\\0', so any host on the WiFi could push attacker-controlled firmware to a freshly-flashed node over plain HTTP on port 8032 — no Secure Boot V2, no signed-image verification, single LAN call could brick or backdoor a node. Flagged in the deep-review of PR #596. Fail-closed means the OTA server still starts (so operators can provision a PSK via USB-CDC without reflashing) but the upload endpoint refuses every request until provision.py --ota-psk <hex> writes the NVS key. Reverting this lets the rogue-LAN attack reopen.",
"ref": "https://github.com/ruvnet/RuView/pull/596#pullrequestreview"
},
{
"id": "RuView#560",
"title": "verify.py quantizes features before SHA-256 for cross-platform hash stability",
"files": ["archive/v1/data/proof/verify.py"],
"require": [
"HASH_QUANTIZATION_DECIMALS",
"np.round(flat, HASH_QUANTIZATION_DECIMALS)"
],
"rationale": "Without quantization, the SHA-256 of features_to_bytes() diverges across SIMD backends (Intel AVX2/AVX-512 vs Apple Silicon NEON) because scipy.fft's pocketfft kernels reorder vectorized FP operations differently per build. IEEE 754 guarantees per-operation determinism, not associativity. Rounding to 9 decimal places (~5 orders of magnitude headroom over observed ULP drift) collapses the cross-platform divergence to a single canonical hash. Removing the round() call reintroduces the macOS arm64 vs Linux x86_64 hash mismatch in issue #560.",
"ref": "https://github.com/ruvnet/RuView/issues/560"
}
]
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Platform probe: reproduce verify.py's hash-relevant FFT steps in isolation.
Runs the same scipy.fft.fft / scipy.signal calls that verify.py hashes
(csi_processor.py:426, :438, :349) on a deterministic synthetic input,
without dragging in src.app / pydantic Settings. Used to empirically
locate the source of platform divergence in issue #560 — and now also to
verify the quantize-before-hash fix shipped in archive/v1/data/proof/verify.py.
Usage: python3 scripts/probe-fft-platform.py
Output: single JSON object on stdout. Run on each platform and diff.
The output now contains TWO hashes:
- `sha256_raw` — hash of unrounded little-endian f64 bytes (legacy)
- `sha256_quantized` — hash after np.round(.., 9) (matches verify.py
behaviour after the issue-#560 fix; should be
IDENTICAL across Intel AVX, ARM NEON, and any
scipy pocketfft build)
If `sha256_raw` differs across machines but `sha256_quantized` matches,
the quantize-before-hash fix is doing its job.
"""
import hashlib
import json
import platform
import struct
import sys
import numpy as np
import scipy.fft
import scipy.signal
# Deterministic synthetic input -- no IO, no .env, no Settings
rng = np.random.RandomState(42)
N_FRAMES = 100
N_SUBC = 100
amp = rng.randn(N_FRAMES, N_SUBC).astype(np.float64)
# Mirror the three scipy calls verify.py's hash depends on:
# archive/v1/src/core/csi_processor.py:349 -> scipy.signal.windows.hamming
# archive/v1/src/core/csi_processor.py:426 -> scipy.fft.fft(mean_phase_diff, n=64)
# archive/v1/src/core/csi_processor.py:438 -> scipy.fft.fft(amp.flatten(), n=128)
mean_phase_diff = amp.mean(axis=1)
doppler = np.abs(scipy.fft.fft(mean_phase_diff, n=64)) ** 2
psd = np.abs(scipy.fft.fft(amp.flatten(), n=128)) ** 2
window = scipy.signal.windows.hamming(56)
# Quantization decimals — kept in sync with
# archive/v1/data/proof/verify.py:HASH_QUANTIZATION_DECIMALS so this probe
# verifies the production hash, not just the FFT outputs.
HASH_QUANTIZATION_DECIMALS = 6
def pack_floats(arrays, quantize):
"""Pack arrays as little-endian f64, optionally rounding first."""
parts = []
for arr in arrays:
flat = np.asarray(arr, dtype=np.float64).ravel()
if quantize:
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
parts.append(struct.pack(f"<{len(flat)}d", *flat))
return b"".join(parts)
arrays = (doppler, psd, window)
blob_raw = pack_floats(arrays, quantize=False)
blob_quantized = pack_floats(arrays, quantize=True)
try:
blas_info = np.show_config(mode="dicts")
except Exception:
blas_info = {"error": "show_config(mode=dicts) unavailable"}
print(json.dumps({
"uname": platform.uname()._asdict(),
"python": sys.version.split()[0],
"numpy": np.__version__,
"scipy": __import__("scipy").__version__,
"blob_len": len(blob_raw),
"sha256_raw": hashlib.sha256(blob_raw).hexdigest(),
"sha256_quantized": hashlib.sha256(blob_quantized).hexdigest(),
"quantization_decimals": HASH_QUANTIZATION_DECIMALS,
"first8_doppler_bytes_hex": doppler[:8].tobytes().hex(),
"first4_psd_floats": psd[:4].tolist(),
"blas_backend": blas_info if isinstance(blas_info, dict) else str(blas_info),
}, indent=2, default=str))
+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: {'*' * len(args.password)}")
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
if args.target_ip:
print(f" Target IP: {args.target_ip}")
if args.target_port:
+6 -1
View File
@@ -259,11 +259,16 @@ def provision_node(
if stale.exists():
stale.unlink()
# Build provision.py arguments
# Build provision.py arguments.
# --force-partial: this is a per-node TDM/channel overlay; WiFi
# credentials live in the base flash image, not the per-node NVS slice.
# Without --force-partial, provision.py rejects calls missing the
# --ssid/--password/--target-ip trio (issue #391 guard).
args = [
sys.executable, str(PROVISION_SCRIPT),
"--port", "/dev/null",
"--dry-run",
"--force-partial",
"--node-id", str(node.node_id),
"--tdm-slot", str(node.tdm_slot),
"--tdm-total", str(n_total),
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
UDP relay for Docker Desktop on Windows (issue #374, #386).
Docker Desktop on Windows multiplexes inbound UDP from multiple source IPs to
a single source IP inside the container, which causes packets from all but one
ESP32 node to be silently dropped at the WSL/Hyper-V boundary.
This relay listens on the host, then re-emits each datagram from its own
single socket back to a localhost port that Docker forwards into the
container. Because every forwarded datagram now has the same source IP/port
(the relay's loopback socket), Docker passes them all through.
Usage:
# Default: listen on host:5005, forward to 127.0.0.1:5006
# Container should be started with -p 5006:5005/udp.
python scripts/udp-relay.py
# Custom ports
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
# Verbose (one line per packet)
python scripts/udp-relay.py --verbose
"""
import argparse
import socket
import sys
import time
def run_relay(listen_host: str, listen_port: int, forward_host: str,
forward_port: int, stats_interval: float, verbose: bool) -> int:
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
rx.bind((listen_host, listen_port))
except OSError as e:
print(f"udp-relay: failed to bind {listen_host}:{listen_port}: {e}",
file=sys.stderr)
return 1
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
forward_addr = (forward_host, forward_port)
print(f"udp-relay: listening on {listen_host}:{listen_port} "
f"-> forwarding to {forward_host}:{forward_port}")
print("udp-relay: collapses multi-source UDP to a single loopback source "
"so Docker Desktop on Windows forwards every packet (issue #374).")
sources: dict[tuple[str, int], int] = {}
total = 0
last_stats = time.monotonic()
try:
while True:
data, src = rx.recvfrom(65535)
tx.sendto(data, forward_addr)
total += 1
sources[src] = sources.get(src, 0) + 1
if verbose:
print(f"udp-relay: {src[0]}:{src[1]} -> "
f"{forward_host}:{forward_port} ({len(data)}B)")
now = time.monotonic()
if now - last_stats >= stats_interval:
print(f"udp-relay: forwarded {total} pkts from "
f"{len(sources)} sources in last {stats_interval:.0f}s")
sources.clear()
total = 0
last_stats = now
except KeyboardInterrupt:
print("udp-relay: stopping")
return 0
finally:
rx.close()
tx.close()
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--listen-host", default="0.0.0.0",
help="Host interface to bind (default: 0.0.0.0)")
p.add_argument("--listen-port", type=int, default=5005,
help="Port the ESP32 nodes send to (default: 5005)")
p.add_argument("--forward-host", default="127.0.0.1",
help="Where to forward packets (default: 127.0.0.1)")
p.add_argument("--forward-port", type=int, default=5006,
help="Port Docker maps into the container (default: 5006)")
p.add_argument("--stats-interval", type=float, default=10.0,
help="Seconds between stats lines (default: 10)")
p.add_argument("--verbose", action="store_true",
help="Log every forwarded packet")
args = p.parse_args()
return run_relay(args.listen_host, args.listen_port, args.forward_host,
args.forward_port, args.stats_interval, args.verbose)
if __name__ == "__main__":
sys.exit(main())
+33
View File
@@ -0,0 +1,33 @@
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-undef": "error",
"no-var": "error",
"prefer-const": "warn",
"eqeqeq": ["error", "always"],
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-alert": "warn",
"no-console": ["warn", { "allow": ["warn", "error", "info"] }],
"curly": ["warn", "multi-line"],
"no-throw-literal": "error",
"prefer-template": "warn",
"no-duplicate-imports": "error"
},
"ignorePatterns": [
"node_modules/",
"mobile/",
"vendor/",
"*.min.js"
]
}
+163 -38
View File
@@ -10,6 +10,24 @@ import { wsService } from './services/websocket.service.js';
import { healthService } from './services/health.service.js';
import { sensingService } from './services/sensing.service.js';
import { backendDetector } from './utils/backend-detector.js';
import { KeyboardShortcuts } from './utils/keyboard-shortcuts.js';
import { PerfMonitor } from './utils/perf-monitor.js';
import { toastManager } from './utils/toast.js';
import { ThemeToggle } from './utils/theme-toggle.js';
import { CommandPalette } from './utils/command-palette.js';
import { ActivityLog } from './utils/activity-log.js';
import { DataExport } from './utils/data-export.js';
import { FullscreenManager } from './utils/fullscreen.js';
import { ConnectionStatus } from './utils/connection-status.js';
import { MobileNav } from './utils/mobile-nav.js';
import { Router } from './utils/router.js';
import { Onboarding } from './utils/onboarding.js';
import { IdleManager } from './utils/idle-manager.js';
import { NotificationCenter } from './utils/notification-center.js';
import { i18n } from './utils/i18n.js';
import { ScreenshotTool } from './utils/screenshot.js';
import { UptimeClock } from './utils/uptime-clock.js';
import { QuickSettings } from './utils/quick-settings.js';
class WiFiDensePoseApp {
constructor() {
@@ -30,10 +48,13 @@ class WiFiDensePoseApp {
// Initialize UI components
this.initializeComponents();
// Initialize enhancements
this.initializeEnhancements();
// Set up global event listeners
this.setupEventListeners();
this.isInitialized = true;
console.log('WiFi DensePose UI initialized successfully');
@@ -167,6 +188,118 @@ class WiFiDensePoseApp {
}
}
// Initialize enhancement modules
initializeEnhancements() {
// Toast notifications
toastManager.init();
// Connection status widget in header
this.connectionStatus = new ConnectionStatus();
this.connectionStatus.init();
// Theme toggle
this.themeToggle = new ThemeToggle();
this.themeToggle.init();
// Performance monitor
this.perfMonitor = new PerfMonitor();
this.perfMonitor.init();
// Activity log
this.activityLog = new ActivityLog();
this.activityLog.init();
// Data export
this.dataExport = new DataExport();
this.dataExport.init();
// Fullscreen manager
this.fullscreenManager = new FullscreenManager();
this.fullscreenManager.init();
// Command palette (Ctrl+K)
this.commandPalette = new CommandPalette(this);
this.commandPalette.init();
// Mobile navigation (hamburger menu for small screens)
this.mobileNav = new MobileNav();
this.mobileNav.init();
// Notification center (bell icon in header)
this.notificationCenter = new NotificationCenter();
this.notificationCenter.init();
// Screenshot tool
this.screenshotTool = new ScreenshotTool();
this.screenshotTool.init();
// Uptime clock
this.uptimeClock = new UptimeClock();
this.uptimeClock.init();
// Quick settings panel
this.quickSettings = new QuickSettings(this);
this.quickSettings.init();
// Internationalization (EN/PL)
i18n.init();
// Keyboard shortcuts (pass app reference for tab switching)
this.keyboardShortcuts = new KeyboardShortcuts(this);
this.keyboardShortcuts.register('l', 'Toggle activity log', () => {
document.dispatchEvent(new CustomEvent('toggle-activity-log'));
});
this.keyboardShortcuts.register('e', 'Export sensor data', () => {
document.dispatchEvent(new CustomEvent('export-data'));
});
this.keyboardShortcuts.register('f', 'Toggle fullscreen', () => {
document.dispatchEvent(new CustomEvent('toggle-fullscreen'));
});
this.keyboardShortcuts.register('s', 'Take screenshot', () => {
document.dispatchEvent(new CustomEvent('take-screenshot'));
});
this.keyboardShortcuts.init();
// Listen for show-shortcuts from command palette
document.addEventListener('show-shortcuts', () => {
this.keyboardShortcuts.showHelp();
});
// Register PWA service worker
this.registerServiceWorker();
// URL hash router (bookmarkable tabs)
this.router = new Router(this);
this.router.init();
// Idle detection (pause updates when inactive)
this.idleManager = new IdleManager();
this.idleManager.onIdle(() => {
healthService.stopHealthMonitoring();
console.info('[App] Paused health monitoring (idle)');
});
this.idleManager.onActive(() => {
healthService.startHealthMonitoring();
console.info('[App] Resumed health monitoring (active)');
});
this.idleManager.init();
// Onboarding tour (first-run walkthrough)
this.onboarding = new Onboarding(this);
this.onboarding.init();
}
// Register service worker for offline capability
registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').then(reg => {
console.info('Service worker registered:', reg.scope);
}).catch(err => {
console.warn('Service worker registration failed:', err);
});
}
}
// Handle tab changes
handleTabChange(newTab, oldTab) {
console.log(`Tab changed from ${oldTab} to ${newTab}`);
@@ -272,45 +405,17 @@ class WiFiDensePoseApp {
});
}
// Show backend status notification
// Show backend status notification (uses enhanced toast system)
showBackendStatus(message, type) {
// Create status notification if it doesn't exist
let statusToast = document.getElementById('backendStatusToast');
if (!statusToast) {
statusToast = document.createElement('div');
statusToast.id = 'backendStatusToast';
statusToast.className = 'backend-status-toast';
document.body.appendChild(statusToast);
}
statusToast.textContent = message;
statusToast.className = `backend-status-toast ${type}`;
statusToast.classList.add('show');
// Auto-hide success messages, keep warnings and errors longer
const timeout = type === 'success' ? 3000 : 8000;
setTimeout(() => {
statusToast.classList.remove('show');
}, timeout);
const toastType = type === 'success' ? 'success' : 'warning';
toastManager[toastType](message, {
duration: type === 'success' ? 3000 : 8000
});
}
// Show global error message
// Show global error message (uses enhanced toast system)
showGlobalError(message) {
// Create error toast if it doesn't exist
let errorToast = document.getElementById('globalErrorToast');
if (!errorToast) {
errorToast = document.createElement('div');
errorToast.id = 'globalErrorToast';
errorToast.className = 'error-toast';
document.body.appendChild(errorToast);
}
errorToast.textContent = message;
errorToast.classList.add('show');
setTimeout(() => {
errorToast.classList.remove('show');
}, 5000);
toastManager.error(message, { duration: 6000 });
}
// Clean up resources
@@ -326,9 +431,29 @@ class WiFiDensePoseApp {
// Disconnect all WebSocket connections
wsService.disconnectAll();
// Stop health monitoring
healthService.dispose();
// Dispose enhancements
if (this.keyboardShortcuts) this.keyboardShortcuts.dispose();
if (this.perfMonitor) this.perfMonitor.dispose();
if (this.themeToggle) this.themeToggle.dispose();
if (this.commandPalette) this.commandPalette.dispose();
if (this.activityLog) this.activityLog.dispose();
if (this.dataExport) this.dataExport.dispose();
if (this.fullscreenManager) this.fullscreenManager.dispose();
if (this.connectionStatus) this.connectionStatus.dispose();
if (this.mobileNav) this.mobileNav.dispose();
if (this.router) this.router.dispose();
if (this.onboarding) this.onboarding.dispose();
if (this.idleManager) this.idleManager.dispose();
if (this.notificationCenter) this.notificationCenter.dispose();
if (this.screenshotTool) this.screenshotTool.dispose();
if (this.uptimeClock) this.uptimeClock.dispose();
if (this.quickSettings) this.quickSettings.dispose();
i18n.dispose();
toastManager.dispose();
}
// Public API
+39 -4
View File
@@ -19,6 +19,33 @@ export class TabManager {
tab.addEventListener('click', () => this.switchTab(tab));
});
// Arrow key navigation within tab bar (WCAG)
const nav = this.container.querySelector('.nav-tabs');
if (nav) {
nav.addEventListener('keydown', (e) => {
const buttonTabs = this.tabs.filter(t => t.tagName === 'BUTTON' && !t.disabled);
const currentIndex = buttonTabs.indexOf(document.activeElement);
if (currentIndex === -1) return;
let nextIndex = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % buttonTabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
nextIndex = (currentIndex - 1 + buttonTabs.length) % buttonTabs.length;
} else if (e.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
nextIndex = buttonTabs.length - 1;
}
if (nextIndex >= 0) {
e.preventDefault();
buttonTabs[nextIndex].focus();
this.switchTab(buttonTabs[nextIndex]);
}
});
}
// Activate first tab if none active
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
if (activeTab) {
@@ -36,14 +63,22 @@ export class TabManager {
return;
}
// Update tab states
// Update tab states and ARIA attributes
this.tabs.forEach(tab => {
tab.classList.toggle('active', tab === tabElement);
const isActive = tab === tabElement;
tab.classList.toggle('active', isActive);
if (tab.hasAttribute('aria-selected')) {
tab.setAttribute('aria-selected', String(isActive));
}
});
// Update content visibility
// Update content visibility and ARIA
this.tabContents.forEach(content => {
content.classList.toggle('active', content.id === tabId);
const isActive = content.id === tabId;
content.classList.toggle('active', isActive);
if (content.hasAttribute('role')) {
content.setAttribute('aria-hidden', String(!isActive));
}
});
// Update active tab
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head><title>RuView Icon Generator</title></head>
<body>
<p>Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png</p>
<canvas id="c192" width="192" height="192"></canvas>
<canvas id="c512" width="512" height="512"></canvas>
<script>
function drawIcon(canvas) {
const ctx = canvas.getContext('2d');
const s = canvas.width;
// Background
ctx.fillStyle = '#1f2121';
ctx.beginPath();
ctx.roundRect(0, 0, s, s, s * 0.15);
ctx.fill();
// WiFi arcs
ctx.strokeStyle = '#32b8c6';
ctx.lineWidth = s * 0.035;
ctx.lineCap = 'round';
const cx = s * 0.5, cy = s * 0.55;
[0.35, 0.25, 0.15].forEach(r => {
ctx.beginPath();
ctx.arc(cx, cy, s * r, -Math.PI * 0.75, -Math.PI * 0.25);
ctx.stroke();
});
// Center dot
ctx.fillStyle = '#32b8c6';
ctx.beginPath();
ctx.arc(cx, cy, s * 0.03, 0, Math.PI * 2);
ctx.fill();
// Person silhouette
ctx.strokeStyle = '#21808d';
ctx.lineWidth = s * 0.025;
// Head
ctx.beginPath();
ctx.arc(cx, cy - s * 0.15, s * 0.045, 0, Math.PI * 2);
ctx.stroke();
// Body
ctx.beginPath();
ctx.moveTo(cx, cy - s * 0.1);
ctx.lineTo(cx, cy + s * 0.05);
ctx.stroke();
// Arms
ctx.beginPath();
ctx.moveTo(cx - s * 0.08, cy - s * 0.04);
ctx.lineTo(cx + s * 0.08, cy - s * 0.04);
ctx.stroke();
// Legs
ctx.beginPath();
ctx.moveTo(cx, cy + s * 0.05);
ctx.lineTo(cx - s * 0.06, cy + s * 0.15);
ctx.moveTo(cx, cy + s * 0.05);
ctx.lineTo(cx + s * 0.06, cy + s * 0.15);
ctx.stroke();
// Text
ctx.fillStyle = '#f5f5f5';
ctx.font = `bold ${s * 0.08}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('RuView', cx, s * 0.88);
}
drawIcon(document.getElementById('c192'));
drawIcon(document.getElementById('c512'));
</script>
</body>
</html>
+38 -30
View File
@@ -3,40 +3,48 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#21808d">
<meta name="description" content="WiFi-based human pose estimation, vital sign detection, and presence sensing through walls">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>WiFi DensePose: Human Tracking Through Walls</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
</head>
<body>
<!-- Skip to main content link for keyboard/screen reader users -->
<a href="#dashboard" class="skip-to-content">Skip to main content</a>
<div class="container">
<!-- Header -->
<header class="header">
<header class="header" role="banner">
<h1>WiFi DensePose</h1>
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<p class="subtitle" data-i18n="dashboard.subtitle">Human Tracking Through Walls Using WiFi Signals</p>
<div class="header-info">
<span class="api-version"></span>
<span class="api-environment"></span>
<span class="overall-health"></span>
<span class="api-version" aria-label="API version"></span>
<span class="api-environment" aria-label="Environment"></span>
<span class="overall-health" role="status" aria-live="polite" aria-label="System health"></span>
</div>
</header>
<!-- Navigation -->
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">Dashboard</button>
<button class="nav-tab" data-tab="hardware">Hardware</button>
<button class="nav-tab" data-tab="demo">Live Demo</button>
<button class="nav-tab" data-tab="architecture">Architecture</button>
<button class="nav-tab" data-tab="performance">Performance</button>
<button class="nav-tab" data-tab="applications">Applications</button>
<button class="nav-tab" data-tab="sensing">Sensing</button>
<button class="nav-tab" data-tab="training">Training</button>
<nav class="nav-tabs" role="tablist" aria-label="Main navigation">
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true" aria-controls="dashboard">Dashboard</button>
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false" aria-controls="hardware">Hardware</button>
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false" aria-controls="demo">Live Demo</button>
<button class="nav-tab" data-tab="architecture" role="tab" aria-selected="false" aria-controls="architecture">Architecture</button>
<button class="nav-tab" data-tab="performance" role="tab" aria-selected="false" aria-controls="performance">Performance</button>
<button class="nav-tab" data-tab="applications" role="tab" aria-selected="false" aria-controls="applications">Applications</button>
<button class="nav-tab" data-tab="sensing" role="tab" aria-selected="false" aria-controls="sensing">Sensing</button>
<button class="nav-tab" data-tab="training" role="tab" aria-selected="false" aria-controls="training">Training</button>
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
</nav>
<!-- Dashboard Tab -->
<section id="dashboard" class="tab-content active">
<section id="dashboard" class="tab-content active" role="tabpanel" aria-labelledby="dashboard">
<div class="hero-section">
<h2>Revolutionary WiFi-Based Human Pose Detection</h2>
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
<p class="hero-description">
AI can track your full-body movement through walls using just WiFi signals.
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
@@ -48,7 +56,7 @@
<!-- Live Status Panel -->
<div class="live-status-panel">
<h3>System Status</h3>
<h3 data-i18n="dashboard.status">System Status</h3>
<div class="status-grid">
<div class="component-status" data-component="api">
<span class="component-name">API Server</span>
@@ -80,24 +88,24 @@
<!-- System Metrics -->
<div class="system-metrics-panel">
<h3>System Metrics</h3>
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label">CPU Usage</span>
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
<div class="progress-bar" data-type="cpu">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="cpu-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Memory Usage</span>
<span class="metric-label" data-i18n="metrics.memory">Memory Usage</span>
<div class="progress-bar" data-type="memory">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
<span class="memory-usage">0%</span>
</div>
<div class="metric-item">
<span class="metric-label">Disk Usage</span>
<span class="metric-label" data-i18n="metrics.disk">Disk Usage</span>
<div class="progress-bar" data-type="disk">
<div class="progress-fill normal" style="width: 0%"></div>
</div>
@@ -108,13 +116,13 @@
<!-- Features Status -->
<div class="features-panel">
<h3>Features</h3>
<h3 data-i18n="dashboard.features">Features</h3>
<div class="features-status"></div>
</div>
<!-- Live Statistics -->
<div class="live-stats-panel">
<h3>Live Statistics</h3>
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Active Persons</span>
@@ -181,7 +189,7 @@
</section>
<!-- Hardware Tab -->
<section id="hardware" class="tab-content">
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
<h2>Hardware Configuration</h2>
<div class="hardware-grid">
@@ -259,7 +267,7 @@
</section>
<!-- Demo Tab -->
<section id="demo" class="tab-content">
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
<h2>Live Demonstration</h2>
<div class="demo-controls">
@@ -312,7 +320,7 @@
</section>
<!-- Architecture Tab -->
<section id="architecture" class="tab-content">
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
<h2>System Architecture</h2>
<div class="architecture-flow">
@@ -350,7 +358,7 @@
</section>
<!-- Performance Tab -->
<section id="performance" class="tab-content">
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
<h2>Performance Analysis</h2>
<div class="performance-chart">
@@ -422,7 +430,7 @@
</section>
<!-- Applications Tab -->
<section id="applications" class="tab-content">
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
<h2>Real-World Applications</h2>
<div class="applications-grid">
@@ -489,10 +497,10 @@
</section>
<!-- Sensing Tab -->
<section id="sensing" class="tab-content"></section>
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
<!-- Training Tab -->
<section id="training" class="tab-content">
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
<div class="tab-header">
<h2>Model Training</h2>
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "RuView - WiFi DensePose",
"short_name": "RuView",
"description": "WiFi-based human pose estimation, vital sign detection, and presence sensing through walls",
"start_url": "/",
"display": "standalone",
"background_color": "#1f2121",
"theme_color": "#21808d",
"orientation": "any",
"categories": ["utilities", "medical"],
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+773 -252
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.3",
"@react-navigation/bottom-tabs": "^7.15.10",
"@react-navigation/native": "^7.1.31",
"@types/three": "^0.183.1",
"axios": "^1.13.6",
"axios": "^1.15.2",
"expo": "~55.0.4",
"expo-status-bar": "~55.0.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-dom": "19.2.6",
"react-native": "0.85.2",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
@@ -32,20 +32,20 @@
"react-native-wifi-reborn": "^4.13.6",
"three": "^0.183.2",
"victory-native": "^41.20.2",
"zustand": "^5.0.11"
"zustand": "^5.0.12"
},
"devDependencies": {
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "~19.2.2",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.56.1",
"babel-preset-expo": "^55.0.10",
"eslint": "^10.0.2",
"eslint": "^10.2.1",
"jest": "^30.2.0",
"jest-expo": "^55.0.9",
"prettier": "^3.8.1",
"prettier": "^3.8.3",
"react-native-worklets": "^0.7.4",
"typescript": "~5.9.2"
},
+19 -5
View File
@@ -9,11 +9,25 @@
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
*/
// Derive WebSocket URL from the page origin so it works on any port.
// The /ws/sensing endpoint is available on the same HTTP port (3000).
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
const SENSING_WS_PORT_BY_HTTP_PORT = {
// Docker image: HTTP UI/API on 3000, sensing stream on 3001.
'3000': '3001',
// Python sensing stack: UI on 8080, sensing stream on 8765.
'8080': '8765',
};
export function buildSensingWsUrl(locationLike = (typeof window !== 'undefined' ? window.location : null)) {
const protocol = locationLike && locationLike.protocol === 'https:' ? 'wss:' : 'ws:';
const host = locationLike && locationLike.host ? locationLike.host : 'localhost:3001';
const hostname = locationLike && locationLike.hostname ? locationLike.hostname : host.split(':')[0];
const port = locationLike && locationLike.port ? locationLike.port : '';
const wsPort = SENSING_WS_PORT_BY_HTTP_PORT[port];
const wsHost = wsPort ? `${hostname}:${wsPort}` : host;
return `${protocol}//${wsHost}/ws/sensing`;
}
const SENSING_WS_URL = buildSensingWsUrl();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
const MAX_RECONNECT_ATTEMPTS = 20;
// Number of failed attempts that must occur before simulation starts.
+24 -2
View File
@@ -136,9 +136,22 @@ export class WebSocketService {
// Set up WebSocket event handlers
setupEventHandlers(url, ws, handlers) {
const connection = this.connections.get(url);
const getConnection = (eventName) => {
const connection = this.connections.get(url);
if (!connection) {
this.logger.warn(`Ignoring WebSocket ${eventName} for unregistered connection`, {
url,
readyState: ws.readyState
});
return null;
}
return connection;
};
ws.onopen = (event) => {
const connection = getConnection('open');
if (!connection) return;
const connectionTime = Date.now() - connection.connectionStartTime;
this.logger.info(`WebSocket connected successfully`, { url, connectionTime });
@@ -158,6 +171,9 @@ export class WebSocketService {
};
ws.onmessage = (event) => {
const connection = getConnection('message');
if (!connection) return;
connection.lastActivity = Date.now();
connection.messageCount++;
@@ -188,6 +204,9 @@ export class WebSocketService {
};
ws.onerror = (event) => {
const connection = getConnection('error');
if (!connection) return;
connection.errorCount++;
this.logger.error(`WebSocket error occurred`, {
url,
@@ -208,6 +227,9 @@ export class WebSocketService {
};
ws.onclose = (event) => {
const connection = getConnection('close');
if (!connection) return;
const { code, reason, wasClean } = event;
this.logger.info(`WebSocket closed`, { url, code, reason, wasClean });
@@ -607,4 +629,4 @@ export class WebSocketService {
}
// Create singleton instance
export const wsService = new WebSocketService();
export const wsService = new WebSocketService();
+1741
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
// RuView Service Worker - Offline caching for the dashboard shell
// Strategy: Network-first for API calls, Cache-first for static assets
const CACHE_NAME = 'ruview-v1';
const SHELL_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/config/api.config.js',
'/components/TabManager.js',
'/components/DashboardTab.js',
'/components/HardwareTab.js',
'/components/LiveDemoTab.js',
'/components/SensingTab.js',
'/components/PoseDetectionCanvas.js',
'/services/api.service.js',
'/services/websocket.service.js',
'/services/health.service.js',
'/services/sensing.service.js',
'/services/pose.service.js',
'/services/stream.service.js',
'/utils/backend-detector.js',
'/utils/keyboard-shortcuts.js',
'/utils/perf-monitor.js',
'/utils/toast.js',
'/utils/theme-toggle.js',
'/utils/command-palette.js',
'/utils/activity-log.js',
'/utils/data-export.js',
'/utils/fullscreen.js',
'/utils/connection-status.js',
'/utils/mobile-nav.js'
];
// Install - cache shell assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(SHELL_ASSETS).catch((err) => {
// Don't fail install if some assets are missing (dev mode)
console.warn('[SW] Some assets failed to cache:', err);
});
})
);
self.skipWaiting();
});
// Activate - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
self.clients.claim();
});
// Fetch - network-first for API, cache-first for static
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip WebSocket upgrade requests
if (request.headers.get('Upgrade') === 'websocket') return;
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache-first with network fallback
event.respondWith(cacheFirst(request));
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
// Return offline fallback for HTML navigation
if (request.headers.get('Accept')?.includes('text/html')) {
const fallback = await caches.match('/index.html');
if (fallback) return fallback;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
+13 -1
View File
@@ -3,6 +3,7 @@
import { API_CONFIG, buildApiUrl, buildWsUrl } from '../config/api.config.js';
import { apiService } from '../services/api.service.js';
import { wsService } from '../services/websocket.service.js';
import { buildSensingWsUrl } from '../services/sensing.service.js';
import { poseService } from '../services/pose.service.js';
import { healthService } from '../services/health.service.js';
import { TabManager } from '../components/TabManager.js';
@@ -232,6 +233,17 @@ testRunner.test('buildWsUrl constructs WebSocket URLs', 'apiConfig', () => {
testRunner.assert(url.includes('token=test-token'), 'URL should contain token parameter');
});
testRunner.test('buildSensingWsUrl maps Docker UI port to sensing WebSocket port', 'apiConfig', () => {
const url = buildSensingWsUrl({
protocol: 'http:',
host: '192.168.28.147:3000',
hostname: '192.168.28.147',
port: '3000',
});
testRunner.assertEqual(url, 'ws://192.168.28.147:3001/ws/sensing');
});
// API Service Tests
testRunner.test('apiService has required methods', 'apiService', () => {
testRunner.assert(typeof apiService.get === 'function', 'get method should exist');
@@ -473,4 +485,4 @@ document.addEventListener('DOMContentLoaded', () => {
testRunner.updateSummary();
});
export { testRunner };
export { testRunner };
+472
View File
@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView UI - Unit Tests</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; }
h1 { font-size: 20px; margin-bottom: 4px; color: #32b8c6; }
.subtitle { font-size: 13px; color: #a7a9a9; margin-bottom: 20px; }
.suite { margin-bottom: 16px; }
.suite-name { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #a7a9a9; }
.test { padding: 4px 0 4px 16px; font-size: 13px; font-family: monospace; }
.pass { color: #32b8c6; }
.fail { color: #ff5459; }
.pass::before { content: "PASS "; font-weight: bold; }
.fail::before { content: "FAIL "; font-weight: bold; }
.summary { margin-top: 24px; padding: 12px; border-top: 1px solid #333; font-size: 14px; font-weight: 600; }
.error-detail { color: #ff8a8a; font-size: 12px; padding-left: 32px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>RuView UI - Unit Tests</h1>
<p class="subtitle">Tests for UI components and utility modules</p>
<div id="output"></div>
<div id="summary" class="summary"></div>
<script type="module">
// ---- Minimal test framework (zero deps) ----
const results = [];
let currentSuite = '';
function describe(name, fn) { currentSuite = name; fn(); }
function it(name, fn) {
try { fn(); results.push({ suite: currentSuite, name, passed: true }); }
catch (e) { results.push({ suite: currentSuite, name, passed: false, error: e.message }); }
}
function expect(actual) {
return {
toBe(exp) { if (actual !== exp) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
toEqual(exp) { if (JSON.stringify(actual) !== JSON.stringify(exp)) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
toBeTruthy() { if (!actual) throw new Error(`Expected truthy, got ${JSON.stringify(actual)}`); },
toBeFalsy() { if (actual) throw new Error(`Expected falsy, got ${JSON.stringify(actual)}`); },
toBeGreaterThan(n) { if (!(actual > n)) throw new Error(`Expected ${actual} > ${n}`); },
toContain(str) { if (typeof actual === 'string' ? !actual.includes(str) : !actual.includes(str)) throw new Error(`Expected to contain "${str}"`); },
not: {
toBe(exp) { if (actual === exp) throw new Error(`Expected not ${JSON.stringify(exp)}`); },
toContain(str) { if (typeof actual === 'string' && actual.includes(str)) throw new Error(`Expected not to contain "${str}"`); }
}
};
}
function mockDOM() {
const c = document.createElement('div');
c.className = 'container';
c.innerHTML = `
<header class="header"><div class="header-info"></div></header>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true">Dashboard</button>
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false">Hardware</button>
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false">Live Demo</button>
</nav>
<section id="dashboard" class="tab-content active" role="tabpanel"></section>
<section id="hardware" class="tab-content" role="tabpanel"></section>
<section id="demo" class="tab-content" role="tabpanel"></section>
`;
document.body.appendChild(c);
return c;
}
// ===== ToastManager =====
const { ToastManager } = await import('../utils/toast.js');
describe('ToastManager', () => {
it('creates container with role=region on init', () => {
const tm = new ToastManager();
tm.init();
expect(tm.container.getAttribute('role')).toBe('region');
expect(tm.container.getAttribute('aria-live')).toBe('polite');
tm.dispose();
});
it('show() returns unique incremental ids', () => {
const tm = new ToastManager();
tm.init();
const a = tm.show('A'); const b = tm.show('B');
expect(b).toBeGreaterThan(a);
tm.dispose();
});
it('dismiss() removes toast from list', () => {
const tm = new ToastManager();
tm.init();
const id = tm.show('X', { duration: 0 });
expect(tm.toasts.length).toBe(1);
tm.dismiss(id);
expect(tm.toasts.length).toBe(0);
tm.dispose();
});
it('dismiss() is safe to call with unknown id', () => {
const tm = new ToastManager();
tm.init();
tm.dismiss(99999); // should not throw
expect(tm.toasts.length).toBe(0);
tm.dispose();
});
it('success/error/warning/info create correct types', () => {
const tm = new ToastManager();
tm.init();
tm.success('a'); tm.error('b'); tm.warning('c'); tm.info('d');
expect(tm.toasts.length).toBe(4);
tm.dispose();
});
it('escapes HTML entities to prevent XSS', () => {
const tm = new ToastManager();
const safe = tm.escapeHtml('<img src=x onerror=alert(1)>');
expect(safe).not.toContain('<img');
expect(safe).toContain('&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
@@ -0,0 +1,181 @@
// Activity Log - Scrollable panel showing system events in real-time
// Toggle with 'L' key or command palette
export class ActivityLog {
constructor() {
this.panel = null;
this.visible = false;
this.entries = [];
this.maxEntries = 200;
this.logBody = null;
this.filters = { info: true, warning: true, error: true, connection: true };
}
init() {
this.createPanel();
this.interceptConsole();
document.addEventListener('toggle-activity-log', () => this.toggle());
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'activity-log';
this.panel.setAttribute('role', 'log');
this.panel.setAttribute('aria-label', 'Activity log');
this.panel.innerHTML = `
<div class="activity-log-header">
<span class="activity-log-title">Activity Log</span>
<div class="activity-log-controls">
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
<button class="activity-log-close" aria-label="Close activity log">&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
@@ -0,0 +1,311 @@
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
// Fuzzy search across tabs, actions, and settings
export class CommandPalette {
constructor(app) {
this.app = app;
this.overlay = null;
this.input = null;
this.results = null;
this.visible = false;
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
}
init() {
this.registerCommands();
this.createDOM();
this.bindGlobalShortcut();
}
registerCommands() {
// Navigation commands
const tabs = [
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
{ id: 'demo', label: 'Live Demo', icon: 'play' },
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
{ id: 'performance', label: 'Performance', icon: 'zap' },
{ id: 'applications', label: 'Applications', icon: 'box' },
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
{ id: 'training', label: 'Training', icon: 'database' },
];
tabs.forEach(tab => {
this.commands.push({
category: 'Navigation',
label: `Go to ${tab.label}`,
keywords: [tab.id, tab.label.toLowerCase()],
icon: tab.icon,
action: () => {
const tm = this.app?.getComponent?.('tabManager');
if (tm) tm.switchToTab(tab.id);
}
});
});
// External pages
this.commands.push({
category: 'Navigation',
label: 'Open Pose Fusion',
keywords: ['pose', 'fusion', 'camera'],
icon: 'external',
action: () => { window.location.href = 'pose-fusion.html'; }
});
this.commands.push({
category: 'Navigation',
label: 'Open Observatory',
keywords: ['observatory', '3d', 'signal'],
icon: 'external',
action: () => { window.location.href = 'observatory.html'; }
});
// Actions
this.commands.push({
category: 'Actions',
label: 'Toggle Dark/Light Theme',
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
icon: 'moon',
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Performance Monitor',
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
icon: 'activity',
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Activity Log',
keywords: ['log', 'events', 'activity', 'history'],
icon: 'list',
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
});
this.commands.push({
category: 'Actions',
label: 'Export Sensor Data',
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
icon: 'download',
action: () => document.dispatchEvent(new CustomEvent('export-data'))
});
this.commands.push({
category: 'Actions',
label: 'Toggle Fullscreen',
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
icon: 'maximize',
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
});
this.commands.push({
category: 'Actions',
label: 'Show Keyboard Shortcuts',
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
icon: 'keyboard',
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
});
}
createDOM() {
this.overlay = document.createElement('div');
this.overlay.className = 'cmd-palette-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Command palette');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.innerHTML = `
<div class="cmd-palette">
<div class="cmd-palette-input-wrap">
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
<kbd class="cmd-palette-hint">Esc</kbd>
</div>
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
<div class="cmd-palette-footer">
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
<span><kbd>Enter</kbd> execute</span>
<span><kbd>Esc</kbd> close</span>
</div>
</div>
`;
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.hide();
});
this.input = this.overlay.querySelector('.cmd-palette-input');
this.results = this.overlay.querySelector('.cmd-palette-results');
this.input.addEventListener('input', () => this.onInput());
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
document.body.appendChild(this.overlay);
}
bindGlobalShortcut() {
document.addEventListener('keydown', (e) => {
// Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.visible ? this.hide() : this.show();
}
show() {
this.visible = true;
this.overlay.classList.add('visible');
this.input.value = '';
this.selectedIndex = 0;
this.filteredCommands = [...this.commands];
this.renderResults();
this.input.focus();
}
hide() {
this.visible = false;
this.overlay.classList.remove('visible');
}
onInput() {
const query = this.input.value.toLowerCase().trim();
if (!query) {
this.filteredCommands = [...this.commands];
} else {
this.filteredCommands = this.commands
.map(cmd => {
const score = this.fuzzyScore(query, cmd);
return { ...cmd, score };
})
.filter(cmd => cmd.score > 0)
.sort((a, b) => b.score - a.score);
}
this.selectedIndex = 0;
this.renderResults();
}
fuzzyScore(query, cmd) {
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
let best = 0;
for (const target of targets) {
if (target === query) return 100;
if (target.startsWith(query)) best = Math.max(best, 80);
if (target.includes(query)) best = Math.max(best, 60);
// Check each word
const words = query.split(/\s+/);
const allMatch = words.every(w => targets.some(t => t.includes(w)));
if (allMatch) best = Math.max(best, 40);
}
return best;
}
renderResults() {
if (this.filteredCommands.length === 0) {
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
return;
}
let lastCategory = '';
let html = '';
this.filteredCommands.forEach((cmd, i) => {
if (cmd.category !== lastCategory) {
lastCategory = cmd.category;
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
}
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
html += `
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
<span class="cmd-palette-item-label">${cmd.label}</span>
</div>`;
});
this.results.innerHTML = html;
// Click handlers
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index, 10);
this.executeCommand(idx);
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index, 10);
this.updateSelection();
});
});
// Scroll selected into view
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
}
updateSelection() {
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
el.classList.toggle('cmd-palette-item-selected', isSelected);
el.setAttribute('aria-selected', String(isSelected));
});
}
onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
const sel = this.results.querySelector('.cmd-palette-item-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.selectedIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
this.hide();
}
}
executeCommand(index) {
const cmd = this.filteredCommands[index];
if (cmd) {
this.hide();
cmd.action();
}
}
getIcon(name) {
const icons = {
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
};
return icons[name] || '';
}
dispose() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// Connection Status Widget - Persistent indicator in header
// Shows WebSocket and API connection state with reconnect button
import { sensingService } from '../services/sensing.service.js';
export class ConnectionStatus {
constructor() {
this.widget = null;
this._unsub = null;
}
init() {
this.createWidget();
this.subscribe();
}
createWidget() {
this.widget = document.createElement('div');
this.widget.className = 'conn-status';
this.widget.setAttribute('role', 'status');
this.widget.setAttribute('aria-live', 'polite');
this.widget.innerHTML = `
<span class="conn-status-dot"></span>
<span class="conn-status-label">Connecting</span>
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
`;
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
this.setStatus('reconnecting', 'Reconnecting...');
sensingService.reconnect?.();
});
// Insert into header-info, after theme toggle if present
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.widget);
}
}
subscribe() {
this._unsub = sensingService.onStateChange(() => {
this.update();
});
// Initial
this.update();
}
update() {
const state = sensingService.state;
const source = sensingService.dataSource;
if (state === 'connected' || state === 'streaming') {
const label = source === 'live' ? 'Live' :
source === 'server-simulated' ? 'Simulated' :
'Connected';
this.setStatus('connected', label);
} else if (state === 'connecting' || state === 'reconnecting') {
this.setStatus('reconnecting', 'Connecting...');
} else if (state === 'error') {
this.setStatus('error', 'Error');
} else {
this.setStatus('disconnected', 'Offline');
}
}
setStatus(status, label) {
if (!this.widget) return;
this.widget.className = `conn-status conn-status-${status}`;
this.widget.querySelector('.conn-status-label').textContent = label;
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
reconnectBtn.style.display =
(status === 'disconnected' || status === 'error') ? '' : 'none';
}
dispose() {
if (this._unsub) this._unsub();
if (this.widget?.parentNode) {
this.widget.parentNode.removeChild(this.widget);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// Data Export Utility - Export sensor/pose data as JSON or CSV
import { sensingService } from '../services/sensing.service.js';
import { toastManager } from './toast.js';
export class DataExport {
constructor() {
this.buffer = [];
this.maxBuffer = 1000;
this.recording = false;
this._unsub = null;
}
init() {
document.addEventListener('export-data', () => this.showExportDialog());
// Continuously buffer sensing data when available
this._unsub = sensingService.onData((data) => {
if (this.buffer.length >= this.maxBuffer) {
this.buffer.shift();
}
this.buffer.push({
timestamp: new Date().toISOString(),
...this.extractFields(data)
});
});
}
extractFields(data) {
// Extract relevant fields from sensing data
return {
rssi: data.rssi ?? null,
variance: data.variance ?? null,
motion_band: data.motion_band ?? null,
breathing_band: data.breathing_band ?? null,
classification: data.classification ?? null,
person_count: data.person_count ?? data.persons ?? null,
subcarriers: data.subcarrier_count ?? null,
source: data.source ?? null
};
}
showExportDialog() {
if (this.buffer.length === 0) {
toastManager.warning('No sensor data to export. Connect to a data source first.');
return;
}
// Create dialog
const overlay = document.createElement('div');
overlay.className = 'export-dialog-overlay';
overlay.innerHTML = `
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
<h3>Export Sensor Data</h3>
<p class="export-dialog-info">${this.buffer.length} data points available</p>
<div class="export-dialog-options">
<label class="export-option">
<input type="radio" name="export-format" value="json" checked>
<span>JSON</span>
<small>Full data with nested fields</small>
</label>
<label class="export-option">
<input type="radio" name="export-format" value="csv">
<span>CSV</span>
<small>Flat table, spreadsheet-ready</small>
</label>
</div>
<div class="export-dialog-range">
<label>
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
</label>
</div>
<div class="export-dialog-actions">
<button class="btn btn--secondary export-cancel">Cancel</button>
<button class="btn btn--primary export-confirm">Export</button>
</div>
</div>
`;
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.export-confirm').addEventListener('click', () => {
const format = overlay.querySelector('input[name="export-format"]:checked').value;
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
this.exportData(format, count);
overlay.remove();
});
document.body.appendChild(overlay);
overlay.querySelector('.export-confirm').focus();
}
exportData(format, count) {
const data = this.buffer.slice(-count);
let content, filename, mimeType;
if (format === 'json') {
content = JSON.stringify(data, null, 2);
filename = `ruview-data-${this.timestamp()}.json`;
mimeType = 'application/json';
} else {
content = this.toCSV(data);
filename = `ruview-data-${this.timestamp()}.csv`;
mimeType = 'text/csv';
}
this.downloadFile(content, filename, mimeType);
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
}
toCSV(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const rows = data.map(row => headers.map(h => {
const val = row[h];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
return `"${val.replace(/"/g, '""')}"`;
}
return String(val);
}).join(','));
return [headers.join(','), ...rows].join('\n');
}
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {
if (this._unsub) this._unsub();
}
}
+79
View File
@@ -0,0 +1,79 @@
// Fullscreen Mode - Toggle fullscreen on visualization tabs
// Activated via F11 key, command palette, or button
export class FullscreenManager {
constructor() {
this.isFullscreen = false;
this.targetElement = null;
}
init() {
document.addEventListener('toggle-fullscreen', () => this.toggle());
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault();
this.toggle();
}
});
document.addEventListener('fullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
this.updateUI();
});
}
toggle() {
if (this.isFullscreen) {
this.exit();
} else {
this.enter();
}
}
enter() {
// Find the active tab content
const activePanel = document.querySelector('.tab-content.active');
if (!activePanel) return;
this.targetElement = activePanel;
if (activePanel.requestFullscreen) {
activePanel.requestFullscreen();
} else if (activePanel.webkitRequestFullscreen) {
activePanel.webkitRequestFullscreen();
}
}
exit() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
this.targetElement = null;
}
updateUI() {
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
// Add/remove exit button when in fullscreen
let exitBtn = document.getElementById('fullscreen-exit-btn');
if (this.isFullscreen && !exitBtn) {
exitBtn = document.createElement('button');
exitBtn.id = 'fullscreen-exit-btn';
exitBtn.className = 'fullscreen-exit-btn';
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
exitBtn.title = 'Exit fullscreen (F11)';
exitBtn.addEventListener('click', () => this.exit());
document.body.appendChild(exitBtn);
} else if (!this.isFullscreen && exitBtn) {
exitBtn.remove();
}
}
dispose() {
if (this.isFullscreen) this.exit();
}
}
+264
View File
@@ -0,0 +1,264 @@
// Internationalization - EN/PL language support
// Detects browser language, persists choice, translates UI strings
const translations = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.hardware': 'Hardware',
'nav.demo': 'Live Demo',
'nav.architecture': 'Architecture',
'nav.performance': 'Performance',
'nav.applications': 'Applications',
'nav.sensing': 'Sensing',
'nav.training': 'Training',
// Dashboard
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
'dashboard.status': 'System Status',
'dashboard.metrics': 'System Metrics',
'dashboard.features': 'Features',
'dashboard.liveStats': 'Live Statistics',
'dashboard.activePersons': 'Active Persons',
'dashboard.avgConfidence': 'Avg Confidence',
'dashboard.totalDetections': 'Total Detections',
'dashboard.zoneOccupancy': 'Zone Occupancy',
// Status
'status.apiServer': 'API Server',
'status.hardware': 'Hardware',
'status.inference': 'Inference',
'status.streaming': 'Streaming',
'status.dataSource': 'Data Source',
// Metrics
'metrics.cpu': 'CPU Usage',
'metrics.memory': 'Memory Usage',
'metrics.disk': 'Disk Usage',
// Benefits
'benefit.throughWalls': 'Through Walls',
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
'benefit.privacy': 'Privacy-Preserving',
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
'benefit.realtime': 'Real-Time',
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
'benefit.lowCost': 'Low Cost',
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
// Stats
'stat.bodyRegions': 'Body Regions',
'stat.samplingRate': 'Sampling Rate',
'stat.accuracy': 'Accuracy (AP@50)',
'stat.hardwareCost': 'Hardware Cost',
// Actions
'action.startDetection': 'Start Detection',
'action.stopDetection': 'Stop Detection',
'action.toggleTheme': 'Toggle theme',
'action.exportData': 'Export data',
'action.screenshot': 'Take screenshot',
// Connection
'conn.connected': 'Connected',
'conn.connecting': 'Connecting...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Reconnecting...',
'conn.live': 'Live',
'conn.simulated': 'Simulated',
// Misc
'misc.loading': 'Loading...',
'misc.error': 'An error occurred',
'misc.noData': 'No data available',
'misc.close': 'Close',
'misc.cancel': 'Cancel',
'misc.confirm': 'Confirm',
'misc.settings': 'Settings',
'misc.language': 'Language'
},
pl: {
// Navigation
'nav.dashboard': 'Panel',
'nav.hardware': 'Sprzet',
'nav.demo': 'Demo na zywo',
'nav.architecture': 'Architektura',
'nav.performance': 'Wydajnosc',
'nav.applications': 'Aplikacje',
'nav.sensing': 'Czujniki',
'nav.training': 'Trening',
// Dashboard
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
'dashboard.status': 'Status systemu',
'dashboard.metrics': 'Metryki systemu',
'dashboard.features': 'Funkcje',
'dashboard.liveStats': 'Statystyki na zywo',
'dashboard.activePersons': 'Aktywne osoby',
'dashboard.avgConfidence': 'Srednia pewnosc',
'dashboard.totalDetections': 'Laczne detekcje',
'dashboard.zoneOccupancy': 'Zajecie stref',
// Status
'status.apiServer': 'Serwer API',
'status.hardware': 'Sprzet',
'status.inference': 'Wnioskowanie',
'status.streaming': 'Streaming',
'status.dataSource': 'Zrodlo danych',
// Metrics
'metrics.cpu': 'Uzycie CPU',
'metrics.memory': 'Uzycie pamieci',
'metrics.disk': 'Uzycie dysku',
// Benefits
'benefit.throughWalls': 'Przez sciany',
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
'benefit.privacy': 'Ochrona prywatnosci',
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
'benefit.realtime': 'Czas rzeczywisty',
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
'benefit.lowCost': 'Niski koszt',
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
// Stats
'stat.bodyRegions': 'Regiony ciala',
'stat.samplingRate': 'Czestotliwosc',
'stat.accuracy': 'Dokladnosc (AP@50)',
'stat.hardwareCost': 'Koszt sprzetu',
// Actions
'action.startDetection': 'Rozpocznij detekcje',
'action.stopDetection': 'Zatrzymaj detekcje',
'action.toggleTheme': 'Zmien motyw',
'action.exportData': 'Eksportuj dane',
'action.screenshot': 'Zrob zrzut ekranu',
// Connection
'conn.connected': 'Polaczono',
'conn.connecting': 'Laczenie...',
'conn.offline': 'Offline',
'conn.reconnecting': 'Ponowne laczenie...',
'conn.live': 'Na zywo',
'conn.simulated': 'Symulacja',
// Misc
'misc.loading': 'Ladowanie...',
'misc.error': 'Wystapil blad',
'misc.noData': 'Brak danych',
'misc.close': 'Zamknij',
'misc.cancel': 'Anuluj',
'misc.confirm': 'Potwierdz',
'misc.settings': 'Ustawienia',
'misc.language': 'Jezyk'
}
};
export class I18n {
constructor() {
this.locale = this.getSavedLocale() || this.detectLocale();
this.listeners = [];
}
init() {
this.createSelector();
this.applyTranslations();
}
detectLocale() {
const lang = navigator.language?.toLowerCase() || 'en';
if (lang.startsWith('pl')) return 'pl';
return 'en';
}
getSavedLocale() {
try { return localStorage.getItem('ruview-locale'); }
catch { return null; }
}
saveLocale(locale) {
try { localStorage.setItem('ruview-locale', locale); }
catch { /* noop */ }
}
t(key) {
const dict = translations[this.locale] || translations.en;
return dict[key] || translations.en[key] || key;
}
setLocale(locale) {
if (!translations[locale]) return;
this.locale = locale;
this.saveLocale(locale);
document.documentElement.setAttribute('lang', locale);
this.applyTranslations();
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
}
onLocaleChange(callback) {
this.listeners.push(callback);
return () => {
const i = this.listeners.indexOf(callback);
if (i > -1) this.listeners.splice(i, 1);
};
}
applyTranslations() {
// Translate elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = this.t(key);
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
const key = el.getAttribute('data-i18n-aria');
el.setAttribute('aria-label', this.t(key));
});
// Update language selector
const selector = document.getElementById('lang-selector');
if (selector) selector.value = this.locale;
}
createSelector() {
const wrapper = document.createElement('div');
wrapper.className = 'lang-selector-wrap';
wrapper.innerHTML = `
<select id="lang-selector" class="lang-selector" aria-label="Language">
<option value="en">EN</option>
<option value="pl">PL</option>
</select>
`;
const select = wrapper.querySelector('select');
select.value = this.locale;
select.addEventListener('change', () => this.setLocale(select.value));
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.appendChild(wrapper);
}
}
getAvailableLocales() {
return Object.keys(translations);
}
dispose() {
this.listeners = [];
}
}
export const i18n = new I18n();
+83
View File
@@ -0,0 +1,83 @@
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
// Reduces CPU/battery usage on idle dashboards
export class IdleManager {
constructor() {
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
this.isIdle = false;
this.timer = null;
this.callbacks = { idle: [], active: [] };
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
}
init() {
this.resetTimer();
this.events.forEach(evt => {
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
});
// Also use Page Visibility API
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.goIdle();
} else {
this.goActive();
}
});
}
onActivity() {
if (this.isIdle) {
this.goActive();
}
this.resetTimer();
}
resetTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
}
goIdle() {
if (this.isIdle) return;
this.isIdle = true;
console.info('[Idle] User inactive - pausing background tasks');
this.notify('idle');
document.body.classList.add('user-idle');
}
goActive() {
if (!this.isIdle) return;
this.isIdle = false;
console.info('[Idle] User active - resuming background tasks');
this.notify('active');
document.body.classList.remove('user-idle');
this.resetTimer();
}
onIdle(callback) {
this.callbacks.idle.push(callback);
return () => {
const i = this.callbacks.idle.indexOf(callback);
if (i > -1) this.callbacks.idle.splice(i, 1);
};
}
onActive(callback) {
this.callbacks.active.push(callback);
return () => {
const i = this.callbacks.active.indexOf(callback);
if (i > -1) this.callbacks.active.splice(i, 1);
};
}
notify(type) {
this.callbacks[type].forEach(cb => {
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
});
}
dispose() {
if (this.timer) clearTimeout(this.timer);
this.callbacks = { idle: [], active: [] };
}
}
+168
View File
@@ -0,0 +1,168 @@
// Keyboard Shortcuts System
// Press '?' to show help overlay, number keys to switch tabs, etc.
export class KeyboardShortcuts {
constructor(app) {
this.app = app;
this.shortcuts = new Map();
this.helpVisible = false;
this.enabled = true;
this.overlay = null;
this.registerDefaults();
}
registerDefaults() {
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
}
register(key, description, handler) {
this.shortcuts.set(key, { description, handler });
}
init() {
document.addEventListener('keydown', (e) => this.handleKeydown(e));
this.createOverlay();
}
handleKeydown(e) {
if (!this.enabled) return;
// Ignore when typing in inputs
const tag = e.target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
if (e.key === 'Escape') {
e.target.blur();
}
return;
}
// Ignore modified keys (except shift for '?')
if (e.ctrlKey || e.altKey || e.metaKey) return;
const shortcut = this.shortcuts.get(e.key);
if (shortcut) {
e.preventDefault();
shortcut.handler();
}
}
switchTab(tabId) {
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager) {
tabManager.switchToTab(tabId);
}
}
togglePerfMonitor() {
const event = new CustomEvent('toggle-perf-monitor');
document.dispatchEvent(event);
}
toggleTheme() {
const event = new CustomEvent('toggle-theme');
document.dispatchEvent(event);
}
closeAll() {
if (this.helpVisible) {
this.hideHelp();
}
}
createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'shortcuts-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.innerHTML = this.buildHelpHTML();
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.hideHelp();
});
document.body.appendChild(this.overlay);
}
buildHelpHTML() {
const groups = [
{
title: 'Navigation',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => /^[1-8]$/.test(key))
},
{
title: 'Actions',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => /^[a-z]$/.test(key))
},
{
title: 'General',
items: Array.from(this.shortcuts.entries())
.filter(([key]) => !/^[1-8a-z]$/.test(key))
}
];
return `
<div class="shortcuts-panel">
<div class="shortcuts-header">
<h2>Keyboard Shortcuts</h2>
<button class="shortcuts-close" aria-label="Close">&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
@@ -0,0 +1,171 @@
// Mobile Navigation - Hamburger menu for small screens
// Replaces wrapped tab bar with a slide-out drawer on mobile
export class MobileNav {
constructor() {
this.drawer = null;
this.backdrop = null;
this.hamburger = null;
this.isOpen = false;
this.mql = window.matchMedia('(max-width: 768px)');
}
init() {
this.createHamburger();
this.createDrawer();
this.bindEvents();
this.onMediaChange(this.mql);
}
createHamburger() {
this.hamburger = document.createElement('button');
this.hamburger.className = 'mobile-hamburger';
this.hamburger.setAttribute('aria-label', 'Open navigation menu');
this.hamburger.setAttribute('aria-expanded', 'false');
this.hamburger.innerHTML = `
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
`;
this.hamburger.addEventListener('click', () => this.toggle());
const header = document.querySelector('.header');
if (header) {
header.style.position = 'relative';
header.appendChild(this.hamburger);
}
}
createDrawer() {
// Backdrop
this.backdrop = document.createElement('div');
this.backdrop.className = 'mobile-nav-backdrop';
this.backdrop.addEventListener('click', () => this.close());
document.body.appendChild(this.backdrop);
// Drawer
this.drawer = document.createElement('nav');
this.drawer.className = 'mobile-nav-drawer';
this.drawer.setAttribute('role', 'navigation');
this.drawer.setAttribute('aria-label', 'Mobile navigation');
// Clone tabs into drawer
const tabs = document.querySelectorAll('.nav-tabs .nav-tab');
const list = document.createElement('div');
list.className = 'mobile-nav-list';
tabs.forEach(tab => {
const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button');
item.className = 'mobile-nav-item';
item.textContent = tab.textContent.trim();
if (tab.tagName === 'A') {
item.href = tab.href;
} else {
const tabId = tab.getAttribute('data-tab');
item.dataset.tab = tabId;
if (tab.classList.contains('active')) {
item.classList.add('active');
}
item.addEventListener('click', () => {
// Activate tab via the original tab manager
tab.click();
this.close();
// Update active states in drawer
list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
});
}
list.appendChild(item);
});
this.drawer.appendChild(list);
// Keyboard hint at bottom
const hint = document.createElement('div');
hint.className = 'mobile-nav-hint';
hint.textContent = 'Tip: Press Ctrl+K for command palette';
this.drawer.appendChild(hint);
document.body.appendChild(this.drawer);
// Sync active tab when tabs change externally
const observer = new MutationObserver(() => {
const activeTab = document.querySelector('.nav-tabs .nav-tab.active');
if (activeTab) {
const activeId = activeTab.getAttribute('data-tab');
list.querySelectorAll('.mobile-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.tab === activeId);
});
}
});
const navTabs = document.querySelector('.nav-tabs');
if (navTabs) {
observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
}
}
bindEvents() {
// Listen for media query changes
this.mql.addEventListener('change', (e) => this.onMediaChange(e));
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) this.close();
});
// Swipe to close
let touchStartX = 0;
this.drawer.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
}, { passive: true });
this.drawer.addEventListener('touchend', (e) => {
const deltaX = e.changedTouches[0].clientX - touchStartX;
if (deltaX < -50) this.close(); // Swipe left to close
}, { passive: true });
}
onMediaChange(mql) {
const isMobile = mql.matches !== undefined ? mql.matches : mql;
document.body.classList.toggle('mobile-nav-active', isMobile);
if (!isMobile && this.isOpen) {
this.close();
}
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.drawer.classList.add('open');
this.backdrop.classList.add('open');
this.hamburger.classList.add('open');
this.hamburger.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
// Focus first item
const first = this.drawer.querySelector('.mobile-nav-item');
if (first) first.focus();
}
close() {
this.isOpen = false;
this.drawer.classList.remove('open');
this.backdrop.classList.remove('open');
this.hamburger.classList.remove('open');
this.hamburger.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
dispose() {
this.close();
this.hamburger?.remove();
this.drawer?.remove();
this.backdrop?.remove();
}
}
+233
View File
@@ -0,0 +1,233 @@
// Notification Center - Bell icon with event history
// Persists notifications across page views (sessionStorage)
export class NotificationCenter {
constructor() {
this.button = null;
this.panel = null;
this.notifications = [];
this.maxNotifications = 50;
this.isOpen = false;
this.unreadCount = 0;
this.storageKey = 'ruview-notifications';
}
init() {
this.loadFromStorage();
this.createButton();
this.createPanel();
this.interceptEvents();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'notif-bell';
this.button.setAttribute('aria-label', 'Notifications');
this.button.setAttribute('title', 'Notifications');
this.button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="notif-badge" style="display:none">0</span>
`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.button);
}
this.updateBadge();
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'notif-panel';
this.panel.setAttribute('role', 'region');
this.panel.setAttribute('aria-label', 'Notification history');
this.panel.innerHTML = `
<div class="notif-panel-header">
<span>Notifications</span>
<div class="notif-panel-actions">
<button class="notif-mark-read" title="Mark all read">Mark read</button>
<button class="notif-clear" title="Clear all">Clear</button>
</div>
</div>
<div class="notif-panel-body"></div>
`;
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.renderList();
this.saveToStorage();
});
document.body.appendChild(this.panel);
// Close on outside click
document.addEventListener('click', (e) => {
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
this.close();
}
});
}
interceptEvents() {
// Listen for toast events to capture as notifications
const origInfo = console.info;
console.info = (...args) => {
origInfo.apply(console, args);
const msg = args.map(String).join(' ');
// Only capture app-relevant messages
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
this.add(msg, 'info');
}
};
const origWarn = console.warn;
console.warn = (...args) => {
origWarn.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
msg.includes('connection') || msg.includes('timeout')) {
this.add(msg, 'warning');
}
};
const origError = console.error;
console.error = (...args) => {
origError.apply(console, args);
const msg = args.map(String).join(' ');
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
this.add(msg, 'error');
}
};
}
add(message, type = 'info') {
const notification = {
id: Date.now() + Math.random(),
message: this.truncate(message, 200),
type,
time: new Date().toISOString(),
read: false
};
this.notifications.unshift(notification);
if (this.notifications.length > this.maxNotifications) {
this.notifications.pop();
}
this.unreadCount++;
this.updateBadge();
this.saveToStorage();
if (this.isOpen) {
this.renderList();
}
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.isOpen = true;
this.panel.classList.add('open');
this.renderList();
}
close() {
this.isOpen = false;
this.panel.classList.remove('open');
}
renderList() {
const body = this.panel.querySelector('.notif-panel-body');
if (this.notifications.length === 0) {
body.innerHTML = '<div class="notif-empty">No notifications</div>';
return;
}
body.innerHTML = this.notifications.map(n => {
const time = new Date(n.time);
const ago = this.timeAgo(time);
return `
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
<div class="notif-item-dot"></div>
<div class="notif-item-content">
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
<span class="notif-item-time">${ago}</span>
</div>
</div>
`;
}).join('');
}
updateBadge() {
const badge = this.button?.querySelector('.notif-badge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return date.toLocaleDateString();
}
truncate(str, max) {
return str.length > max ? str.slice(0, max) + '...' : str;
}
escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
loadFromStorage() {
try {
const data = sessionStorage.getItem(this.storageKey);
if (data) {
const parsed = JSON.parse(data);
this.notifications = parsed.notifications || [];
this.unreadCount = parsed.unreadCount || 0;
}
} catch { /* noop */ }
}
saveToStorage() {
try {
sessionStorage.setItem(this.storageKey, JSON.stringify({
notifications: this.notifications.slice(0, 20),
unreadCount: this.unreadCount
}));
} catch { /* noop */ }
}
dispose() {
this.close();
this.button?.remove();
this.panel?.remove();
}
}
+192
View File
@@ -0,0 +1,192 @@
// Onboarding Tour - Interactive first-run walkthrough
// Shows on first visit, can be re-triggered from command palette or help
const STORAGE_KEY = 'ruview-onboarding-done';
export class Onboarding {
constructor(app) {
this.app = app;
this.overlay = null;
this.currentStep = 0;
this.steps = [];
this.active = false;
}
init() {
this.defineSteps();
document.addEventListener('start-onboarding', () => this.start());
// Auto-start on first visit
if (!this.isDone()) {
// Delay to let the app render first
setTimeout(() => this.start(), 800);
}
}
defineSteps() {
this.steps = [
{
title: 'Welcome to RuView',
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
target: null, // No highlight, centered
position: 'center'
},
{
title: 'System Status',
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
target: '.live-status-panel',
position: 'bottom'
},
{
title: 'Live Demo',
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
target: '[data-tab="demo"]',
position: 'bottom'
},
{
title: 'Sensing Visualization',
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
target: '[data-tab="sensing"]',
position: 'bottom'
},
{
title: 'Keyboard Shortcuts',
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
target: null,
position: 'center'
},
{
title: 'You\'re all set!',
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
target: null,
position: 'center'
}
];
}
isDone() {
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
catch { return false; }
}
markDone() {
try { localStorage.setItem(STORAGE_KEY, 'true'); }
catch { /* noop */ }
}
start() {
this.currentStep = 0;
this.active = true;
this.createOverlay();
this.showStep();
}
createOverlay() {
// Remove existing if any
this.removeOverlay();
this.overlay = document.createElement('div');
this.overlay.className = 'onboarding-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-label', 'Onboarding tour');
this.overlay.setAttribute('aria-modal', 'true');
document.body.appendChild(this.overlay);
}
showStep() {
if (this.currentStep >= this.steps.length) {
this.finish();
return;
}
const step = this.steps[this.currentStep];
const total = this.steps.length;
const isFirst = this.currentStep === 0;
const isLast = this.currentStep === total - 1;
// Clear highlight
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
// Highlight target
let targetRect = null;
if (step.target) {
const targetEl = document.querySelector(step.target);
if (targetEl) {
targetEl.classList.add('onboarding-highlight');
targetRect = targetEl.getBoundingClientRect();
}
}
this.overlay.innerHTML = `
<div class="onboarding-backdrop"></div>
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
<div class="onboarding-progress">
${Array.from({ length: total }, (_, i) =>
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
).join('')}
</div>
<h3 class="onboarding-title">${step.title}</h3>
<p class="onboarding-text">${step.text}</p>
<div class="onboarding-actions">
<button class="onboarding-skip">Skip tour</button>
<div class="onboarding-nav">
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
</div>
</div>
</div>
`;
// Bind events
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
this.currentStep++;
this.showStep();
});
const prevBtn = this.overlay.querySelector('.onboarding-prev');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
this.currentStep--;
this.showStep();
});
}
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
// Focus next button
this.overlay.querySelector('.onboarding-next').focus();
// Escape to close
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
document.addEventListener('keydown', this._escHandler);
}
positionTooltip(rect, position) {
const margin = 12;
if (position === 'bottom') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
}
if (position === 'top') {
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
}
return '';
}
finish() {
this.active = false;
this.markDone();
this.removeOverlay();
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
}
removeOverlay() {
if (this.overlay?.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
}
}
dispose() {
this.finish();
}
}
+216
View File
@@ -0,0 +1,216 @@
// Performance Monitor Overlay
// Shows FPS, memory usage, and network latency in real-time
export class PerfMonitor {
constructor() {
this.visible = false;
this.panel = null;
this.frames = [];
this.lastFrameTime = 0;
this.rafId = null;
this.latencyHistory = [];
this.maxHistory = 60;
}
init() {
this.createPanel();
document.addEventListener('toggle-perf-monitor', () => this.toggle());
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'perf-monitor';
this.panel.setAttribute('role', 'status');
this.panel.setAttribute('aria-label', 'Performance monitor');
this.panel.innerHTML = `
<div class="perf-header">
<span>PERF</span>
<button class="perf-close" aria-label="Close performance monitor">&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);
}
}
}
+7 -3
View File
@@ -651,14 +651,18 @@ export class PoseRenderer {
this.performanceMetrics.frameCount++;
if (this.performanceMetrics.lastFrameTime > 0) {
const deltaTime = currentTime - this.performanceMetrics.lastFrameTime;
// Clamp to a minimum dt so consecutive frames within the same
// performance.now() tick don't yield Infinity (issue #519 Bug 2).
// 1 ms floor caps the displayed FPS at 1000 — far above any real
// render rate, but finite so the EMA stays well-defined.
const deltaTime = Math.max(currentTime - this.performanceMetrics.lastFrameTime, 1);
const fps = 1000 / deltaTime;
// Update average FPS using exponential moving average
if (this.performanceMetrics.averageFps === 0) {
this.performanceMetrics.averageFps = fps;
} else {
this.performanceMetrics.averageFps =
this.performanceMetrics.averageFps =
(this.performanceMetrics.averageFps * 0.9) + (fps * 0.1);
}
}
+191
View File
@@ -0,0 +1,191 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
export class QuickSettings {
constructor(app) {
this.app = app;
this.button = null;
this.panel = null;
this.isOpen = false;
}
init() {
this.createButton();
this.createPanel();
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'settings-gear';
this.button.setAttribute('aria-label', 'Settings');
this.button.setAttribute('title', 'Quick settings');
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
this.button.addEventListener('click', () => this.toggle());
const headerInfo = document.querySelector('.header-info');
if (headerInfo) headerInfo.appendChild(this.button);
}
createPanel() {
this.panel = document.createElement('div');
this.panel.className = 'quick-settings-panel';
this.panel.setAttribute('role', 'dialog');
this.panel.setAttribute('aria-label', 'Quick settings');
this.panel.innerHTML = `
<div class="qs-header">
<h3>Settings</h3>
<button class="qs-close" aria-label="Close">&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
@@ -0,0 +1,47 @@
// Hash Router - Makes tabs bookmarkable and shareable
// URL format: #dashboard, #demo, #sensing, etc.
export class Router {
constructor(app) {
this.app = app;
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
}
init() {
// Navigate to hash on load
this.onHashChange();
// Listen for hash changes (back/forward navigation)
window.addEventListener('hashchange', () => this.onHashChange());
// Update hash when tab changes
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager) {
tabManager.onTabChange((tabId) => {
this.setHash(tabId);
});
}
}
onHashChange() {
const hash = window.location.hash.replace('#', '').toLowerCase();
if (hash && this.validTabs.includes(hash)) {
const tabManager = this.app?.getComponent?.('tabManager');
if (tabManager && tabManager.getActiveTab() !== hash) {
tabManager.switchToTab(hash);
}
}
}
setHash(tabId) {
// Only update if different to avoid infinite loop
const current = window.location.hash.replace('#', '');
if (current !== tabId) {
history.replaceState(null, '', `#${tabId}`);
}
}
dispose() {
// No explicit cleanup needed - event listeners are on window
}
}
+160
View File
@@ -0,0 +1,160 @@
// Screenshot Tool - Capture current tab view as PNG
// Uses html2canvas-like approach with native Canvas API
import { toastManager } from './toast.js';
export class ScreenshotTool {
constructor() {
this.capturing = false;
}
init() {
document.addEventListener('take-screenshot', () => this.capture());
}
async capture() {
if (this.capturing) return;
this.capturing = true;
const activeTab = document.querySelector('.tab-content.active');
if (!activeTab) {
toastManager.warning('No active tab to capture');
this.capturing = false;
return;
}
try {
// Flash effect
this.flashEffect();
// Try native ClipboardItem API first (modern browsers)
if (typeof ClipboardItem !== 'undefined') {
await this.captureToClipboard(activeTab);
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
} else {
// Fallback: download as file
await this.captureToFile(activeTab);
toastManager.success('Screenshot saved as file', { duration: 3000 });
}
} catch (err) {
console.error('Screenshot failed:', err);
// Fallback: capture visible canvases + basic layout
try {
await this.captureCanvasFallback(activeTab);
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
} catch {
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
}
}
this.capturing = false;
}
async captureToClipboard(element) {
const canvas = await this.renderToCanvas(element);
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
}
async captureToFile(element) {
const canvas = await this.renderToCanvas(element);
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async captureCanvasFallback(element) {
// Find any canvas elements and merge them
const canvases = element.querySelectorAll('canvas');
if (canvases.length === 0) throw new Error('No canvas elements found');
const firstCanvas = canvases[0];
const mergedCanvas = document.createElement('canvas');
mergedCanvas.width = firstCanvas.width || 800;
mergedCanvas.height = firstCanvas.height || 600;
const ctx = mergedCanvas.getContext('2d');
// Dark background
ctx.fillStyle = '#1f2121';
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
canvases.forEach(c => {
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
});
// Add timestamp watermark
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '12px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
const dataUrl = mergedCanvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `ruview-screenshot-${this.timestamp()}.png`;
link.click();
}
async renderToCanvas(element) {
// Simple DOM-to-canvas renderer for basic content
const rect = element.getBoundingClientRect();
const canvas = document.createElement('canvas');
const scale = window.devicePixelRatio || 1;
canvas.width = rect.width * scale;
canvas.height = rect.height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// Render background
const styles = getComputedStyle(element);
ctx.fillStyle = styles.backgroundColor || '#1f2121';
ctx.fillRect(0, 0, rect.width, rect.height);
// Render existing canvases
const canvases = element.querySelectorAll('canvas');
canvases.forEach(c => {
const cRect = c.getBoundingClientRect();
const x = cRect.left - rect.left;
const y = cRect.top - rect.top;
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
});
// Render text content
ctx.fillStyle = styles.color || '#e0e0e0';
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
let textY = 30;
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
const text = el.textContent.trim();
if (text && textY < rect.height - 20) {
const elStyles = getComputedStyle(el);
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
ctx.fillStyle = elStyles.color;
ctx.fillText(text, 20, textY);
textY += parseInt(elStyles.fontSize) + 8;
}
});
// Watermark
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '11px monospace';
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
return canvas;
}
flashEffect() {
const flash = document.createElement('div');
flash.className = 'screenshot-flash';
document.body.appendChild(flash);
flash.addEventListener('animationend', () => flash.remove());
}
timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
dispose() {}
}
+86
View File
@@ -0,0 +1,86 @@
// Theme Toggle - Manual dark/light mode switch with persistence
export class ThemeToggle {
constructor() {
this.button = null;
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
}
init() {
this.createButton();
this.applyTheme(this.currentTheme);
document.addEventListener('toggle-theme', () => this.toggle());
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!this.getSavedTheme()) {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'theme-toggle';
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
this.button.setAttribute('title', 'Toggle theme (T)');
this.updateIcon();
this.button.addEventListener('click', () => this.toggle());
// Insert into header
const headerInfo = document.querySelector('.header-info');
if (headerInfo) {
headerInfo.prepend(this.button);
} else {
const header = document.querySelector('.header');
if (header) header.appendChild(this.button);
}
}
toggle() {
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(this.currentTheme);
this.saveTheme(this.currentTheme);
}
applyTheme(theme) {
this.currentTheme = theme;
document.documentElement.setAttribute('data-color-scheme', theme);
this.updateIcon();
}
updateIcon() {
if (!this.button) return;
const isDark = this.currentTheme === 'dark';
this.button.innerHTML = isDark
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
}
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
getSavedTheme() {
try {
return localStorage.getItem('ruview-theme');
} catch {
return null;
}
}
saveTheme(theme) {
try {
localStorage.setItem('ruview-theme', theme);
} catch {
// localStorage not available
}
}
dispose() {
if (this.button?.parentNode) {
this.button.parentNode.removeChild(this.button);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Enhanced Toast Notification System
// Supports multiple types: success, error, warning, info
// Stacking, auto-dismiss, manual close, progress bar
export class ToastManager {
constructor() {
this.container = null;
this.toasts = [];
this.idCounter = 0;
}
init() {
this.container = document.createElement('div');
this.container.className = 'toast-container';
this.container.setAttribute('role', 'region');
this.container.setAttribute('aria-label', 'Notifications');
this.container.setAttribute('aria-live', 'polite');
document.body.appendChild(this.container);
}
show(message, options = {}) {
const {
type = 'info',
duration = 5000,
closable = true,
icon = null,
action = null
} = options;
const id = ++this.idCounter;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'alert');
toast.dataset.toastId = id;
const iconMap = {
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
};
const displayIcon = icon || iconMap[type] || iconMap.info;
toast.innerHTML = `
<div class="toast-icon">${displayIcon}</div>
<div class="toast-content">
<span class="toast-message">${this.escapeHtml(message)}</span>
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
</div>
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">&times;</button>' : ''}
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
`;
// Bind events
if (closable) {
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
}
if (action?.onClick) {
toast.querySelector('.toast-action')?.addEventListener('click', () => {
action.onClick();
this.dismiss(id);
});
}
this.container.appendChild(toast);
// Trigger enter animation
requestAnimationFrame(() => toast.classList.add('toast-enter'));
// Auto-dismiss
let timeoutId = null;
if (duration > 0) {
const progressBar = toast.querySelector('.toast-progress-bar');
if (progressBar) {
progressBar.style.animationDuration = `${duration}ms`;
progressBar.classList.add('toast-progress-animate');
}
timeoutId = setTimeout(() => this.dismiss(id), duration);
}
// Pause on hover
toast.addEventListener('mouseenter', () => {
if (timeoutId) {
clearTimeout(timeoutId);
const bar = toast.querySelector('.toast-progress-bar');
if (bar) bar.style.animationPlayState = 'paused';
}
});
toast.addEventListener('mouseleave', () => {
if (duration > 0) {
const bar = toast.querySelector('.toast-progress-bar');
if (bar) bar.style.animationPlayState = 'running';
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
}
});
this.toasts.push({ id, toast, timeoutId });
return id;
}
dismiss(id) {
const index = this.toasts.findIndex(t => t.id === id);
if (index === -1) return;
const { toast, timeoutId } = this.toasts[index];
if (timeoutId) clearTimeout(timeoutId);
toast.classList.add('toast-exit');
toast.addEventListener('animationend', () => {
toast.remove();
}, { once: true });
this.toasts.splice(index, 1);
}
success(message, options = {}) {
return this.show(message, { ...options, type: 'success' });
}
error(message, options = {}) {
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
}
warning(message, options = {}) {
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
}
info(message, options = {}) {
return this.show(message, { ...options, type: 'info' });
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
dispose() {
this.toasts.forEach(({ timeoutId }) => {
if (timeoutId) clearTimeout(timeoutId);
});
this.toasts = [];
if (this.container?.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
}
export const toastManager = new ToastManager();

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