Compare commits
62 Commits
v0.8.0
...
v0.6.5-esp32
| Author | SHA1 | Date | |
|---|---|---|---|
| f5e2b5474b | |||
| 281c4cb0ce | |||
| b2e2e6d6fd | |||
| 72bbd256e7 | |||
| 50131b2519 | |||
| 50136c920d | |||
| 3bd70f7910 | |||
| 6f5ac3aa5a | |||
| 1b155ad027 | |||
| fa28318bae | |||
| ec73109d57 | |||
| acbd3ff13c | |||
| 07086c5d9d | |||
| 0310b1fa9a | |||
| 9daa8c3078 | |||
| ffa808ed4b | |||
| 59dbb76757 | |||
| 4ecc053a27 | |||
| 5170b99aca | |||
| c16dc9f80a | |||
| 04ccfcde56 | |||
| 4d45add824 | |||
| 562cb7461f | |||
| fad6828697 | |||
| 807bf0b32a | |||
| 4b602c79dd | |||
| 76321ce4bc | |||
| 1690aea22a | |||
| a80617ee84 | |||
| 75dc302952 | |||
| afc86c6fc4 | |||
| fc654034b3 | |||
| c4653b8bc6 | |||
| d214855228 | |||
| e6710e8988 | |||
| ab9799adc3 | |||
| bdb4484259 | |||
| ba370c7b08 | |||
| 3fdd310f89 | |||
| 98e7eeda42 | |||
| 5615edb24e | |||
| 9cc9419db9 | |||
| d544b8f070 | |||
| d33962eff2 | |||
| e22a24714a | |||
| cee414f3c0 | |||
| f853c74563 | |||
| 8b297dd706 | |||
| 9d4f7820b2 | |||
| b2fe452e74 | |||
| 88da304631 | |||
| 880a3a41d3 | |||
| 68b042faf6 | |||
| 4698f54fa0 | |||
| ea62ec4667 | |||
| 3685d16a49 | |||
| 8a155e07ec | |||
| 540ecb4538 | |||
| 10684972d7 | |||
| 27a6edba8b | |||
| 174e2365f0 | |||
| bf30844835 |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
- name: Extract metadata
|
||||
continue-on-error: true
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release -- --no-default-features --features wasm
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
|
||||
|
||||
- working-directory: dashboard
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
-- --no-default-features --features wasm
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/ruvnet/nvsim-server
|
||||
tags: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: v2
|
||||
file: v2/crates/nvsim-server/Dockerfile
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
|
||||
- name: Run Grype vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@v3
|
||||
uses: anchore/scan-action@v7
|
||||
id: grype-scan
|
||||
with:
|
||||
image: 'wifi-densepose:scan'
|
||||
@@ -343,7 +343,7 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
@@ -68,7 +68,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 +81,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
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -57,7 +57,18 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: v1
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
|
||||
# order is otherwise non-deterministic across CI runs (issue #560
|
||||
# follow-up: 9- and 6-decimal quantization were not enough because
|
||||
# the divergence is from threading order, not SIMD reordering).
|
||||
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
@@ -65,7 +76,13 @@ jobs:
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: v1
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
|
||||
@@ -13,6 +13,9 @@ firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
|
||||
@@ -7,6 +7,42 @@ 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.
|
||||
- **`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
|
||||
@@ -127,6 +163,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
saturation, hyperfine spectroscopy, or pulsed protocols become required.
|
||||
|
||||
### Fixed
|
||||
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
|
||||
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
|
||||
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
|
||||
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
|
||||
Clients would reconnect, immediately lag again, and rapid-cycle every 2–4 s.
|
||||
`Lagged` now continues (drops missed frames, logs debug) rather than breaking.
|
||||
Added 30 s ping keepalive on the sensing handler to prevent proxy idle timeouts.
|
||||
- **Ghost skeletons in live UI with multi-node ESP32 setups** (#420, ADR-082) —
|
||||
`tracker_bridge::tracker_to_person_detections` documented itself as filtering
|
||||
to `is_alive()` tracks but in fact passed every non-Terminated track to the
|
||||
|
||||
@@ -14,9 +14,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-api` | REST API (Axum) |
|
||||
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration management |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
@@ -135,17 +132,14 @@ Crates must be published in dependency order:
|
||||
2. `wifi-densepose-vitals` (no internal deps)
|
||||
3. `wifi-densepose-wifiscan` (no internal deps)
|
||||
4. `wifi-densepose-hardware` (no internal deps)
|
||||
5. `wifi-densepose-config` (no internal deps)
|
||||
6. `wifi-densepose-db` (no internal deps)
|
||||
7. `wifi-densepose-signal` (depends on core)
|
||||
8. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
10. `wifi-densepose-train` (depends on signal, nn)
|
||||
11. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
12. `wifi-densepose-api` (no internal deps)
|
||||
13. `wifi-densepose-wasm` (depends on mat)
|
||||
14. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
15. `wifi-densepose-cli` (depends on mat)
|
||||
5. `wifi-densepose-signal` (depends on core)
|
||||
6. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
8. `wifi-densepose-train` (depends on signal, nn)
|
||||
9. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
10. `wifi-densepose-wasm` (depends on mat)
|
||||
11. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
12. `wifi-densepose-cli` (depends on mat)
|
||||
|
||||
### Validation & Witness Verification (ADR-028)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
**Turn ordinary WiFi into a spacial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
|
||||
### π RuView is a WiFi sensing platform that turns radio signals into spatial intelligence.
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
8c0680d7d285739ea9597715e84959d9c356c87ee3ad35b5f1e69a4ca41151c6
|
||||
667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7
|
||||
@@ -164,18 +164,44 @@ def frame_to_csi_data(frame, signal_meta):
|
||||
)
|
||||
|
||||
|
||||
# Quantization precision for cross-platform hash stability (issue #560).
|
||||
#
|
||||
# The bytes packed below feed SHA-256. Without quantization, the hash diverges
|
||||
# across SIMD backends (Intel AVX2/AVX-512 vs ARM NEON vs different x86 micro-
|
||||
# architectures in the same CI pool) because scipy.fft's pocketfft kernels
|
||||
# reorder vectorized FP operations differently per build. IEEE 754 guarantees
|
||||
# per-operation determinism, not associativity under reordering.
|
||||
#
|
||||
# Empirically: 9 decimals was NOT enough to collapse the divergence — two
|
||||
# back-to-back Ubuntu 24.04 / Python 3.11 / scipy 1.17 CI runs landed on
|
||||
# different Azure VM microarchitectures (likely Skylake vs Cascade Lake)
|
||||
# and produced two different SHA-256s even after np.round(.., 9). The DSP
|
||||
# pipeline (preprocess → biquad bandpass → FFT → PSD → variance accumulation)
|
||||
# amplifies the ~1e-14 raw FFT divergence by several orders of magnitude
|
||||
# downstream — the actual drift at features_to_bytes() input can reach 1e-7
|
||||
# or worse.
|
||||
#
|
||||
# 6 decimals (parts per million) gives ~6 orders of magnitude headroom over
|
||||
# observed pipeline-amplified ULP drift and is still far below any meaningful
|
||||
# signal change (CSI phase precision is ~1e-3 rad; PSD bins differ by orders
|
||||
# of magnitude). Round to this precision, then hash.
|
||||
HASH_QUANTIZATION_DECIMALS = 6
|
||||
|
||||
|
||||
def features_to_bytes(features):
|
||||
"""Convert CSIFeatures to a deterministic byte representation.
|
||||
|
||||
We serialize each numpy array to bytes in a canonical order
|
||||
using little-endian float64 representation. This ensures the
|
||||
hash is platform-independent for IEEE 754 compliant systems.
|
||||
Each feature array is quantized to ``HASH_QUANTIZATION_DECIMALS`` decimal
|
||||
places before being packed as little-endian float64. The quantization is
|
||||
what makes the resulting SHA-256 hash actually platform-independent — the
|
||||
raw float values diverge at ULP precision across scipy.fft SIMD backends
|
||||
(issue #560), even though all platforms compute the "correct" answer.
|
||||
|
||||
Args:
|
||||
features: CSIFeatures instance.
|
||||
|
||||
Returns:
|
||||
bytes: Canonical byte representation.
|
||||
bytes: Canonical, quantized byte representation.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
@@ -189,6 +215,10 @@ def features_to_bytes(features):
|
||||
features.power_spectral_density,
|
||||
]:
|
||||
flat = np.asarray(array, dtype=np.float64).ravel()
|
||||
# Quantize before packing so SIMD-level FP reordering across
|
||||
# Intel AVX vs Apple Silicon NEON pocketfft kernels does not
|
||||
# leak into the SHA-256 input.
|
||||
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
|
||||
# Pack as little-endian double (8 bytes each)
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
@@ -155,16 +156,17 @@ class UserManager:
|
||||
return False
|
||||
|
||||
|
||||
class AuthenticationMiddleware:
|
||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""Authentication middleware for FastAPI."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.token_manager = TokenManager(settings)
|
||||
self.user_manager = UserManager()
|
||||
self.enabled = settings.enable_authentication
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable) -> Response:
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through authentication middleware."""
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from src.config.settings import Settings
|
||||
@@ -299,15 +300,16 @@ class RateLimiter:
|
||||
}
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiting middleware for FastAPI."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.rate_limiter = RateLimiter(settings)
|
||||
self.enabled = settings.enable_rate_limiting
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable) -> Response:
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through rate limiting middleware."""
|
||||
if not self.enabled:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -220,7 +220,11 @@ class PoseService:
|
||||
# Apply phase sanitization if we have phase data
|
||||
if hasattr(detection_result.features, 'phase_difference'):
|
||||
phase_data = detection_result.features.phase_difference
|
||||
sanitized_phase = self.phase_sanitizer.sanitize(phase_data)
|
||||
# PhaseSanitizer's full-pipeline method is sanitize_phase,
|
||||
# not sanitize (issue #612). The shorter name was an
|
||||
# AttributeError waiting to fire on any code path that
|
||||
# reaches this branch.
|
||||
sanitized_phase = self.phase_sanitizer.sanitize_phase(phase_data)
|
||||
# Combine amplitude and phase data
|
||||
return np.concatenate([amplitude_data, sanitized_phase])
|
||||
|
||||
|
||||
@@ -9,7 +9,18 @@ services:
|
||||
ports:
|
||||
- "3000:3000" # REST API
|
||||
- "3001:3001" # WebSocket
|
||||
- "5005:5005/udp" # ESP32 UDP
|
||||
# ESP32 UDP. On Linux/macOS this works with multiple ESP32 nodes out of
|
||||
# the box. On Docker Desktop for Windows, multi-source UDP is collapsed
|
||||
# to one source IP at the WSL/Hyper-V boundary, so all-but-one node's
|
||||
# frames are silently dropped (issue #374, #386).
|
||||
#
|
||||
# Windows workaround: change this to "5006:5005/udp" and run the host
|
||||
# relay so every datagram arrives from the same loopback source:
|
||||
#
|
||||
# python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
#
|
||||
# See docs/TROUBLESHOOTING.md §9 for details.
|
||||
- "5005:5005/udp"
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
|
||||
@@ -109,3 +109,75 @@ ssh thyhack@100.90.238.87
|
||||
**Symptom:** Plugging into the right USB-C port (when facing the board with USB-C toward you) shows no serial device on the host.
|
||||
|
||||
**Fix:** Use the left USB-C port. On most ESP32-S3-DevKitC boards, the left port is the USB-to-UART bridge (CP2102/CH340) used for flashing and serial monitor. The right port is the native USB (USB-JTAG) which requires different drivers and isn't used by the RuView firmware.
|
||||
|
||||
---
|
||||
|
||||
## 9. Docker Desktop on Windows drops UDP from multiple ESP32 nodes
|
||||
|
||||
**Symptom:** Two or more ESP32 nodes are flashed, provisioned, and visibly transmit on the network — `tcpdump`/Wireshark on the Windows host shows datagrams from every node — but inside the Docker container only one source IP arrives. `/api/v1/sensing/latest` shows a single node and the live UI freezes or only tracks one body. Reported in #374 (4-node bench) and reproduced in #386 (6-node demo, RuView v0.7.0).
|
||||
|
||||
**Root cause:** Docker Desktop on Windows runs the engine inside a WSL2 / Hyper-V VM. Inbound UDP from the host LAN is forwarded through `vpnkit` / `vEthernet` and the multi-source-IP datagrams are demultiplexed onto a single virtual socket. The first source-IP "wins"; subsequent unique sources are silently dropped at the VM boundary. This is a Docker Desktop limitation, not a sensing-server bug — `host.docker.internal` and `--network host` do not help (host networking is not implemented for the Linux engine on Windows).
|
||||
|
||||
**Fix:** Run the bundled UDP relay on the host so every forwarded datagram arrives from the same loopback source IP, which Docker passes through unchanged.
|
||||
|
||||
```powershell
|
||||
# 1. Start the relay (PowerShell or any terminal)
|
||||
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
|
||||
# 2. Edit docker/docker-compose.yml — change the ESP32 UDP mapping from
|
||||
# - "5005:5005/udp"
|
||||
# to
|
||||
# - "5006:5005/udp"
|
||||
|
||||
# 3. Bring the stack up
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
ESP32 nodes still target the host on `--target-ip <host>:5005` — no firmware re-provisioning is needed. The relay is `scripts/udp-relay.py` (stdlib only, no extra deps). Verify with `--verbose` that each node's source IP appears at least once before forwarding stabilises on a single ephemeral relay port.
|
||||
|
||||
**Prevention:** Linux and macOS hosts are unaffected; the relay only needs to run on Docker Desktop for Windows. If Docker Desktop ships per-source UDP forwarding (tracked at [docker/for-win#1144](https://github.com/docker/for-win/issues/1144) and related), this workaround can be retired.
|
||||
|
||||
**Prior art:** PR #413 (`txhno`) proposed a docs-only writeup of the same workaround; this entry supersedes it.
|
||||
|
||||
---
|
||||
|
||||
## 10. `404` on the visualization page when running sensing-server
|
||||
|
||||
**Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188.
|
||||
|
||||
**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404.
|
||||
|
||||
**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`).
|
||||
|
||||
```bash
|
||||
# Option A — absolute path (recommended for production)
|
||||
sensing-server --source esp32 --udp-port 5005 --http-port 3000 \
|
||||
--ws-port 3001 --ui-path /absolute/path/to/ui
|
||||
|
||||
# Option B — run from the crate dir (works for local dev / cargo run)
|
||||
cd v2/crates/wifi-densepose-sensing-server
|
||||
cargo run -- --source esp32
|
||||
|
||||
# Option C — Docker (no path config needed)
|
||||
docker compose -f docker/docker-compose.yml up sensing-server
|
||||
```
|
||||
|
||||
**Prevention:** Track future work in #188 to fall back to a path resolved relative to the executable when the cwd-relative path doesn't exist, so the binary works regardless of where it's launched.
|
||||
|
||||
---
|
||||
|
||||
## 11. Boot loop on `--edge-tier 1` or `--edge-tier 2`
|
||||
|
||||
**Symptom:** ESP32-S3 boots normally with `--edge-tier 0`, but flashing the same firmware with `--edge-tier 1` or `2` produces a boot loop. Serial output reaches `cpu_start` and `heap_init`, then resets repeatedly. Reported in #438 against firmware `v0.4.3.1-esp32-3-g66e2fa083-dir`.
|
||||
|
||||
**Root cause:** Edge tiers 1 and 2 enable the on-device DSP pipeline on Core 1. In the affected build, the `edge_dsp` task ran a tight per-frame loop without yielding, so the FreeRTOS task watchdog tripped on Core 1 and panicked. Tier 0 is passthrough only and doesn't activate the pipeline, so the watchdog never fires there.
|
||||
|
||||
**Fix:** Flash the [v0.4.3.1-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) release or later — the DSP task yield fixes have shipped on `main` since the build in the report.
|
||||
|
||||
```bash
|
||||
# Verify what version you're on (look for "App version" in serial output on boot)
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
# Expect: "App version: v0.4.3.1-esp32" or higher
|
||||
```
|
||||
|
||||
If the boot loop persists on a release build, capture a full serial trace including the watchdog backtrace and reopen #438 with the new build hash.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# ADR-098: Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Rejected (with crate-level carve-outs for future evaluation) |
|
||||
| **Date** | 2026-05-13 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **midstream-in-RuView** |
|
||||
| **Relates to** | ADR-095 (rvCSI platform), ADR-096 (rvCSI crate topology), ADR-097 (adopt rvCSI as RuView's CSI runtime), ADR-012 (ESP32 CSI mesh), ADR-029 (RuvSense multistatic / TDM), ADR-031 (RuView sensing-first RF mode), ADR-043 (sensing-server UI API completion) |
|
||||
| **midstream repo** | [github.com/ruvnet/midstream](https://github.com/ruvnet/midstream) — vendored at `vendor/midstream`, currently pinned at [`30fe5eb`](https://github.com/ruvnet/midstream/commit/30fe5eb7a1f1494aa1ad00d54160088a565ec766) |
|
||||
| **Outcome** | Do **not** adopt as a system component. Two of midstream's six workspace crates (`temporal-compare`, `nanosecond-scheduler`) are plausible future-use building blocks; the rest do not fit. `vendor/midstream` is retained as a reference-only submodule. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`vendor/midstream` is a git submodule of RuView (`.gitmodules:1-4`) but, like `vendor/rvcsi` was before ADR-097, it is **vendored but not consumed**: no `v2/crates/*/Cargo.toml` depends on a `midstreamer-*` crate, no Rust source contains `use midstreamer_…`, and the ESP32 firmware and TypeScript dashboard have no midstream imports.
|
||||
|
||||
This ADR settles the standing question of *whether RuView should consume midstream at all*, and if so, where. The user-facing prompt enumerated four candidate seams to evaluate:
|
||||
|
||||
1. Streaming / pub-sub for the WebSocket fan-out (today: `tokio::sync::broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`).
|
||||
2. Stream processing for the CSI → DSP → event pipeline (today: synchronous `EventPipeline` at `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs`, freshly adopted via ADR-097).
|
||||
3. Multi-source merging / TDM coordination for the ESP32 mesh (ADR-029, ADR-073).
|
||||
4. Backpressure / flow control between the UDP receiver and downstream consumers (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638` `udp_receiver_task`; firmware-side `stream_sender` ENOMEM backoff at `firmware/esp32-csi-node/main/csi_collector.c:223-228`).
|
||||
|
||||
To evaluate each, we read midstream's workspace `Cargo.toml` (`vendor/midstream/Cargo.toml:1-99`), the `README.md` and `BENCHMARKS_SUMMARY.md`, and every crate's `lib.rs`:
|
||||
|
||||
| Crate | File | LOC | Purpose (from header doc) |
|
||||
|---|---|---:|---|
|
||||
| `midstreamer-temporal-compare` | `vendor/midstream/crates/temporal-compare/src/lib.rs:1-697` | 697 | DTW, LCS, Levenshtein, generic pattern matching on `Sequence<T>` of `TemporalElement<T>` |
|
||||
| `midstreamer-scheduler` | `vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:1-406` | 406 | Priority + deadline-aware task scheduler (RM, EDF, LLF) for low-latency real-time tasks |
|
||||
| `midstreamer-attractor` | `vendor/midstream/crates/temporal-attractor-studio/src/lib.rs:1-482` | 482 | Phase-space reconstruction, Lyapunov exponents, attractor classification |
|
||||
| `midstreamer-neural-solver` | `vendor/midstream/crates/temporal-neural-solver/src/lib.rs:1-509` | 509 | LTL / CTL / MTL temporal-logic verification with neural reasoning |
|
||||
| `midstreamer-strange-loop` | `vendor/midstream/crates/strange-loop/src/lib.rs:1-496` | 496 | Multi-level meta-learning, self-referential systems |
|
||||
| `midstreamer-quic` | `vendor/midstream/crates/quic-multistream/src/lib.rs:1-255`, `native.rs:1-303`, `wasm.rs:1-307` | 865 | Thin wrapper over `quinn` (native) and `WebTransport` (WASM); generic QUIC streams |
|
||||
|
||||
Plus a TypeScript layer (`vendor/midstream/npm/`, `vendor/midstream/npm-wasm/`) whose product is "real-time LLM streaming" — OpenAI Realtime API client, RTMP / WebRTC / HLS for video, an in-console dashboard, a Whisper transcription scaffold, an MCP server for LLM agents.
|
||||
|
||||
The top-level identity is unambiguous: `Cargo.toml:16` describes the package as **`"Real-time LLM streaming with inflight analysis"`**, and the README (`vendor/midstream/README.md:45-80`) frames midstream as a platform that "analyzes [LLM] responses **as they stream in real-time** — enabling instant insights, pattern detection, and intelligent decision-making" — i.e. the streaming domain is **LLM tokens and dashboard telemetry**, not RF signals. A search for any of `csi`, `wifi`, `sensing`, or `sensor` across `vendor/midstream/crates/*/src/*.rs` returns zero hits.
|
||||
|
||||
This shapes the conclusion: midstream's *abstractions* (DTW pattern matching, attractor analysis, LTL verification, meta-learning) were chosen for a fundamentally different problem domain than CSI, and its *transport* (QUIC) is a thin `quinn` wrapper rather than a sensing-aware backplane. The candidate seams enumerated above are either already filled by simpler primitives in RuView, or filled better by rvCSI under ADR-097.
|
||||
|
||||
### 1.1 What this ADR is *not*
|
||||
|
||||
- Not a judgment on midstream's quality. It has 139 passing tests and clean Rust; it is well-engineered for its target domain.
|
||||
- Not a decision to drop `vendor/midstream`. The submodule pin is cheap to keep, and the carve-outs in §3 may justify revisiting it.
|
||||
- Not a position on the *standalone* midstream product (LLM streaming, OpenAI Realtime, dashboards). That product is unaffected by this ADR.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
**Reject midstream as a system component of RuView.** The four candidate seams are either filled (well) by existing RuView primitives, or are filled by rvCSI's freshly-adopted `EventPipeline` and `RfMemoryStore`. The eight decisions below are the architectural contract.
|
||||
|
||||
### D1 — Streaming / pub-sub for the WebSocket fan-out: no change
|
||||
|
||||
RuView's sensing-server currently fans out updates to WebSocket clients via `tokio::sync::broadcast::channel::<String>(256)` (`v2/crates/wifi-densepose-sensing-server/src/main.rs:4769`). midstream offers no equivalent in-process broadcast primitive — its TypeScript dashboard fan-out is HTTP-server based (`vendor/midstream/npm/src/dashboard.ts`), and its Rust `midstreamer-quic` crate is a generic point-to-point QUIC wrapper (`vendor/midstream/crates/quic-multistream/src/native.rs:31-69`), not a pub-sub bus.
|
||||
|
||||
Tokio's `broadcast` channel is the standard Rust idiom for this pattern, costs effectively nothing per subscriber, integrates with the rest of the Axum + Tokio stack already in use (`v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47`), and is what `rvcsi-runtime` itself uses for event distribution (`vendor/rvcsi/crates/rvcsi-runtime/src/lib.rs`). **Keep `tokio::sync::broadcast`.**
|
||||
*Consequences:* zero migration; zero new dependency surface; the WebSocket handlers at `main.rs:1989,2030` continue to work unchanged.
|
||||
|
||||
### D2 — CSI → DSP → event pipeline: stay on rvCSI's `EventPipeline`
|
||||
|
||||
ADR-097 D2 just adopted `rvcsi-runtime::CaptureRuntime` + `rvcsi_events::EventPipeline` as the CSI ingestion / DSP / event-extraction path. `EventPipeline` is **deterministic, synchronous, single-frame-at-a-time** (`vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`: *"Feed it frames with `EventPipeline::process_frame` and drain the tail with `EventPipeline::flush`"*) — and that determinism is load-bearing for ADR-095 D9 (replayability) and ADR-095 D13 (quality scoring against learned baselines).
|
||||
|
||||
midstream's stream-processing primitives are designed for the opposite shape: `temporal-attractor-studio` (phase-space reconstruction, Lyapunov exponents) and `temporal-neural-solver` (LTL formula verification) operate on **trajectories** of multi-dimensional states over hundreds-to-thousands of samples (`vendor/midstream/README.md:528-531`: *"Attractor detection: <5ms for 1000-point series"*) — that is closer to RuView's existing RuvSense modules (`v2/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs`, `intention.rs`) than to anything the runtime DSP layer needs.
|
||||
|
||||
Replacing rvCSI's event detectors with midstream constructs would (a) break determinism, (b) re-introduce a parallel CSI-processing implementation — exactly the duplication ADR-097 was opened to remove — and (c) force RuView to invent a `Sequence<T: temporal-compare::TemporalElement>` shim around `CsiFrame` for marginal benefit. **Stay on `rvcsi-events::EventPipeline`.**
|
||||
*Consequences:* the determinism / replay guarantees of ADR-095 D9 and ADR-097 D6 remain intact; the work to land `rvcsi-adapter-esp32` (ADR-097 D4, P3) is not duplicated.
|
||||
|
||||
### D3 — TDM / multi-source merging: stay on the existing aggregator
|
||||
|
||||
The ESP32 mesh's multi-source merging is in `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220` — a `UdpSocket`-backed aggregator (`mod.rs:74,85`) that receives parsed `CsiFrame`s from N nodes and forwards them on a `SyncSender<CsiFrame>` to the consumer. The TDM coordination (slot assignment, channel hopping, dwell time) lives in firmware (`firmware/esp32-csi-node/main/`) and is governed by ADR-029 and ADR-073. midstream offers nothing for either side: it has no UDP merger, no slot scheduler, and no firmware-side primitives.
|
||||
|
||||
`midstreamer-scheduler` is conceptually adjacent — it does priority + deadline-aware scheduling (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`: `RateMonotonic`, `EarliestDeadlineFirst`, `LeastLaxityFirst`, `FixedPriority`) — but its target is **in-process tokio tasks on a 4-thread executor** (`vendor/midstream/README.md:466-477`: *"4 worker threads"*, *"<50 ns scheduling latency"*), not the cross-device, wall-clock-anchored TDM that RuvSense needs. **Keep the existing `wifi-densepose-hardware` aggregator and firmware-side TDM.**
|
||||
*Consequences:* ADR-029 stays as-is; the work to migrate the parser to `rvcsi-adapter-esp32` (ADR-097 D4) is unaffected.
|
||||
|
||||
### D4 — UDP receiver backpressure / flow control: existing solutions are correct at each end
|
||||
|
||||
There are two distinct backpressure problems in RuView, and neither benefits from midstream:
|
||||
|
||||
- **Firmware side (`firmware/esp32-csi-node/main/csi_collector.c:64,223-228`):** lwIP pbuf exhaustion produces `ENOMEM` when the ESP32 tries to UDP-send faster than the network drains. The fix in code is a rate-limit on `stream_sender_send` *inside the CSI callback*. This is a C-level firmware concern with no Rust analogue — midstream cannot run on the ESP32.
|
||||
- **Host side (`v2/crates/wifi-densepose-sensing-server/src/main.rs:3638-3640`, `4769`):** `udp_receiver_task` reads from `UdpSocket` and pushes onto `broadcast::channel::<String>(256)`. The bounded channel is itself the backpressure mechanism: lagged subscribers see `RecvError::Lagged`, the buffer wraps, no producer ever blocks. The 256-slot capacity is sized to one second of frame envelopes at the target rate; the per-second packet-yield collapse symptom (`adaptive_controller_decide.c:26-28`) is detected and surfaced by ADR-039 / ADR-081's `pkt_yield_per_sec` accessor, not by transport-layer flow control.
|
||||
|
||||
midstream's `quic-multistream` provides per-stream prioritization (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`), which is a useful flow-control primitive *for QUIC* but not for the UDP-CSI / WS-fan-out topology RuView actually uses. Adopting QUIC end-to-end would mean (a) replacing the ESP32's UDP sender — which would need a QUIC stack on a memory-constrained Xtensa MCU and is out of scope for this project — or (b) terminating QUIC at the aggregator only, which provides no benefit the current bounded `broadcast` channel doesn't. **Keep the existing two-tier backpressure.**
|
||||
*Consequences:* the ENOMEM rate-limit at `csi_collector.c:223-228` and the bounded `broadcast::channel::<String>(256)` at `main.rs:4769` continue to be the load-bearing primitives.
|
||||
|
||||
### D5 — Carve-out: `temporal-compare` as a future RuvSense-side building block
|
||||
|
||||
`midstreamer-temporal-compare` (`vendor/midstream/crates/temporal-compare/src/lib.rs:1-697`) is a clean DTW / LCS / Levenshtein implementation with an LRU cache. RuView's gesture detector at `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs` already does DTW template matching, and the longitudinal analysis at `ruvsense/longitudinal.rs` could plausibly benefit from cached pattern matching. If we ever need a *separate* DTW implementation that is decoupled from RuvSense's internal types, `temporal-compare` is a reasonable starting point — but only if and when that need arises.
|
||||
|
||||
We **do not adopt it today** because RuvSense's gesture matcher already exists, works, and uses RuView-native types, and pulling in `dashmap`, `lru`, and a generic `TemporalElement<T>` abstraction would be net-negative right now. **Tracked as a future evaluation, not a decision.**
|
||||
*Consequences:* zero today; one named option for a future ADR if a "second" DTW pattern appears.
|
||||
|
||||
### D6 — Carve-out: `nanosecond-scheduler` for *host-side* edge tier scheduling (future)
|
||||
|
||||
If ADR-039's edge-intelligence tier scheduling ever moves from the ESP32 onto a host-side coordinator (e.g. a Raspberry Pi running the cluster aggregator), `nanosecond-scheduler`'s deadline-aware policies (`vendor/midstream/crates/nanosecond-scheduler/src/lib.rs:53-63`) could plausibly host that scheduler. Today the scheduling is firmware-side and the C-level RTOS handles it; there is nothing to schedule in Rust at the granularity midstream offers.
|
||||
|
||||
Again: **not a current decision, just an option kept open.**
|
||||
*Consequences:* zero today.
|
||||
|
||||
### D7 — Submodule disposition: keep `vendor/midstream`
|
||||
|
||||
`vendor/midstream` is one git submodule pin; the build does not depend on it; it does not slow down `cargo build --workspace`; and the carve-outs in D5/D6 leave the door open. Removing the submodule would also remove the reference material that justified the carve-outs.
|
||||
|
||||
**Keep the submodule, no per-release pin advancement.** Unlike `vendor/rvcsi` (whose pin is bumped per RuView release under ADR-097 D7), `vendor/midstream` has no in-build consumer to validate against. If D5 or D6 ever activates, *that* ADR will start the per-release pin process. Until then the pin can drift freely.
|
||||
*Consequences:* one line of `.gitmodules` (`.gitmodules:1-4`) stays; `git submodule update --init` remains a no-op for normal RuView development.
|
||||
|
||||
### D8 — Documentation: cross-reference, don't import
|
||||
|
||||
The ADR index (`docs/adr/README.md`) gets ADR-098 added under "Architecture and infrastructure". No other docs are updated. The README on the RuView side is untouched; midstream is not part of the RuView platform story.
|
||||
*Consequences:* one row added to the ADR index; no churn elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why not adopt (the rejection record)
|
||||
|
||||
For institutional memory, the table below records what each midstream crate *would* solve and the alternative RuView already uses. This is the answer to "but we vendored midstream — what is it for?"
|
||||
|
||||
| midstream crate | Plausible RuView seam | Already filled by | Verdict |
|
||||
|---|---|---|---|
|
||||
| `midstreamer-temporal-compare` (DTW, LCS, Levenshtein) | Gesture template matching (`ruvsense/gesture.rs`); longitudinal biomechanics drift | RuvSense's existing DTW gesture matcher | Carve-out only (D5) — not adopted today |
|
||||
| `midstreamer-scheduler` (nanosecond priority + deadline) | ESP32 edge-tier scheduling (ADR-039); RuvSense TDM (ADR-029) | Firmware-side RTOS (ESP32); ADR-029's wall-clock-anchored TDM | Carve-out only (D6) — wrong scope today |
|
||||
| `midstreamer-attractor` (Lyapunov, phase-space) | RF-field stability detection in `ruvsense/field_model.rs`, `longitudinal.rs` | Welford stats + biomechanics drift (longitudinal.rs); SVD eigenstructure (field_model.rs) | Not adopted — RuvSense's approach is calibrated to RF signal scale and the project's existing dataset, not generic dynamical-systems theory |
|
||||
| `midstreamer-neural-solver` (LTL / CTL / MTL verification) | Adversarial signal detection (`ruvsense/adversarial.rs`); coherence-gate decisions | Multi-link consistency checks (adversarial.rs); `coherence_gate.rs` state machine | Not adopted — RuView's adversarial detector is not a formal-verification problem; it's a multi-link physical-consistency check |
|
||||
| `midstreamer-strange-loop` (meta-learning, self-modification) | None in RuView's scope | RuView is not a self-modifying learner; AETHER (ADR-024) is contrastive embedding, not meta-learning | Not adopted — out of scope |
|
||||
| `midstreamer-quic` (QUIC native + WASM) | Sensing-server → external client transport (alternative to WS) | `tokio::sync::broadcast` + Axum WebSocket + UDP (`main.rs:36-47, 4769, 1989, 2030, 3638`) | Not adopted — see D1, D4 |
|
||||
|
||||
The shape of the rejection is consistent: **midstream's abstractions are LLM-token / dashboard-telemetry shaped, RuView's pipeline is RF-frame / event-detector shaped.** Where the two share vocabulary ("streaming", "temporal", "real-time"), the implementations diverge sharply — and the case-by-case analysis above shows that the closer one looks at each seam, the worse the fit gets.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Zero net change to RuView's build, runtime, or surface area; ADR-097's phased rvCSI adoption proceeds unaffected.
|
||||
- The decision space around midstream is now bounded and documented; future contributors and AI agents see "ADR-098 already evaluated this; here is why not" before re-opening the question.
|
||||
- The two crate-level carve-outs (D5, D6) are explicit, so if the relevant seams appear later, the evaluation can pick up from this ADR rather than start over.
|
||||
- `vendor/midstream` (the submodule) remains as reference material, but is correctly marked as not part of the build path.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One more vendored repo with no in-build consumer — a small but non-zero cognitive load (mitigated by D7's explicit "do not bump the pin").
|
||||
- If midstream's published crates evolve materially (e.g. a CSI-aware feature lands), the reasoning in §3 needs revisiting; this is the standard "rejected ADRs go stale" risk and applies to every Rejected ADR in the index.
|
||||
|
||||
**Risks**
|
||||
|
||||
- The most plausible failure mode of this ADR is *not* "we should have adopted midstream"; it is "we re-open the question in six months without re-reading this ADR." Mitigated by indexing ADR-098 in `docs/adr/README.md` and by the per-crate table in §3 being precise enough to short-circuit the next evaluator.
|
||||
|
||||
---
|
||||
|
||||
## 5. Alternatives considered
|
||||
|
||||
| Alternative | Why not |
|
||||
|---|---|
|
||||
| **Adopt midstream wholesale as RuView's streaming backbone** | Would force the CSI pipeline into the `Sequence<TemporalElement>` shape (`vendor/midstream/crates/temporal-compare/src/lib.rs:42-70`) and the `quic-multistream` transport (`vendor/midstream/crates/quic-multistream/src/native.rs:1-303`) — both are designed for LLM tokens / arbitrary streams, not validated RF frames with quality scoring. Conflicts directly with ADR-095 D5 (one `CsiFrame` schema), D6 (validate before crossing boundaries), and D9 (deterministic replay). |
|
||||
| **Replace `tokio::sync::broadcast` with midstream's QUIC fan-out** | Solves no observed problem. `broadcast::channel::<String>(256)` at `v2/crates/wifi-densepose-sensing-server/src/main.rs:4769` handles N WebSocket subscribers at zero per-subscriber cost; the lagged-subscriber semantics (`RecvError::Lagged`) are exactly what an event-feed wants. QUIC adds TLS + congestion control + per-stream priority — useful for *external* clients across a network, but the sensing-server's clients connect over WS on the same host or LAN. |
|
||||
| **Replace `EventPipeline` with `temporal-attractor-studio` / `temporal-neural-solver`** | `EventPipeline` is deterministic by contract (`vendor/rvcsi/crates/rvcsi-events/src/lib.rs:20`) and ADR-097 just made it RuView's event source of truth. Attractor analysis and LTL verification operate on entirely different abstractions; using them as event detectors would re-invent rvCSI's pipeline in a less-determined way. |
|
||||
| **Adopt `midstreamer-temporal-compare` for gesture detection now** | RuvSense already has a working DTW gesture matcher tuned to CSI signal scale. Swapping it for a generic `TemporalElement<T>` matcher buys cleanliness but costs a re-tune and a new dep tree (`dashmap`, `lru`). Tracked as D5 for if/when a *second* DTW use case shows up. |
|
||||
| **Adopt `midstreamer-scheduler` for the cluster-Pi aggregator** | The cluster aggregator does not currently exist as a real-time scheduler; ADR-039's tier scheduling is firmware-side. Until the host-side schedule appears, importing a deadline-aware scheduler is solution-looking-for-a-problem. Tracked as D6. |
|
||||
| **Drop the `vendor/midstream` submodule entirely** | Cheap to keep, useful as the reference material this ADR cites. D7 keeps it on the explicit understanding that the pin is not advanced. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions / re-evaluation triggers
|
||||
|
||||
This ADR is `Rejected` today on the strength of the §1.1 / §3 analysis. The following events would justify re-opening it:
|
||||
|
||||
1. **A second DTW / LCS / Levenshtein use case appears in RuView** (e.g. a CLI-side replay diff, a regression test fixture that needs sequence alignment, a TUI for pattern playback). Then re-evaluate `midstreamer-temporal-compare` per D5.
|
||||
2. **A host-side real-time scheduler enters RuView's scope** (e.g. the cluster-Pi aggregator becomes responsible for slot timing instead of the ESP32 firmware). Then re-evaluate `midstreamer-scheduler` per D6.
|
||||
3. **midstream ships a CSI-aware adapter or RF-scale `Sequence<T>` extension** — i.e. midstream's own scope grows to include sensing primitives. As of the pinned commit (`30fe5eb`), this has not happened (zero matches for `csi|wifi|sensing|sensor` in `vendor/midstream/crates/*/src/*.rs`).
|
||||
4. **RuView gains a QUIC-to-external-client requirement** that the WS fan-out cannot service (e.g. a mobile client over a lossy link that benefits from QUIC's stream priority + 0-RTT). Then re-evaluate `midstreamer-quic` per D1 / D4.
|
||||
|
||||
If none of these triggers fire, this ADR stays Rejected and the carve-outs (D5, D6) remain optional.
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- [ADR-095 — rvCSI Edge RF Sensing Platform](ADR-095-rvcsi-edge-rf-sensing-platform.md) — sets the single-`CsiFrame` schema, deterministic replay, and quality-scoring constraints that midstream's abstractions conflict with.
|
||||
- [ADR-096 — rvCSI Crate Topology, the napi-c Shim, the napi-rs Surface](ADR-096-rvcsi-ffi-crate-layout.md) — the crate topology that rvCSI fills the candidate seams with.
|
||||
- [ADR-097 — Adopt rvCSI as RuView's primary CSI runtime](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) — phased adoption (P1-P5) that this ADR explicitly does not duplicate.
|
||||
- [ADR-012 — ESP32 CSI Sensor Mesh](ADR-012-esp32-csi-sensor-mesh.md) — the multi-source TDM context for D3.
|
||||
- [ADR-029 — RuvSense Multistatic Sensing Mode](ADR-029-ruvsense-multistatic-sensing-mode.md) — the wall-clock-anchored TDM that `midstreamer-scheduler` is the wrong shape for.
|
||||
- [ADR-039 — ESP32 Edge Intelligence Pipeline](ADR-039-esp32-edge-intelligence.md) — the firmware-side tier scheduling that would need to move host-side before D6 activates.
|
||||
- [`github.com/ruvnet/midstream`](https://github.com/ruvnet/midstream) — 5 published crates on crates.io (`temporal-compare`, `nanosecond-scheduler`, `temporal-attractor-studio`, `temporal-neural-solver`, `strange-loop`) + 1 local crate (`quic-multistream`); 139 passing tests.
|
||||
- `vendor/midstream` (submodule) — pinned at `30fe5eb` (`vendor/midstream/Cargo.toml:16` describes the package as *"Real-time LLM streaming with inflight analysis"*).
|
||||
- RuView code paths cited in §1: `v2/crates/wifi-densepose-sensing-server/src/main.rs:36,47,1989,2030,3638-3640,4769`; `v2/crates/wifi-densepose-hardware/src/aggregator/mod.rs:74-220`; `firmware/esp32-csi-node/main/csi_collector.c:64,223-228`; `firmware/esp32-csi-node/main/adaptive_controller_decide.c:26-28`.
|
||||
- RuvSense code paths cited in §3: `v2/crates/wifi-densepose-signal/src/ruvsense/gesture.rs`, `longitudinal.rs`, `field_model.rs`, `adversarial.rs`, `coherence_gate.rs`.
|
||||
- rvCSI code paths cited in §2: `vendor/rvcsi/crates/rvcsi-events/src/lib.rs:1-37`, `vendor/rvcsi/crates/rvcsi-events/src/pipeline.rs:1-5`.
|
||||
@@ -108,6 +108,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) | rvCSI — Edge RF Sensing Runtime Platform | Proposed |
|
||||
| [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) | rvCSI — Crate Topology, the napi-c Shim, and the napi-rs Node Surface | Proposed |
|
||||
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
|
||||
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
|
||||
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,466 @@
|
||||
# Pi 5 + Hailo Cluster: Building a Cognitive RF Observer with rvcsi
|
||||
|
||||
A field-tested tutorial for turning a 4-node Raspberry Pi 5 cluster into a
|
||||
multistatic Wi-Fi CSI cognitive RF observer that learns room states,
|
||||
predicts the next one, and flags anomalies — entirely from radio.
|
||||
|
||||
**Estimated time:** 4–6 hours (hardware 1h, firmware 1h, software 1h, calibration 1–3h)
|
||||
|
||||
**What you will build:** A self-learning 4-node cluster that captures Wi-Fi
|
||||
Channel State Information from a stable RF beacon, encodes each frame into a
|
||||
128-dimensional fingerprint on an on-device Hailo-8 NPU, clusters those
|
||||
fingerprints into discrete room states with stable IDs across runs, models
|
||||
state transitions with a 2nd-order Markov chain (with measurable predictive
|
||||
skill above chance), and persists everything to a queryable brain corpus on
|
||||
a workstation. The whole thing runs over Tailscale and is operated through
|
||||
a single CLI with **34 subcommands**.
|
||||
|
||||
**Who this is for:** RF engineers, smart-home hackers, security researchers,
|
||||
and ML/embedded folks comfortable with Linux + systemd. No specific signal-
|
||||
processing background required — but you do need patience for hardware
|
||||
quirks (nexmon_csi cross-compile is a known dead end; see step 3).
|
||||
|
||||
> **The TL;DR**: 4× Pi 5 + 2× Hailo-8 → CSI → 128-d embeddings → cosine
|
||||
> k-means with warm-start → 2nd-order Markov → SQLite brain → 34-subcommand
|
||||
> operator CLI. Production-grade signal: 39% top-1 ceiling on next-state
|
||||
> prediction (16× chance baseline), continuous fleet/drift/anomaly
|
||||
> monitoring, and a 12-category time-series corpus.
|
||||
|
||||
> **About the name "rvcsi" in this tutorial.** When this tutorial was
|
||||
> first written, the cluster's per-Pi capture services were named with
|
||||
> an `rvcsi` prefix (`cog-rvcsi-stream`, `cog-rvcsi-correlator`) as
|
||||
> branding only — the actual code was Python and didn't depend on the
|
||||
> upstream [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) Rust
|
||||
> runtime. **As of 2026-05-13**, the v0-appliance project has accepted
|
||||
> [ADR-207](https://github.com/ruvnet/v0-appliance/blob/main/docs/adr/ADR-207-rvcsi-library-integration.md)
|
||||
> (rvCSI library integration — Option D) and shipped a Rust binary
|
||||
> `cog-rvcsi-pi` built on rvcsi-runtime 0.3 that replaces the three
|
||||
> Python services. The cutover is per-Pi, operator-driven, with
|
||||
> one-command rollback (`scripts/rvcsi-pi/install-rvcsi-pi.sh` and
|
||||
> `uninstall-rvcsi-pi.sh`). A given cluster may be running either
|
||||
> stack while migration is in progress; the schema and operator
|
||||
> surface are unchanged across the cutover. See ADR-207's
|
||||
> Implementation log for the current state.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Architecture overview](#2-architecture-overview)
|
||||
3. [Per-node firmware: nexmon_csi on Pi 5](#3-per-node-firmware-nexmon_csi-on-pi-5)
|
||||
4. [Per-node services](#4-per-node-services)
|
||||
5. [Workstation pipeline](#5-workstation-pipeline)
|
||||
6. [Calibration: getting from raw CSI to room states](#6-calibration-getting-from-raw-csi-to-room-states)
|
||||
7. [Operating the cluster: the cog-query CLI](#7-operating-the-cluster-the-cog-query-cli)
|
||||
8. [What you can measure](#8-what-you-can-measure)
|
||||
9. [Troubleshooting](#9-troubleshooting)
|
||||
10. [Next steps](#10-next-steps)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Hardware
|
||||
|
||||
| Item | Quantity | Approx. cost | Notes |
|
||||
|------|----------|--------------|-------|
|
||||
| Raspberry Pi 5 (8GB) | 4 | ~$80 each | 4GB works but tight under sustained load |
|
||||
| Hailo-8 M.2 HAT (AI Kit) | 2 | ~$110 each | Only 2 needed — encoder is split across cluster-1 + cluster-2 |
|
||||
| MicroSD (64GB, A2) | 4 | ~$10 each | A2 class strongly recommended for sustained writes |
|
||||
| USB-C PD power supply (27W) | 4 | ~$12 each | Pi 5 draws 5A at full Hailo load |
|
||||
| Active cooler | 4 | ~$5 each | Cluster-2 sustains thermal load — passive will throttle |
|
||||
| Workstation (≥16GB RAM, Linux) | 1 | — | Hosts the brain HTTP service + clusterer + anomaly daemon |
|
||||
| Stable Wi-Fi beacon | 1 | — | Any AP on the same 5 GHz channel. We use ch.149/80MHz. Stability matters more than identity. |
|
||||
|
||||
**Total parts cost:** ~$580 plus workstation.
|
||||
|
||||
> **Important:** All 4 Pi 5s must use the on-board `bcm43455c0` radio. USB
|
||||
> Wi-Fi adapters with otherwise-similar chipsets **will not** work — nexmon's
|
||||
> firmware patches are silicon-specific. See ADR-206 § "USB Wi-Fi dongle
|
||||
> rabbit-hole" for the painful version of that lesson.
|
||||
|
||||
### Software prerequisites
|
||||
|
||||
| Component | Version | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Pi OS Bookworm (Lite) | 64-bit, kernel 6.6+ | Use the Lite image — Desktop slows boot and burns SD writes |
|
||||
| Tailscale | ≥1.60 | Mesh networking across the cluster |
|
||||
| Rust toolchain | 1.78+ on workstation, 1.78+ on each Pi | For ruvector + adapter binaries |
|
||||
| Python 3.11+ | system Python on workstation | numpy required |
|
||||
| systemd-user | already present | Workstation timers run as user units |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture overview
|
||||
|
||||
```
|
||||
┌─ workstation (Linux, ≥16GB) ──────────────────┐
|
||||
│ │
|
||||
│ brain HTTP (SQLite, port 9876) │
|
||||
│ ↑↑ │
|
||||
│ ┌──┴┴──────────────────────────────────┐ │
|
||||
│ │ rfmem-tail ← ingests live brain │ │
|
||||
│ │ rfmem-recall → posts category= │ │
|
||||
│ │ rfmem-recall when │ │
|
||||
│ │ current state ≈ past │ │
|
||||
│ │ rfmem-anomaly → 13-axis detector, │ │
|
||||
│ │ posts rfmem-anomaly & │ │
|
||||
│ │ rfmem-state-transition │ │
|
||||
│ │ cog-rfmem-states (timer, hourly) │ │
|
||||
│ │ re-clusters w/ warm-start│ │
|
||||
│ │ cog-rfmem-insights (timer, nightly) │ │
|
||||
│ │ writes rfmem-insights │ │
|
||||
│ │ cog-rfmem-drift-check (timer, 05:00) │ │
|
||||
│ │ audits cluster file state│ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ cog-query (CLI, 34 subcommands, 4 JSON modes)│
|
||||
└────────────────────────────────────────────────┘
|
||||
↑
|
||||
Tailscale mesh ──────────┴───────────────────────────────┐
|
||||
↓ ↓ ↓
|
||||
┌─ cluster-1 (Hailo) ┐ ┌─ cluster-2 (Hailo + fusion) ┐ ┌─ cluster-3 ┐ ┌─ v0 ┐
|
||||
│ cog-csi-emitter │ │ cog-csi-emitter │ │ same as │ │ same│
|
||||
│ cog-csi-adapter │ │ cog-csi-adapter │ │ cluster-1 │ │ as │
|
||||
│ cog-rvcsi-stream │ │ cog-rvcsi-stream │ │ minus │ │ c-3 │
|
||||
│ cog-hailo-encoder │ │ cog-hailo-encoder │ │ Hailo & │ │ │
|
||||
│ │ │ cog-rvcsi-correlator (fusion)│ │ correlator │ │ │
|
||||
└────────────────────┘ └─────────────────────────────┘ └────────────┘ └─────┘
|
||||
4 svc 5 svc 3 svc 3 svc
|
||||
└─────────────────────── 15 expected services total ──────────────────────┘
|
||||
```
|
||||
|
||||
**Why this split?** Multistatic fusion (combining CSI from 4 spatial vantage
|
||||
points into a single weighted observation) is computationally cheap but
|
||||
benefits from being on **one** node so the other three only do capture +
|
||||
encode. Hailo-8 is the bottleneck cost, so we put two on the cluster
|
||||
(one for redundancy, one for the fusion node) and let `cluster-3` + `v0`
|
||||
run as pure capture sensors.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-node firmware: nexmon_csi on Pi 5
|
||||
|
||||
**Critical lesson learned (saved you a week):** the workstation x86_64
|
||||
cross-compile path for nexmon_csi on Pi 5 **does not work**. The 39-hunk
|
||||
patch series applies cleanly on a native Pi 5 ARM build, and fails in
|
||||
subtle ways elsewhere.
|
||||
|
||||
The recipe that works:
|
||||
|
||||
```bash
|
||||
# On each Pi 5 (not the workstation):
|
||||
sudo apt update && sudo apt install -y \
|
||||
raspberrypi-kernel-headers bc bison flex libssl-dev make \
|
||||
gcc gawk qpdf cmake build-essential libpcap-dev clang gcc-arm-none-eabi
|
||||
|
||||
git clone https://github.com/seemoo-lab/nexmon.git ~/nexmon
|
||||
cd ~/nexmon
|
||||
source setup_env.sh
|
||||
make
|
||||
|
||||
cd patches
|
||||
git clone https://github.com/seemoo-lab/nexmon_csi.git
|
||||
cd nexmon_csi
|
||||
|
||||
# Apply the Pi-5-friendly patch series — all 39 hunks should apply clean
|
||||
# on native ARM. If you see "Hunk #N FAILED", you are almost certainly
|
||||
# cross-compiling from x86_64. Stop. Build on the Pi.
|
||||
./install.sh
|
||||
|
||||
# Switch on:
|
||||
sudo mcp # 'monitor capability provisioning' — enable
|
||||
sudo nexutil -Iwlan0 -s500 -b -l34 -v<86-char base64 capture filter>
|
||||
```
|
||||
|
||||
> **Pi 5 kernel gotcha:** Pi OS Bookworm ships two kernels — `kernel8.img`
|
||||
> (4K pages) and `kernel_2712.img` (16K pages, Pi 5 only). nexmon_csi
|
||||
> currently builds clean against `kernel8.img`. Add `kernel=kernel8.img`
|
||||
> to `/boot/firmware/config.txt` if you've switched. **After the switch,
|
||||
> SSH by hostname via Tailscale** — host keys + DHCP gotchas otherwise.
|
||||
|
||||
> **Clock-skew first-boot trap:** Pi 5 has no RTC. First-boot apt will
|
||||
> reject "future-dated" `Release` files. Patch your firstboot to wait for
|
||||
> `systemd-timesyncd` before running `apt-get`.
|
||||
|
||||
The complete commands + full troubleshooting matrix is in the
|
||||
[detailed gist](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017) — section "Firmware: nexmon_csi on Pi 5".
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-node services
|
||||
|
||||
Each cluster Pi runs a small fixed set of systemd services. Per-host
|
||||
topology:
|
||||
|
||||
| Service | cluster-1 | cluster-2 | cluster-3 | v0 |
|
||||
|---|:--:|:--:|:--:|:--:|
|
||||
| `cog-csi-emitter` (raw CSI capture from nexmon) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-csi-adapter` (Rust binary; CSI → 256-byte float frames) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-rvcsi-stream` (publishes frames to rvcsi-correlator) | ✓ | ✓ | ✓ | ✓ |
|
||||
| `cog-hailo-encoder` (frames → 128-d fingerprints on Hailo-8) | ✓ | ✓ | — | — |
|
||||
| `cog-rvcsi-correlator` (multistatic fusion across 4 nodes) | — | ✓ | — | — |
|
||||
| **Expected service count** | **4** | **5** | **3** | **3** |
|
||||
|
||||
The topology is encoded in the workstation's `cog-query fleet-status`
|
||||
subcommand, which compares per-host expected services against live
|
||||
`systemctl is-active` results. A flat-service check would falsely flag
|
||||
cluster-3 and v0 as degraded (they have neither Hailo nor the correlator
|
||||
— that's by design).
|
||||
|
||||
> **rvcsi cutover (ADR-207 Option D, 2026-05-13).** The three services
|
||||
> `cog-csi-emitter`, `cog-csi-adapter`, and `cog-rvcsi-stream` are
|
||||
> being consolidated into one Rust binary `cog-rvcsi-pi` built on
|
||||
> [rvcsi-runtime](https://crates.io/crates/rvcsi-runtime). The new
|
||||
> binary holds the same per-Pi role and the same expected-service
|
||||
> count from the operator's view (`fleet-status` already understands
|
||||
> both layouts). Deploy with
|
||||
> `bash scripts/rvcsi-pi/install-rvcsi-pi.sh <pi-host>`; revert with
|
||||
> `scripts/rvcsi-pi/uninstall-rvcsi-pi.sh`. The cutover is per-Pi,
|
||||
> not flag-day — mixed Python/Rust clusters are supported. The Hailo
|
||||
> encoder + correlator stay Python in this phase; their Rust ports
|
||||
> are tracked as follow-on ADRs.
|
||||
|
||||
All unit files + the install script are in the
|
||||
[detailed gist](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017) — section "Per-node systemd units".
|
||||
|
||||
---
|
||||
|
||||
## 5. Workstation pipeline
|
||||
|
||||
The workstation runs ten user-mode units (3 daemons, 7 timers):
|
||||
|
||||
| Unit | Type | Cadence | Purpose |
|
||||
|---|---|---|---|
|
||||
| `cog-rfmem-tail` | daemon | continuous | Ingests live brain entries into the workstation mirror |
|
||||
| `cog-rfmem-recall` | daemon | continuous | kNN-matches current fingerprint vs persisted ones, posts `rfmem-recall` |
|
||||
| `cog-rfmem-anomaly` | daemon | continuous | 13-axis anomaly detector, posts `rfmem-anomaly` + `rfmem-state-transition` |
|
||||
| `cog-rfmem-indexer` | timer | every 5 min | Updates HNSW index for kNN |
|
||||
| `cog-rfmem-compress` | timer | hourly | Compresses old brain entries |
|
||||
| `cog-rfmem-daily` | timer | nightly 04:00 | Per-day stats roll-up (`rfmem-daily`) |
|
||||
| `cog-rfmem-states` | timer | hourly | Re-runs cosine k-means w/ warm-start (`rfmem-state-summary`) |
|
||||
| `cog-rfmem-insights` | timer | nightly 04:55 | NL synthesis, posts `rfmem-insights` |
|
||||
| `cog-rfmem-drift-check` | timer | nightly 05:00 | Audits cluster file/unit drift, posts `rfmem-drift` |
|
||||
| `cog-rfmem-mirror` | timer | hourly | Mirrors cluster-2 brain → workstation read-replica |
|
||||
|
||||
Install in one shot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-fork>/v0-appliance.git
|
||||
cd v0-appliance
|
||||
bash scripts/rfmem/install-workstation.sh
|
||||
```
|
||||
|
||||
The installer is **idempotent** — rerunning is safe and only enables
|
||||
units that aren't yet enabled. It also wires a git post-commit hook
|
||||
that auto-deploys + auto-smoke-tests on every commit touching
|
||||
`scripts/rfmem/`. That closes the "I edited the repo but forgot to
|
||||
deploy" gap that bit us repeatedly in early development.
|
||||
|
||||
---
|
||||
|
||||
## 6. Calibration: getting from raw CSI to room states
|
||||
|
||||
This is the longest step but largely passive — let it run.
|
||||
|
||||
### 6.1 Walk the room
|
||||
|
||||
For 30–60 minutes after the cluster is live, walk through every room you
|
||||
want recognized. Sit, stand, move between rooms, repeat. The encoder is
|
||||
learning to map "what the room looks like in CSI" into 128-d vectors;
|
||||
diversity here matters more than total time.
|
||||
|
||||
### 6.2 First clustering pass
|
||||
|
||||
```bash
|
||||
# Force-trigger the clusterer (it normally fires hourly):
|
||||
systemctl --user start cog-rfmem-states.service
|
||||
python3 scripts/rfmem/cog-query.py states
|
||||
```
|
||||
|
||||
Output looks like:
|
||||
|
||||
```
|
||||
=== rfmem-states — k=16, n=12,847 ===
|
||||
state #0 π=0.184 dwell=42.3s centroid_drift=0.012 (default)
|
||||
state #1 π=0.121 dwell=18.1s centroid_drift=0.003
|
||||
state #4 π=0.087 dwell=29.6s centroid_drift=0.041
|
||||
...
|
||||
```
|
||||
|
||||
**Stable IDs across runs.** The warm-start k-means recipe matches new
|
||||
centroids to the prior run's centroids by cosine similarity before
|
||||
assigning IDs. This means state #4 stays state #4 between hourly runs —
|
||||
otherwise downstream Markov transitions would scramble after every
|
||||
re-cluster.
|
||||
|
||||
### 6.3 Let the Markov chain build
|
||||
|
||||
After a few thousand transitions (a few hours of activity), check:
|
||||
|
||||
```bash
|
||||
python3 scripts/rfmem/cog-query.py prediction-accuracy
|
||||
```
|
||||
|
||||
You should see something like:
|
||||
|
||||
```
|
||||
=== prediction-accuracy — training-set top-1 ceilings ===
|
||||
1st-order: 37.1% (16x chance baseline of 6.25%)
|
||||
2nd-order: 39.4% (16x chance baseline of 6.25%, 1.06x gain over 1st)
|
||||
```
|
||||
|
||||
The 2nd-order chain beats 1st-order because it conditions on the
|
||||
**previous** state as well as the current one. Self-loops are excluded
|
||||
from the argmax (a transition is by definition a state change).
|
||||
|
||||
### 6.4 Verify the room learned itself
|
||||
|
||||
```bash
|
||||
python3 scripts/rfmem/cog-query.py insights
|
||||
```
|
||||
|
||||
Reads like:
|
||||
|
||||
```
|
||||
The cluster has observed 446,231 fingerprints, clustering them into
|
||||
16 discrete RF states. The room exhibits moderately diverse (stationary
|
||||
entropy 0.82/1.0). State #4 is the dominant 'default' state (π=0.214);
|
||||
state #13 is the rarest baseline (π=0.018).
|
||||
Prediction skill (last hour, 2nd-order): top-1 12.4% (1.98x chance),
|
||||
top-3 31.0% (1.65x chance, 412 transitions) (training-set ceiling
|
||||
39.4% — operating @ 31% of capacity).
|
||||
```
|
||||
|
||||
That "operating @ 31% of capacity" line is the operational efficiency:
|
||||
how close live performance is to the model's theoretical ceiling. Big
|
||||
gap = the room is being noisy in ways the static cluster model doesn't
|
||||
capture. Small gap = you're near SOTA for this static model.
|
||||
|
||||
---
|
||||
|
||||
## 7. Operating the cluster: the cog-query CLI
|
||||
|
||||
A single CLI binary with **34 subcommands** + 4 machine-readable JSON
|
||||
modes. Practical ones (full list in the gist):
|
||||
|
||||
| Subcommand | What it does |
|
||||
|---|---|
|
||||
| `summary --hours 1` | Bird's-eye view of last hour: anomalies, transitions, recall hits |
|
||||
| `top-events --hours 24 --limit 5` | Highest-info events in window (combines novelty + tier + recency) |
|
||||
| `top-events --json` | Same, agent-consumable |
|
||||
| `insights` | Natural-language synthesis (paragraph) — what the cluster thinks |
|
||||
| `insights --json` | Same, structured |
|
||||
| `insights --post` | Same, persisted to brain as `rfmem-insights` |
|
||||
| `stats` | Corpus: per-category counts, dimensions, vector counts |
|
||||
| `motion` | Recent motion events |
|
||||
| `anomalies --sort info` | Anomalies sorted by composite info score (1.0–8.0) |
|
||||
| `circadian` | 24-hour bin of activity — does the room have a daily rhythm? |
|
||||
| `by-state` | Per-state metrics (dwell, σ-baseline, novelty distribution) |
|
||||
| `markov` | Top transitions by frequency, both 1st + 2nd-order |
|
||||
| `transitions --sort novelty` | Rare/surprising transitions |
|
||||
| `dwell-times` | How long the room stays in each state |
|
||||
| `prediction-accuracy` | 1st + 2nd-order top-1 ceilings |
|
||||
| `baseline-drift` | Has the noise floor shifted? (slow change) |
|
||||
| `centroid-drift` | Has any state's RF signature materially changed? |
|
||||
| `fleet-status` | Per-host expected-service liveness check |
|
||||
| `fleet-status --json` | Same, agent-consumable |
|
||||
| `fleet-status --post` | Same, persisted to brain as `rfmem-fleet` (heartbeat) |
|
||||
| `check-drift` | Workstation/cluster file + unit drift audit |
|
||||
| `replica-status` | Hourly cluster-2 → workstation mirror health |
|
||||
|
||||
### The fleet-health triad
|
||||
|
||||
Three subcommands cover the operator's full health picture:
|
||||
|
||||
- `check-drift` — file content drift (what's deployed vs what's in git)
|
||||
- `replica-status` — workstation mirror lag (last successful sync)
|
||||
- `fleet-status` — service liveness across the 4 Pis (topology-aware)
|
||||
|
||||
If all three are green, the cluster is healthy. If any one fires, you
|
||||
have a concrete starting point.
|
||||
|
||||
---
|
||||
|
||||
## 8. What you can measure
|
||||
|
||||
After a week of runtime, you can answer questions like:
|
||||
|
||||
- **"What's the room's most common 'baseline' state?"** → `states` shows
|
||||
the π-dominant cluster ID.
|
||||
- **"Did anything weird happen last night?"** → `anomalies --sort info
|
||||
--hours 12` sorts by combined-information score (novelty × tier × state-
|
||||
rarity × calmness).
|
||||
- **"How predictable is the room?"** → `insights` reports stationary
|
||||
entropy (0.0 = single state, 1.0 = uniform). Most rooms land 0.6–0.9.
|
||||
- **"What's the most novel transition ever observed?"** → `transitions
|
||||
--sort novelty --limit 1`. We've seen transitions with
|
||||
`transition_p=0.0000` — never observed before in 446k+ embeddings.
|
||||
- **"Is the room changing slowly?"** → `centroid-drift` flags states
|
||||
whose 128-d signature has moved > 0.05 cosine distance since the prior
|
||||
clusterer run. Common cause: a piece of furniture moved.
|
||||
- **"What's the daily rhythm?"** → `circadian` bins activity by hour.
|
||||
Most rooms show clear morning/evening peaks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `nexmon_csi` build fails with FAILED hunks | Cross-compiling from x86_64 | Build on the Pi natively |
|
||||
| Pi 5 stops booting after kernel switch | Wrong `kernel=` in `/boot/firmware/config.txt` | Use `kernel=kernel8.img` |
|
||||
| First boot fails on `apt update` | No RTC → clock skew, apt rejects "future-dated" Release files | Wait for `systemd-timesyncd` in firstboot |
|
||||
| `cog-rfmem-now` times out | Workstation daemon swap-thrashing | Bump `MemoryMax=` in unit file (we run 1G) |
|
||||
| `fleet-status` shows DEGRADED on cluster-3 / v0 | Topology unaware (old version) | Update to latest — per-host expected-services |
|
||||
| Cluster-2 Hailo encoder silent | `cp -r` made encoder a directory, not a file | `install -m 0755` instead |
|
||||
| 2nd-order Markov top-1 = 0% | Self-loop dominates argmax | Zero out self-loop before `.argmax()` |
|
||||
| State IDs change between runs | No warm-start k-means | Update clusterer to match new centroids to prior run by cosine |
|
||||
| HardFaults during embedded N6 bring-up | (Different topic, see [ADR-027](../adr/) for STM32N6 startup notes) | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. Next steps
|
||||
|
||||
Once your cluster is producing stable predictions and clean fleet health,
|
||||
the natural directions are:
|
||||
|
||||
1. **Cross-room correlation** — train a second cluster in another room
|
||||
and feed both into the workstation. The brain already supports
|
||||
multiple namespaces.
|
||||
2. **Active sensing** — instead of passively observing whatever beacon is
|
||||
present, drive your own (e.g., dedicated 5 GHz beacon AP at fixed
|
||||
power). Eliminates upstream variability.
|
||||
3. **Vital signs** — the RuView project has companion code for extracting
|
||||
heart-rate and breathing from CSI; the 128-d encoder output is a
|
||||
reasonable input feature.
|
||||
4. **Federated training** — multiple physical sites publishing to a shared
|
||||
brain. Each site keeps its own clusters; transitions are the shared
|
||||
vocabulary.
|
||||
5. **Push to upstream RuView** — if your cluster develops capabilities not
|
||||
in this tutorial (you'll know by the time you've written the README),
|
||||
send a PR.
|
||||
|
||||
---
|
||||
|
||||
## Reference material
|
||||
|
||||
- **[Detailed cookbook gist (all commands, configs, unit files)](https://gist.github.com/ruvnet/88e7b053c41cb4f4af7a7ec4af873017)**
|
||||
- **[ADR-206: nexmon_csi on Pi 5 cluster](https://github.com/ruvnet/v0-appliance/blob/main/docs/adr/ADR-206-nexmon-csi-on-pi-5-cluster.md)** — the engineering decision record
|
||||
with full rationale, including the painful-but-instructive failures
|
||||
- **[v0-appliance repo](https://github.com/ruvnet/v0-appliance)** — the
|
||||
source of truth for `scripts/rfmem/` operator tooling
|
||||
- **[seemoo-lab/nexmon_csi](https://github.com/seemoo-lab/nexmon_csi)** —
|
||||
upstream CSI capture firmware
|
||||
- **[Hailo-8 documentation](https://hailo.ai/products/hailo-8/)** — NPU
|
||||
reference
|
||||
|
||||
---
|
||||
|
||||
*This tutorial was built against the v0.5.0-cognitive-rf-observer milestone
|
||||
of `v0-appliance`. The cluster has been running continuously for 6+ weeks
|
||||
of development with 446k+ fingerprints observed, 16 stable RF states, and
|
||||
a 2nd-order Markov model operating at 31% of its 39.4% theoretical
|
||||
top-1 ceiling. SOTA is a moving target — but this is a real, working
|
||||
cognitive RF observer that you can reproduce.*
|
||||
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
@@ -331,6 +332,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.
|
||||
@@ -1744,6 +1785,8 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still
|
||||
|
||||
- Verify the sensing server is running: `curl http://localhost:3000/health`
|
||||
- Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL)
|
||||
- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005` instead; the Observatory reads the server WebSocket, not the standalone aggregator output
|
||||
- Verify the ESP32 nodes are provisioned to the IP address of the machine running `sensing-server`
|
||||
- Hard refresh with Ctrl+Shift+R to clear cached settings
|
||||
- The auto-detect probes `/health` on the same origin — cross-origin won't work
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Mixamo FBX downloads — too large + license boundary. Get your own from
|
||||
# mixamo.com (FBX Binary + T-Pose / Without Skin), drop into assets/.
|
||||
*.fbx
|
||||
|
||||
# Diagnostic / debug screenshots from a dev session. Official screenshots
|
||||
# live in screenshots/ and are committed; these underscore-prefixed ones
|
||||
# are scratch.
|
||||
_diag-*.png
|
||||
_demo-mode-shot*.png
|
||||
_PROOF-*.png
|
||||
@@ -0,0 +1,77 @@
|
||||
# three.js demos
|
||||
|
||||
Five progressively richer browser demos of the ADR-097 sensing-helpers scene,
|
||||
ending with a live MediaPipe-Pose → Mixamo X Bot retargeting pipeline driven
|
||||
by a real ESP32 CSI feed.
|
||||
|
||||
## Run them
|
||||
|
||||
```bash
|
||||
python examples/three.js/server/serve-demo.py
|
||||
# then open one of the URLs the script prints
|
||||
```
|
||||
|
||||
`server/serve-demo.py` is a tiny `ThreadingHTTPServer` with aggressive
|
||||
no-cache headers — the stdlib `http.server` is single-threaded and times out
|
||||
on the parallel script + FBX fetches the demos make.
|
||||
|
||||
## Demos
|
||||
|
||||
| # | File | What it shows |
|
||||
|---|------|---------------|
|
||||
| 01 | [`demos/01-helpers.html`](demos/01-helpers.html) | Plain ADR-097 helpers in the point-cloud viewer |
|
||||
| 02 | [`demos/02-cinematic.html`](demos/02-cinematic.html) | Cinematic camera + pseudo-CSI visualization on top of #01 |
|
||||
| 03 | [`demos/03-skinned.html`](demos/03-skinned.html) | GLTF skinned mesh + additive animation blending |
|
||||
| 04 | [`demos/04-skinned-fbx.html`](demos/04-skinned-fbx.html) | Mixamo X Bot loaded from FBX in the ADR-097 scene |
|
||||
| 05 | [`demos/05-skinned-realtime.html`](demos/05-skinned-realtime.html) | Webcam → MediaPipe Pose Heavy → Mixamo IK retarget, live ESP32 CSI overlay |
|
||||
|
||||
| Screenshot | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
examples/three.js/
|
||||
├── README.md
|
||||
├── .gitignore
|
||||
├── demos/ # 5 self-contained HTML demos
|
||||
│ ├── 01-helpers.html
|
||||
│ ├── 02-cinematic.html
|
||||
│ ├── 03-skinned.html
|
||||
│ ├── 04-skinned-fbx.html
|
||||
│ └── 05-skinned-realtime.html
|
||||
├── screenshots/ # one PNG per demo
|
||||
│ └── 0N-*.png
|
||||
├── server/
|
||||
│ ├── serve-demo.py # local HTTP server with no-cache headers
|
||||
│ └── ruvultra-csi-bridge.py # ESP32 CSI WebSocket bridge (ruvultra:8766)
|
||||
└── assets/
|
||||
└── X Bot.fbx # gitignored — get your own from mixamo.com
|
||||
# (FBX Binary, T-Pose, Without Skin)
|
||||
# used by demos 04 and 05
|
||||
```
|
||||
|
||||
## Mixamo X Bot
|
||||
|
||||
Demos 04 and 05 expect `assets/X Bot.fbx`. It's gitignored (size + license
|
||||
boundary). Download yours from [mixamo.com](https://mixamo.com): pick the
|
||||
"X Bot" character, export as **FBX Binary**, **T-Pose**, **Without Skin**,
|
||||
and drop it into `assets/`.
|
||||
|
||||
## Live ESP32 CSI overlay (demo 05 only)
|
||||
|
||||
`server/ruvultra-csi-bridge.py` is the systemd-deployable bridge that runs on
|
||||
the `ruvultra` host (over Tailscale). It listens for ESP32-S3 CSI on UDP and
|
||||
re-broadcasts it as WebSocket frames at `ws://ruvultra:8766/csi`. Demo 05
|
||||
auto-connects; if the socket is down, it falls back to the bundled idle clip
|
||||
plus a synthetic CSI driver.
|
||||
|
||||
## Open issues
|
||||
|
||||
- [#583](https://github.com/ruvnet/RuView/issues/583) — head/face tracking
|
||||
fidelity in `05-skinned-realtime.html`. Recommended fix: swap MediaPipe
|
||||
Pose Heavy for MediaPipe Holistic (same API, adds 468-point face mesh +
|
||||
hand landmarks for proper PnP head pose and finger curl tracking).
|
||||
@@ -0,0 +1,587 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · ADR-097 · three.js helpers in the point cloud viewer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-panel: rgba(0, 0, 0, 0.88);
|
||||
--amber: #e8a634;
|
||||
--amber-dim: #4a3a1a;
|
||||
--amber-hot: #ffc04d;
|
||||
--grid-major: #444444;
|
||||
--grid-minor: #222222;
|
||||
--green: #4f4;
|
||||
--blue: #4cf;
|
||||
--text-mute: #888;
|
||||
--border: #2a2a2a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
/* Top-left HUD */
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
max-width: 340px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
|
||||
}
|
||||
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
|
||||
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); }
|
||||
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
#info .row .v.live { color: var(--green); }
|
||||
|
||||
/* Bottom-left helper toggle panel */
|
||||
#controls {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
min-width: 220px;
|
||||
}
|
||||
#controls h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
}
|
||||
#controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#controls label:hover { color: var(--amber-hot); }
|
||||
#controls input[type=checkbox] {
|
||||
accent-color: var(--amber);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#controls .helper-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Bottom-right ADR badge */
|
||||
#adr-badge {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
#adr-badge a { color: var(--amber); text-decoration: none; }
|
||||
#adr-badge a:hover { color: var(--amber-hot); }
|
||||
|
||||
/* Top-right legend */
|
||||
#legend {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
min-width: 200px;
|
||||
}
|
||||
#legend h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
}
|
||||
#legend .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
#legend .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#legend .label { font-size: 11px; line-height: 1.3; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<h1>RuView · Helpers Demo</h1>
|
||||
<div class="sub">ADR-097 · three.js helpers for the point cloud viewer</div>
|
||||
<div class="row"><span class="k">Scene</span><span class="v live">● SYNTHETIC</span></div>
|
||||
<div class="row"><span class="k">Skeleton</span><span class="v">17 kpts · COCO</span></div>
|
||||
<div class="row"><span class="k">Point cloud</span><span class="v" id="pc-count">— pts</span></div>
|
||||
<div class="row"><span class="k">Sensor nodes</span><span class="v">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Frame rate</span><span class="v" id="fps">— fps</span></div>
|
||||
<div class="row"><span class="k">Bbox volume</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<h2>Helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="helper-swatch" style="background:#444"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="helper-swatch" style="background:#4a3a1a"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper<span class="helper-swatch" style="background:#e8a634"></span></label>
|
||||
<label><input type="checkbox" id="t-axes" checked>AxesHelper<span class="helper-swatch" style="background:linear-gradient(90deg,#f44,#4f4,#4cf)"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="helper-swatch" style="background:#4cf"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h2>Scene</h2>
|
||||
<div class="item"><span class="dot" style="background:#ffff00"></span><span class="label">COCO-17 keypoints (yellow)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#ffffff"></span><span class="label">Bones (white lines)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#4cf"></span><span class="label">Face point cloud (cyan→white)</span></div>
|
||||
<div class="item"><span class="dot" style="background:#e8a634"></span><span class="label">ESP32 sensor nodes</span></div>
|
||||
</div>
|
||||
|
||||
<div id="adr-badge">
|
||||
ADR-097 · <a href="https://threejs.org/examples/#webgl_helpers" target="_blank" rel="noopener">three.js helpers</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · ADR-097 · three.js helpers demo
|
||||
// --------------------------------------------------------------------
|
||||
// Self-contained, no backend. Demonstrates how `GridHelper`,
|
||||
// `PolarGridHelper`, `BoxHelper`, and `AxesHelper` slot into the
|
||||
// RuView point cloud viewer (`v2/crates/wifi-densepose-pointcloud
|
||||
// /src/viewer.html`). Open this file in a browser — no build step.
|
||||
//
|
||||
// The scene contains:
|
||||
// 1. A synthetic walking, breathing 17-keypoint skeleton.
|
||||
// 2. A face-shaped point cloud attached to the skeleton head.
|
||||
// 3. Four multistatic sensor-node markers arranged around the room.
|
||||
// 4. All four ADR-097 helpers, toggleable from the bottom-left panel.
|
||||
//
|
||||
// Coordinate frame matches the production viewer:
|
||||
// +X = right, +Y = up, +Z = away from camera.
|
||||
// Floor at y = -1.5, person hip at y = 0, head reaches ~ y = 0.7.
|
||||
// =====================================================================
|
||||
|
||||
const COCO_BONES = [
|
||||
// head
|
||||
[0, 1], [0, 2], [1, 3], [2, 4],
|
||||
// torso
|
||||
[5, 6], [5, 11], [6, 12], [11, 12],
|
||||
// left arm
|
||||
[5, 7], [7, 9],
|
||||
// right arm
|
||||
[6, 8], [8, 10],
|
||||
// left leg
|
||||
[11, 13], [13, 15],
|
||||
// right leg
|
||||
[12, 14], [14, 16],
|
||||
];
|
||||
|
||||
// Static "T-pose" skeleton in local frame, animated each frame.
|
||||
// 17 keypoints in COCO order. Units: meters.
|
||||
const SKELETON_BASE = {
|
||||
0: [ 0.00, 0.65, 0.00], // nose
|
||||
1: [-0.04, 0.68, 0.04], // L eye
|
||||
2: [ 0.04, 0.68, 0.04], // R eye
|
||||
3: [-0.08, 0.64, 0.00], // L ear
|
||||
4: [ 0.08, 0.64, 0.00], // R ear
|
||||
5: [-0.18, 0.45, 0.00], // L shoulder
|
||||
6: [ 0.18, 0.45, 0.00], // R shoulder
|
||||
7: [-0.22, 0.20, 0.00], // L elbow
|
||||
8: [ 0.22, 0.20, 0.00], // R elbow
|
||||
9: [-0.26, -0.05, 0.00], // L wrist
|
||||
10: [ 0.26, -0.05, 0.00], // R wrist
|
||||
11: [-0.10, 0.00, 0.00], // L hip
|
||||
12: [ 0.10, 0.00, 0.00], // R hip
|
||||
13: [-0.12, -0.40, 0.00], // L knee
|
||||
14: [ 0.12, -0.40, 0.00], // R knee
|
||||
15: [-0.12, -0.80, 0.00], // L ankle
|
||||
16: [ 0.12, -0.80, 0.00], // R ankle
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene + camera + renderer
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
scene.fog = new THREE.Fog(0x0a0a0a, 6, 14);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.0, 1.4, 4.2);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.minDistance = 1.5;
|
||||
controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers — wired to checkbox toggles
|
||||
// ---------------------------------------------------------------------
|
||||
// GridHelper — Cartesian floor reference. Establishes "down" and
|
||||
// scale: 4 m × 4 m floor, 20 divisions = 0.2 m grid spacing.
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x444444, 0x222222);
|
||||
gridHelper.position.y = -1.5;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// PolarGridHelper — multistatic geometry reference. 16 radial
|
||||
// divisions (angular bins) × 4 concentric circles, centered on
|
||||
// the fusion target. Matches the bin count in
|
||||
// signal/src/ruvsense/multistatic.rs:attention_weight().
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0x4a3a1a, 0x2a1f10);
|
||||
polarHelper.position.y = -1.499; // a hair above grid to avoid z-fight
|
||||
scene.add(polarHelper);
|
||||
|
||||
// AxesHelper — XYZ tripod at origin. Red = X, green = Y, blue = Z.
|
||||
const axesHelper = new THREE.AxesHelper(0.5);
|
||||
axesHelper.position.set(0, -1.49, 0);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// BoxHelper — per-person bounding volume. Refreshed each frame
|
||||
// after the skeleton is updated. Color = RuView amber.
|
||||
let bboxHelper = null;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Skeleton — joint spheres + bone lines, animated
|
||||
// ---------------------------------------------------------------------
|
||||
const skeletonGroup = new THREE.Group();
|
||||
scene.add(skeletonGroup);
|
||||
|
||||
const jointGeo = new THREE.SphereGeometry(0.025, 12, 12);
|
||||
const jointMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||
const joints = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const sphere = new THREE.Mesh(jointGeo, jointMat);
|
||||
const p = SKELETON_BASE[i];
|
||||
sphere.position.set(p[0], p[1], p[2]);
|
||||
sphere.userData.baseY = p[1];
|
||||
sphere.userData.baseX = p[0];
|
||||
sphere.userData.idx = i;
|
||||
skeletonGroup.add(sphere);
|
||||
joints.push(sphere);
|
||||
}
|
||||
|
||||
const boneMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
|
||||
const bones = [];
|
||||
for (const [a, b] of COCO_BONES) {
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
|
||||
const line = new THREE.Line(geom, boneMat);
|
||||
line.userData = { a, b };
|
||||
skeletonGroup.add(line);
|
||||
bones.push(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — synthetic ellipsoid attached to head keypoint
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 600;
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceColors = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3); // canonical face shape, relative to nose
|
||||
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
// Sample points roughly on a face-shaped ellipsoid (taller than wide).
|
||||
const u = Math.random() * Math.PI * 2;
|
||||
const v = (Math.random() - 0.5) * Math.PI;
|
||||
const cu = Math.cos(u), su = Math.sin(u);
|
||||
const cv = Math.cos(v), sv = Math.sin(v);
|
||||
// ellipsoid radii (head-like proportions)
|
||||
const rx = 0.085, ry = 0.105, rz = 0.075;
|
||||
faceOffsets[i * 3 + 0] = rx * cv * cu;
|
||||
faceOffsets[i * 3 + 1] = ry * sv;
|
||||
faceOffsets[i * 3 + 2] = rz * cv * su;
|
||||
// depth-encoded color: cyan at back, near-white at front (toward +Z = away from camera)
|
||||
const depthT = (sv + 1) * 0.5;
|
||||
faceColors[i * 3 + 0] = 0.30 + 0.70 * depthT; // R
|
||||
faceColors[i * 3 + 1] = 0.80 + 0.20 * depthT; // G
|
||||
faceColors[i * 3 + 2] = 1.00; // B
|
||||
}
|
||||
const faceGeom = new THREE.BufferGeometry();
|
||||
faceGeom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
faceGeom.setAttribute('color', new THREE.BufferAttribute(faceColors, 3));
|
||||
const faceMat = new THREE.PointsMaterial({
|
||||
size: 0.012,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const facePoints = new THREE.Points(faceGeom, faceMat);
|
||||
skeletonGroup.add(facePoints);
|
||||
document.getElementById('pc-count').textContent = FACE_POINTS + ' pts';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Multistatic sensor nodes — 4 ESP32 markers around the room
|
||||
// ---------------------------------------------------------------------
|
||||
const nodeGroup = new THREE.Group();
|
||||
scene.add(nodeGroup);
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9], // back-left high
|
||||
[ 1.9, 1.3, 1.9], // back-right high
|
||||
[-1.9, 1.3, -1.9], // front-left high
|
||||
[ 1.9, 1.3, -1.9], // front-right high
|
||||
];
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeGeo = new THREE.BoxGeometry(0.12, 0.06, 0.18);
|
||||
const nodeMat = new THREE.MeshBasicMaterial({ color: 0xe8a634 });
|
||||
const nodeAntennaGeo = new THREE.ConeGeometry(0.018, 0.08, 8);
|
||||
const nodeAntennaMat = new THREE.MeshBasicMaterial({ color: 0xffc04d });
|
||||
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
const body = new THREE.Mesh(nodeGeo, nodeMat);
|
||||
group.add(body);
|
||||
|
||||
// little antenna sticking up
|
||||
const antenna = new THREE.Mesh(nodeAntennaGeo, nodeAntennaMat);
|
||||
antenna.position.y = 0.07;
|
||||
group.add(antenna);
|
||||
|
||||
// pulsing emissive ring (visualizes RX activity)
|
||||
const ringGeo = new THREE.RingGeometry(0.10, 0.13, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0xe8a634, side: THREE.DoubleSide, transparent: true, opacity: 0.4 });
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -0.04;
|
||||
ring.userData.phase = i * 0.5;
|
||||
group.add(ring);
|
||||
group.userData.ring = ring;
|
||||
|
||||
// sight-line from node to scene origin (visualizes multistatic geometry)
|
||||
const sightGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(-pos[0], -pos[1], -pos[2]),
|
||||
]);
|
||||
const sightMat = new THREE.LineDashedMaterial({
|
||||
color: 0xe8a634, transparent: true, opacity: 0.18,
|
||||
dashSize: 0.1, gapSize: 0.06,
|
||||
});
|
||||
const sightLine = new THREE.Line(sightGeo, sightMat);
|
||||
sightLine.computeLineDistances();
|
||||
group.add(sightLine);
|
||||
|
||||
nodeGroup.add(group);
|
||||
|
||||
// ADR-097 §3.3 — per-node BoxHelper. Demonstrates that helpers
|
||||
// compose naturally: one box per detected object.
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
scene.add(bbox);
|
||||
nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Animation — synthetic motion model
|
||||
// ---------------------------------------------------------------------
|
||||
let frameStart = performance.now();
|
||||
let frameCount = 0;
|
||||
let fpsAvg = 0;
|
||||
|
||||
function applyPose(t) {
|
||||
// Body sway (slow), breathing (chest expansion), arm/leg swing (walking).
|
||||
const swayX = Math.sin(t * 0.35) * 0.05;
|
||||
const swayZ = Math.cos(t * 0.27) * 0.04;
|
||||
const breathe = Math.sin(t * 1.4) * 0.012; // chest in/out
|
||||
const walkPhase = t * 1.9; // walk cycle
|
||||
|
||||
skeletonGroup.position.set(swayX, 0, swayZ);
|
||||
skeletonGroup.rotation.y = Math.sin(t * 0.22) * 0.18;
|
||||
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const base = SKELETON_BASE[i];
|
||||
let dx = 0, dy = 0, dz = 0;
|
||||
|
||||
// breathing — shoulders + nose rise a little
|
||||
if (i === 0 || i === 1 || i === 2) dy = breathe * 0.6;
|
||||
if (i === 5 || i === 6) dy = breathe;
|
||||
|
||||
// arm swing (opposite of legs)
|
||||
if (i === 7) { dz = Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
|
||||
if (i === 9) { dz = Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
|
||||
if (i === 8) { dz = -Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
|
||||
if (i === 10){ dz = -Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
|
||||
|
||||
// leg swing
|
||||
if (i === 13){ dz = -Math.sin(walkPhase) * 0.08; }
|
||||
if (i === 15){ dz = -Math.sin(walkPhase) * 0.15; dy = Math.max(0, Math.cos(walkPhase)) * 0.04; }
|
||||
if (i === 14){ dz = Math.sin(walkPhase) * 0.08; }
|
||||
if (i === 16){ dz = Math.sin(walkPhase) * 0.15; dy = Math.max(0, -Math.cos(walkPhase)) * 0.04; }
|
||||
|
||||
joints[i].position.set(base[0] + dx, base[1] + dy, base[2] + dz);
|
||||
}
|
||||
|
||||
// update bone line vertices from current joint positions
|
||||
for (const line of bones) {
|
||||
const { a, b } = line.userData;
|
||||
const pa = joints[a].position;
|
||||
const pb = joints[b].position;
|
||||
const pos = line.geometry.attributes.position;
|
||||
pos.array[0] = pa.x; pos.array[1] = pa.y; pos.array[2] = pa.z;
|
||||
pos.array[3] = pb.x; pos.array[4] = pb.y; pos.array[5] = pb.z;
|
||||
pos.needsUpdate = true;
|
||||
}
|
||||
|
||||
// attach face point cloud to the nose keypoint (kpt 0)
|
||||
const nose = joints[0].position;
|
||||
const positions = faceGeom.attributes.position;
|
||||
const headTurn = Math.sin(t * 0.6) * 0.35; // y-axis nod
|
||||
const cosH = Math.cos(headTurn), sinH = Math.sin(headTurn);
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const ox = faceOffsets[i * 3 + 0];
|
||||
const oy = faceOffsets[i * 3 + 1];
|
||||
const oz = faceOffsets[i * 3 + 2];
|
||||
// rotate offset around Y axis by headTurn
|
||||
const rx = cosH * ox + sinH * oz;
|
||||
const rz = -sinH * ox + cosH * oz;
|
||||
positions.array[i * 3 + 0] = nose.x + rx;
|
||||
positions.array[i * 3 + 1] = nose.y + oy;
|
||||
positions.array[i * 3 + 2] = nose.z + rz;
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
}
|
||||
|
||||
function updateNodes(t) {
|
||||
nodeGroup.children.forEach((node, i) => {
|
||||
const ring = node.userData.ring;
|
||||
const phase = (t * 1.8 + ring.userData.phase) % (Math.PI * 2);
|
||||
ring.material.opacity = 0.18 + 0.42 * Math.max(0, Math.cos(phase));
|
||||
ring.scale.setScalar(1 + 0.18 * Math.max(0, Math.cos(phase)));
|
||||
});
|
||||
}
|
||||
|
||||
function updateBboxHelper() {
|
||||
const want = document.getElementById('t-bbox').checked;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
return;
|
||||
}
|
||||
skeletonGroup.updateMatrixWorld(true);
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(skeletonGroup, 0xe8a634);
|
||||
scene.add(bboxHelper);
|
||||
} else {
|
||||
bboxHelper.setFromObject(skeletonGroup);
|
||||
}
|
||||
// compute volume for the HUD
|
||||
const box = new THREE.Box3().setFromObject(skeletonGroup);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent =
|
||||
(size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const t = now * 0.001;
|
||||
const dt = now - frameStart;
|
||||
frameStart = now;
|
||||
frameCount++;
|
||||
if (frameCount % 30 === 0) {
|
||||
fpsAvg = 1000 / dt;
|
||||
document.getElementById('fps').textContent = fpsAvg.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
applyPose(t);
|
||||
updateNodes(t);
|
||||
updateBboxHelper();
|
||||
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Controls wiring — checkbox toggles attach/detach helpers from scene
|
||||
// ---------------------------------------------------------------------
|
||||
function bindToggle(id, obj) {
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener('change', () => {
|
||||
if (el.checked) {
|
||||
if (!scene.children.includes(obj)) scene.add(obj);
|
||||
} else {
|
||||
scene.remove(obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
bindToggle('t-axes', axesHelper);
|
||||
|
||||
// per-node bbox toggle (group of 4)
|
||||
document.getElementById('t-nodebox').addEventListener('change', (e) => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked) {
|
||||
if (!scene.children.includes(bb)) scene.add(bb);
|
||||
} else {
|
||||
scene.remove(bb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Resize
|
||||
// ---------------------------------------------------------------------
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,854 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation blending</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #050507;
|
||||
--bg-panel: rgba(8, 10, 14, 0.78);
|
||||
--amber: #ffb840;
|
||||
--amber-hot: #ffe09f;
|
||||
--cyan: #4cf;
|
||||
--magenta: #ff4cc8;
|
||||
--text: #d8c69a;
|
||||
--text-mute: #6b6155;
|
||||
--border: rgba(255, 184, 64, 0.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
.overlay-frame {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||
}
|
||||
.scanlines {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||
mix-blend-mode: overlay; opacity: 0.5;
|
||||
}
|
||||
.panel {
|
||||
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 0 rgba(255, 184, 64, 0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||
}
|
||||
|
||||
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||
#info .row .v.amber { color: var(--amber); }
|
||||
#info .row .v.cyan { color: var(--cyan); }
|
||||
#info .row .v.mag { color: var(--magenta); }
|
||||
|
||||
#anim {
|
||||
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#anim .group { padding: 6px 0; border-bottom: 1px solid rgba(255,184,64,0.08); }
|
||||
#anim .group:last-child { border-bottom: none; }
|
||||
#anim .group-label { font-size: 10px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
#anim button {
|
||||
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#anim .slider-row .label { width: 90px; color: var(--text-mute); }
|
||||
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||
#csi .bar-row .bar-fill {
|
||||
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||
}
|
||||
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#helpers {
|
||||
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#helpers label {
|
||||
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||
}
|
||||
#helpers label:hover { color: var(--amber-hot); }
|
||||
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||
|
||||
#loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(5, 5, 7, 0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||
letter-spacing: 2px; text-transform: uppercase;
|
||||
}
|
||||
#loading.hidden { display: none; }
|
||||
#loading .text {
|
||||
text-shadow: 0 0 12px var(--amber);
|
||||
animation: loadPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||
|
||||
@keyframes scanFlash {
|
||||
0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; }
|
||||
}
|
||||
.scan-flash {
|
||||
position: fixed; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||
}
|
||||
|
||||
#titlecard {
|
||||
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||
}
|
||||
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||
|
||||
#adr-badge {
|
||||
position: absolute; top: 50%; right: 20px; transform: translateY(-50%);
|
||||
padding: 6px 10px; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; font-size: 9px; color: var(--text-mute); z-index: 10;
|
||||
backdrop-filter: blur(8px); letter-spacing: 0.5px; max-width: 70px; text-align: center; line-height: 1.5;
|
||||
}
|
||||
#adr-badge a { color: var(--amber); text-decoration: none; display: block; }
|
||||
#adr-badge a:hover { color: var(--amber-hot); }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-frame"></div>
|
||||
<div class="scanlines"></div>
|
||||
<div class="scan-flash" id="scan-flash"></div>
|
||||
|
||||
<div id="loading"><div class="text">▸ Loading skinned subject · Xbot.glb · 2.9 MB</div></div>
|
||||
|
||||
<div class="panel" id="info">
|
||||
<h1>RuView · Skinned</h1>
|
||||
<div class="sub">ADR-097 · GLTF skinned mesh · additive animation blending</div>
|
||||
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||
<div class="row"><span class="k">Model</span><span class="v">Xbot.glb · 14k tris</span></div>
|
||||
<div class="row"><span class="k">Base anim</span><span class="v amber" id="base-name">walk</span></div>
|
||||
<div class="row"><span class="k">Additive</span><span class="v mag" id="add-name">headShake · 0.40</span></div>
|
||||
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||
</div>
|
||||
|
||||
<div id="anim">
|
||||
<h2>AnimationMixer</h2>
|
||||
<div class="group">
|
||||
<div class="group-label">Base · loops</div>
|
||||
<button data-base="idle">idle</button>
|
||||
<button data-base="walk" class="active">walk</button>
|
||||
<button data-base="run">run</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="group-label">Additive · layered</div>
|
||||
<button data-add="agree">agree</button>
|
||||
<button data-add="headShake" class="active">headShake</button>
|
||||
<button data-add="sad_pose">sad</button>
|
||||
<button data-add="sneak_pose">sneak</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="slider-row">
|
||||
<span class="label">add weight</span>
|
||||
<input type="range" id="add-weight" min="0" max="1" step="0.01" value="0.40">
|
||||
<span class="val" id="add-weight-val">0.40</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="label">time scale</span>
|
||||
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||
<span class="val" id="time-scale-val">1.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="csi">
|
||||
<h2>Per-node CSI</h2>
|
||||
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0" style="width:0"></div></div><span class="val" id="val-0">—</span></div>
|
||||
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1" style="width:0"></div></div><span class="val" id="val-1">—</span></div>
|
||||
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2" style="width:0"></div></div><span class="val" id="val-2">—</span></div>
|
||||
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3" style="width:0"></div></div><span class="val" id="val-3">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="helpers">
|
||||
<h2>ADR-097 helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="titlecard">
|
||||
RuView · Seldon Vault
|
||||
<div class="sub">skinned · ADR-097 · CCDIKSolver next</div>
|
||||
</div>
|
||||
|
||||
<div id="adr-badge">
|
||||
<a href="https://threejs.org/examples/#webgl_animation_skinning_additive_blending" target="_blank" rel="noopener">additive blend</a>
|
||||
<a href="https://threejs.org/examples/#webgl_animation_skinning_ik" target="_blank" rel="noopener" style="margin-top:4px;">skinning IK</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation
|
||||
// --------------------------------------------------------------------
|
||||
// Replaces the procedural sphere-skeleton of helpers-cinematic.html
|
||||
// with a real rigged + skinned humanoid loaded from Xbot.glb. Plays
|
||||
// a base loop (walk / run / idle) and layers an additive pose on
|
||||
// top (headShake / agree / sneak / sad) — mirrors the upstream
|
||||
// three.js webgl_animation_skinning_additive_blending example.
|
||||
//
|
||||
// All ADR-097 helpers still wrap the loaded mesh — BoxHelper picks
|
||||
// up the live AABB of the SkinnedMesh, the polar grid sits under
|
||||
// the rig, and per-node BoxHelpers wrap the four ESP32 markers.
|
||||
//
|
||||
// Production path (next): swap canned GLTF animations for live
|
||||
// COCO-17 keypoint output → CCDIKSolver targets on hands/feet/head.
|
||||
// Reference: three.js webgl_animation_skinning_ik example.
|
||||
// =====================================================================
|
||||
|
||||
const MODEL_URL = 'https://threejs.org/examples/models/gltf/Xbot.glb';
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050507);
|
||||
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.2, 1.55, 4.0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.80;
|
||||
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0.9, 0);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.06;
|
||||
controls.minDistance = 2; controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||
controls.autoRotateSpeed = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lights — the GLTF uses PBR materials so we actually need lighting
|
||||
// here (unlike the all-emissive cinematic.html). Tuned to keep the
|
||||
// amber/cyan mood: amber hemi + amber key + cyan rim lights from
|
||||
// each node direction (visualizes "the nodes illuminate the subject").
|
||||
// ---------------------------------------------------------------------
|
||||
const hemiLight = new THREE.HemisphereLight(0x553a18, 0x080606, 0.7);
|
||||
hemiLight.position.set(0, 4, 0);
|
||||
scene.add(hemiLight);
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffc070, 0.95);
|
||||
keyLight.position.set(2.5, 3.8, 2.5);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.bias = -0.0008;
|
||||
scene.add(keyLight);
|
||||
|
||||
// cyan rim lights, one per ESP32 node — keeps the "sensed by the mesh" mood
|
||||
const rimLights = [];
|
||||
NODE_POSITIONS.forEach(pos => {
|
||||
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||
scene.add(rim);
|
||||
rimLights.push(rim);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Post-processing — same composer as cinematic.html
|
||||
// ---------------------------------------------------------------------
|
||||
const composer = new THREE.EffectComposer(renderer);
|
||||
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||
const bloom = new THREE.UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
0.45, 0.40, 0.78,
|
||||
);
|
||||
composer.addPass(bloom);
|
||||
|
||||
const filmShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
time: { value: 0 }, grain: { value: 0.04 },
|
||||
vignette: { value: 0.32 }, aberration: { value: 0.0018 },
|
||||
},
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||
varying vec2 vUv;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
vec2 off = (vUv - 0.5) * aberration;
|
||||
float r = texture2D(tDiffuse, vUv + off).r;
|
||||
float g = texture2D(tDiffuse, vUv).g;
|
||||
float b = texture2D(tDiffuse, vUv - off).b;
|
||||
vec3 col = vec3(r, g, b);
|
||||
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||
col *= mix(1.0 - vignette, 1.0, v);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
};
|
||||
const filmPass = new THREE.ShaderPass(filmShader);
|
||||
composer.addPass(filmPass);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Floor — same procedural cyber grid (toned down for skinned scene)
|
||||
// ---------------------------------------------------------------------
|
||||
const floorMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||
void main() {
|
||||
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||
scan = pow(scan, 14.0);
|
||||
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = 0;
|
||||
scene.add(floor);
|
||||
|
||||
// shadow-receiving ground (invisible, just catches the shadow)
|
||||
const shadowGround = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||
);
|
||||
shadowGround.rotation.x = -Math.PI / 2;
|
||||
shadowGround.position.y = 0.001;
|
||||
shadowGround.receiveShadow = true;
|
||||
scene.add(shadowGround);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers
|
||||
// ---------------------------------------------------------------------
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||
polarHelper.position.y = 0.002;
|
||||
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||
scene.add(polarHelper);
|
||||
|
||||
let bboxHelper = null;
|
||||
let skeletonHelper = null;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Multistatic sensor nodes — same as cinematic
|
||||
// ---------------------------------------------------------------------
|
||||
const nodeGroup = new THREE.Group();
|
||||
scene.add(nodeGroup);
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeRings = [];
|
||||
const nodeAnchors = [];
|
||||
const nodeBodyGeo = new THREE.BoxGeometry(0.14, 0.06, 0.20);
|
||||
const nodeBodyMat = new THREE.MeshBasicMaterial({ color: 0xffb840 });
|
||||
const antennaGeo = new THREE.ConeGeometry(0.018, 0.10, 8);
|
||||
const antennaMat = new THREE.MeshBasicMaterial({ color: 0xffe09f });
|
||||
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
const body = new THREE.Mesh(nodeBodyGeo, nodeBodyMat);
|
||||
group.add(body);
|
||||
const antenna = new THREE.Mesh(antennaGeo, antennaMat);
|
||||
antenna.position.y = 0.08; group.add(antenna);
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide, transparent: true,
|
||||
opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||
ring.userData.phase = i * 0.7;
|
||||
group.add(ring); nodeRings.push(ring);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.025, 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
core.position.y = 0.04; group.add(core);
|
||||
|
||||
nodeGroup.add(group); nodeAnchors.push(group);
|
||||
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GLTF — load the rigged Xbot model
|
||||
// ---------------------------------------------------------------------
|
||||
let model = null;
|
||||
let mixer = null;
|
||||
let headBone = null;
|
||||
const baseActions = {}; // idle / walk / run
|
||||
const additiveActions = {}; // sneak_pose / sad_pose / agree / headShake
|
||||
let currentBase = 'walk';
|
||||
let currentAddName = 'headShake';
|
||||
let addWeight = 0.40;
|
||||
|
||||
const loader = new THREE.GLTFLoader();
|
||||
loader.load(MODEL_URL, (gltf) => {
|
||||
model = gltf.scene;
|
||||
model.position.y = 0;
|
||||
model.traverse(obj => {
|
||||
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
|
||||
if (obj.isBone && /head/i.test(obj.name) && !headBone) headBone = obj;
|
||||
});
|
||||
scene.add(model);
|
||||
|
||||
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||
skeletonHelper.visible = false;
|
||||
scene.add(skeletonHelper);
|
||||
|
||||
mixer = new THREE.AnimationMixer(model);
|
||||
const baseNames = new Set(['idle', 'walk', 'run']);
|
||||
const additiveNames = new Set(['sneak_pose', 'sad_pose', 'agree', 'headShake']);
|
||||
|
||||
for (let i = 0; i < gltf.animations.length; i++) {
|
||||
let clip = gltf.animations[i];
|
||||
const name = clip.name;
|
||||
if (baseNames.has(name)) {
|
||||
const action = mixer.clipAction(clip);
|
||||
action.enabled = true;
|
||||
action.setEffectiveTimeScale(1);
|
||||
action.setEffectiveWeight(name === currentBase ? 1 : 0);
|
||||
action.play();
|
||||
baseActions[name] = action;
|
||||
} else if (additiveNames.has(name)) {
|
||||
THREE.AnimationUtils.makeClipAdditive(clip);
|
||||
if (name.endsWith('_pose')) {
|
||||
clip = THREE.AnimationUtils.subclip(clip, name, 2, 3, 30);
|
||||
}
|
||||
const action = mixer.clipAction(clip);
|
||||
action.enabled = true;
|
||||
action.setEffectiveTimeScale(1);
|
||||
action.setEffectiveWeight(name === currentAddName ? addWeight : 0);
|
||||
action.play();
|
||||
additiveActions[name] = action;
|
||||
}
|
||||
}
|
||||
|
||||
// build the face point cloud anchored to head bone
|
||||
buildFacePointCloud();
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}, (xhr) => {
|
||||
const pct = xhr.loaded / (xhr.total || 2930032) * 100;
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = `▸ Loading skinned subject · Xbot.glb · ${pct.toFixed(0)} %`;
|
||||
}, (err) => {
|
||||
console.error('GLTF load failed', err);
|
||||
document.querySelector('#loading .text').textContent = '⚠ Load failed — see console';
|
||||
});
|
||||
|
||||
function setBase(name) {
|
||||
if (!baseActions[name]) return;
|
||||
for (const k in baseActions) {
|
||||
const a = baseActions[k];
|
||||
const target = (k === name) ? 1 : 0;
|
||||
a.crossFadeTo ? null : null; // (no-op — using simple weight crossfade)
|
||||
a.setEffectiveWeight(target);
|
||||
}
|
||||
currentBase = name;
|
||||
document.getElementById('base-name').textContent = name;
|
||||
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||
btn.classList.toggle('active', btn.dataset.base === name);
|
||||
}
|
||||
}
|
||||
function setAdditive(name) {
|
||||
for (const k in additiveActions) {
|
||||
additiveActions[k].setEffectiveWeight(k === name ? addWeight : 0);
|
||||
}
|
||||
currentAddName = name;
|
||||
document.getElementById('add-name').textContent = name + ' · ' + addWeight.toFixed(2);
|
||||
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||
btn.classList.toggle('active', btn.dataset.add === name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — anchored to head bone via getWorldPosition each frame
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 480;
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||
const facePhases = new Float32Array(FACE_POINTS);
|
||||
let facePoints = null;
|
||||
function buildFacePointCloud() {
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const u = Math.random() * Math.PI * 2;
|
||||
const v = (Math.random() - 0.5) * Math.PI * 0.95;
|
||||
const cu = Math.cos(u), su = Math.sin(u);
|
||||
const cv = Math.cos(v), sv = Math.sin(v);
|
||||
faceOffsets[i*3+0] = 0.085 * cv * cu;
|
||||
faceOffsets[i*3+1] = 0.108 * sv;
|
||||
faceOffsets[i*3+2] = 0.072 * cv * su;
|
||||
facePhases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 } },
|
||||
vertexShader: `
|
||||
attribute float aPhase; uniform float time;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||
vAlpha = 0.18 + 0.30 * shimmer;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
gl_PointSize = (1.6 + shimmer * 1.0) * (200.0 / -mv.z);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
if (d > 0.5) discard;
|
||||
float falloff = smoothstep(0.5, 0.0, d);
|
||||
vec3 col = mix(vec3(0.18, 0.52, 0.72), vec3(0.55, 0.62, 0.72), 0.5);
|
||||
gl_FragColor = vec4(col * (1.0 + falloff * 0.3), vAlpha * falloff);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
facePoints = new THREE.Points(geom, mat);
|
||||
scene.add(facePoints);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Sonar pings + tomography sweep — same as cinematic.html
|
||||
// ---------------------------------------------------------------------
|
||||
const PING_POOL = 24;
|
||||
const pings = [];
|
||||
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||
for (let i = 0; i < PING_POOL; i++) {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(pingGeo, mat);
|
||||
mesh.visible = false; scene.add(mesh);
|
||||
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||
}
|
||||
let pingIndex = 0;
|
||||
function emitPing(origin, target) {
|
||||
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||
p.active = true; p.t0 = performance.now() * 0.001;
|
||||
p.duration = 0.55 + Math.random() * 0.20;
|
||||
p.origin.copy(origin); p.target.copy(target);
|
||||
p.mesh.position.copy(origin); p.mesh.visible = true;
|
||||
p.mesh.material.opacity = 0;
|
||||
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||
}
|
||||
|
||||
const tomoMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity; varying vec2 vUv;
|
||||
void main() {
|
||||
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||
}`,
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||
});
|
||||
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||
tomoPlane.rotation.y = Math.PI / 2;
|
||||
tomoPlane.position.set(-2, 1.0, 0);
|
||||
tomoPlane.visible = false;
|
||||
scene.add(tomoPlane);
|
||||
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pseudo-CSI driver — same as cinematic
|
||||
// ---------------------------------------------------------------------
|
||||
const csiAmp = [0, 0, 0, 0];
|
||||
let csiCoherence = 0.5;
|
||||
const csiNoise = [0, 0, 0, 0];
|
||||
|
||||
function tickCsi(t, targetWorld) {
|
||||
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||
let mean = 0; const amps = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const np = NODE_POSITIONS[i];
|
||||
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||
const r2 = dx*dx + dy*dy + dz*dz;
|
||||
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||
amps.push(a);
|
||||
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||
mean += a;
|
||||
}
|
||||
mean /= 4;
|
||||
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||
v = Math.sqrt(v / 4);
|
||||
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Per-frame updates
|
||||
// ---------------------------------------------------------------------
|
||||
const tmpVec = new THREE.Vector3();
|
||||
let lastPingT = [0, 0, 0, 0];
|
||||
|
||||
function updateNodes() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ring = nodeRings[i];
|
||||
const amp = csiAmp[i];
|
||||
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||
ring.scale.setScalar(1 + 0.30 * amp);
|
||||
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||
}
|
||||
}
|
||||
function maybeEmitPings(t, modelCenter) {
|
||||
if (!document.getElementById('t-pings').checked || !model) return;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||
if (t - lastPingT[i] > interval) {
|
||||
lastPingT[i] = t;
|
||||
const target = modelCenter.clone();
|
||||
target.y += (Math.random() - 0.3) * 0.8;
|
||||
target.x += (Math.random() - 0.5) * 0.2;
|
||||
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||
emitPing(origin, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
function updatePings(t) {
|
||||
for (const p of pings) {
|
||||
if (!p.active) continue;
|
||||
const u = (t - p.t0) / p.duration;
|
||||
if (u >= 1) { p.active = false; p.mesh.visible = false; continue; }
|
||||
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||
}
|
||||
}
|
||||
function updateTomography(t) {
|
||||
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||
if (!tomoActive && t > tomoNextAt) {
|
||||
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||
const sf = document.getElementById('scan-flash');
|
||||
sf.style.animation = 'none';
|
||||
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||
}
|
||||
if (tomoActive) {
|
||||
const dur = 2.4;
|
||||
const e = (t - tomoT0) / dur;
|
||||
if (e >= 1) {
|
||||
tomoActive = false; tomoPlane.visible = false;
|
||||
tomoNextAt = t + 4 + Math.random() * 5;
|
||||
} else {
|
||||
tomoPlane.position.x = -3 + e * 6;
|
||||
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||
tomoMat.uniforms.time.value = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateBbox() {
|
||||
const want = document.getElementById('t-bbox').checked && model;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
document.getElementById('bbox-vol').textContent = '—';
|
||||
return;
|
||||
}
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||
scene.add(bboxHelper);
|
||||
} else bboxHelper.setFromObject(model);
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
function updateFaceCloud(t) {
|
||||
if (!facePoints || !headBone) return;
|
||||
const headWorld = new THREE.Vector3();
|
||||
headBone.getWorldPosition(headWorld);
|
||||
const pos = facePoints.geometry.attributes.position;
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
pos.array[i*3+0] = headWorld.x + faceOffsets[i*3+0];
|
||||
pos.array[i*3+1] = headWorld.y + faceOffsets[i*3+1] + 0.06;
|
||||
pos.array[i*3+2] = headWorld.z + faceOffsets[i*3+2];
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
facePoints.material.uniforms.time.value = t;
|
||||
}
|
||||
let hudT = 0;
|
||||
function updateHud(t, fps) {
|
||||
if (t - hudT < 0.1) return;
|
||||
hudT = t;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const pct = Math.round(csiAmp[i] * 100);
|
||||
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||
document.getElementById('val-' + i).textContent = pct + '%';
|
||||
}
|
||||
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UI wiring
|
||||
// ---------------------------------------------------------------------
|
||||
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||
btn.addEventListener('click', () => setBase(btn.dataset.base));
|
||||
}
|
||||
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||
btn.addEventListener('click', () => setAdditive(btn.dataset.add));
|
||||
}
|
||||
document.getElementById('add-weight').addEventListener('input', (e) => {
|
||||
addWeight = parseFloat(e.target.value);
|
||||
document.getElementById('add-weight-val').textContent = addWeight.toFixed(2);
|
||||
if (additiveActions[currentAddName]) additiveActions[currentAddName].setEffectiveWeight(addWeight);
|
||||
document.getElementById('add-name').textContent = currentAddName + ' · ' + addWeight.toFixed(2);
|
||||
});
|
||||
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||
const ts = parseFloat(e.target.value);
|
||||
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||
if (mixer) mixer.timeScale = ts;
|
||||
});
|
||||
function bindToggle(id, obj) {
|
||||
document.getElementById(id).addEventListener('change', e => {
|
||||
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||
else if (!e.target.checked) scene.remove(obj);
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
document.getElementById('t-skel').addEventListener('change', e => {
|
||||
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||
});
|
||||
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||
else if (!e.target.checked) scene.remove(bb);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main loop
|
||||
// ---------------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let lastMs = performance.now();
|
||||
let fpsEma = 60;
|
||||
function tick() {
|
||||
const nowMs = performance.now();
|
||||
const dt = nowMs - lastMs;
|
||||
lastMs = nowMs;
|
||||
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||
const t = nowMs * 0.001;
|
||||
const delta = clock.getDelta();
|
||||
|
||||
if (mixer) mixer.update(delta);
|
||||
floorMat.uniforms.time.value = t;
|
||||
filmShader.uniforms.time.value = t;
|
||||
|
||||
// get model center for CSI / ping targeting
|
||||
const center = new THREE.Vector3();
|
||||
if (model) {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
} else center.set(0, 0.9, 0);
|
||||
|
||||
tickCsi(t, center);
|
||||
updateNodes();
|
||||
maybeEmitPings(t, center);
|
||||
updatePings(t);
|
||||
updateTomography(t);
|
||||
updateBbox();
|
||||
updateFaceCloud(t);
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
|
||||
updateHud(t, fpsEma);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,961 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · Skinned (FBX) · Mixamo X Bot in the ADR-097 helpers scene</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #050507; --bg-panel: rgba(8,10,14,0.78);
|
||||
--amber: #ffb840; --amber-hot: #ffe09f;
|
||||
--cyan: #4cf; --magenta: #ff4cc8;
|
||||
--text: #d8c69a; --text-mute: #6b6155;
|
||||
--border: rgba(255,184,64,0.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||
}
|
||||
canvas { display: block; }
|
||||
.overlay-frame {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||
}
|
||||
.scanlines {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||
mix-blend-mode: overlay; opacity: 0.5;
|
||||
}
|
||||
.panel {
|
||||
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||
}
|
||||
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||
#info .row .v.amber { color: var(--amber); }
|
||||
#info .row .v.cyan { color: var(--cyan); }
|
||||
#info .row .v.mag { color: var(--magenta); }
|
||||
|
||||
#anim {
|
||||
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#anim .row { padding: 6px 0; font-size: 10px; }
|
||||
#anim .row .label { color: var(--text-mute); margin-right: 8px; }
|
||||
#anim button {
|
||||
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; margin-top: 6px; border-top: 1px solid rgba(255,184,64,0.08); padding-top: 8px; }
|
||||
#anim .slider-row .label { width: 90px; }
|
||||
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
#anim .empty-hint {
|
||||
font-size: 10px; color: var(--text-mute); line-height: 1.5; margin-top: 4px;
|
||||
padding: 8px; background: rgba(255,184,64,0.04); border-radius: 3px;
|
||||
border-left: 2px solid var(--amber);
|
||||
}
|
||||
#anim .empty-hint a { color: var(--amber); text-decoration: none; }
|
||||
#anim .empty-hint a:hover { color: var(--amber-hot); text-decoration: underline; }
|
||||
|
||||
#helpers {
|
||||
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
#helpers label {
|
||||
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||
}
|
||||
#helpers label:hover { color: var(--amber-hot); }
|
||||
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||
|
||||
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||
#csi .bar-row .bar-fill {
|
||||
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||
}
|
||||
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
|
||||
#loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(5,5,7,0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||
letter-spacing: 2px; text-transform: uppercase;
|
||||
}
|
||||
#loading.hidden { display: none; }
|
||||
#loading .text { text-shadow: 0 0 12px var(--amber); animation: loadPulse 1.4s ease-in-out infinite; }
|
||||
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||
|
||||
@keyframes scanFlash { 0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; } }
|
||||
.scan-flash {
|
||||
position: fixed; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||
}
|
||||
|
||||
#titlecard {
|
||||
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||
}
|
||||
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="https://unpkg.com/fflate@0.7.4/umd/index.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/curves/NURBSCurve.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-frame"></div>
|
||||
<div class="scanlines"></div>
|
||||
<div class="scan-flash" id="scan-flash"></div>
|
||||
<div id="loading"><div class="text">▸ Loading skinned subject · X Bot.fbx</div></div>
|
||||
|
||||
<div class="panel" id="info">
|
||||
<h1>RuView · Skinned (FBX)</h1>
|
||||
<div class="sub">ADR-097 · Mixamo X Bot · loaded via FBXLoader</div>
|
||||
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||
<div class="row"><span class="k">Source</span><span class="v" id="src-name">X Bot.fbx</span></div>
|
||||
<div class="row"><span class="k">Format</span><span class="v">FBX 7700 · 1.75 MB</span></div>
|
||||
<div class="row"><span class="k">Bones</span><span class="v" id="bone-count">—</span></div>
|
||||
<div class="row"><span class="k">Animation</span><span class="v amber" id="anim-name">—</span></div>
|
||||
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||
</div>
|
||||
|
||||
<div id="anim">
|
||||
<h2>AnimationMixer</h2>
|
||||
<div class="row">
|
||||
<span class="label">clips</span>
|
||||
<span id="clip-buttons"></span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="label">time scale</span>
|
||||
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||
<span class="val" id="time-scale-val">1.00</span>
|
||||
</div>
|
||||
<div class="empty-hint" id="empty-hint" style="display:none;">
|
||||
<strong>No animations in this FBX.</strong><br>
|
||||
Mixamo's "T-Pose / Without Skin" export rigs the model but has no clips.
|
||||
Re-download with <em>"Original Pose"</em> + an animation selected
|
||||
(e.g. <a href="https://www.mixamo.com/#/?page=1&query=walking&type=Motion%2CMotionPack" target="_blank" rel="noopener">Walking</a>) to get a clip, or drop another FBX with anim and reload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="csi">
|
||||
<h2>Per-node CSI</h2>
|
||||
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0"></div></div><span class="val" id="val-0">—</span></div>
|
||||
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1"></div></div><span class="val" id="val-1">—</span></div>
|
||||
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2"></div></div><span class="val" id="val-2">—</span></div>
|
||||
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3"></div></div><span class="val" id="val-3">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="helpers">
|
||||
<h2>ADR-097 helpers</h2>
|
||||
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||
<label><input type="checkbox" id="t-rays" checked>RF illumination cones<span class="swatch" style="color:#ffb840"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="titlecard">
|
||||
RuView · Seldon Vault
|
||||
<div class="sub">FBXLoader · Mixamo · ADR-097</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// =====================================================================
|
||||
// RuView · Skinned (FBX) · Mixamo X Bot loaded via FBXLoader
|
||||
// --------------------------------------------------------------------
|
||||
// Sibling of helpers-skinned.html that loads a local .fbx file
|
||||
// rather than the canonical GLB. Same cinematic atmosphere
|
||||
// (UnrealBloomPass, sonar pings, tomography sweep, pseudo-CSI),
|
||||
// same ADR-097 helpers wrapping the rigged mesh.
|
||||
//
|
||||
// Mixamo FBX caveats handled here:
|
||||
// 1. Mixamo exports in cm (100 = 1 m). We auto-detect by the
|
||||
// loaded model's bbox height and rescale to ~1.7 m human size.
|
||||
// 2. PhongMaterial → StandardMaterial swap for cleaner shading
|
||||
// under our amber key + cyan rim lights.
|
||||
// 3. Bone name probing for the head (Mixamo: "mixamorigHead",
|
||||
// legacy: "Bip01_Head", or any bone with /head/i match).
|
||||
// 4. Graceful no-animations case — many Mixamo exports are
|
||||
// rig-only.
|
||||
// =====================================================================
|
||||
|
||||
const MODEL_URL = '../assets/X%20Bot.fbx';
|
||||
|
||||
const NODE_POSITIONS = [
|
||||
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scene / camera / renderer
|
||||
// ---------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050507);
|
||||
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||
camera.position.set(3.2, 1.55, 4.0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.80;
|
||||
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0.9, 0);
|
||||
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
||||
controls.minDistance = 2; controls.maxDistance = 12;
|
||||
controls.maxPolarAngle = Math.PI * 0.92;
|
||||
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||
controls.autoRotateSpeed = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lights — amber key + cyan rim from each ESP32 direction
|
||||
// ---------------------------------------------------------------------
|
||||
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffc070, 1.05);
|
||||
keyLight.position.set(2.5, 3.8, 2.5);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.bias = -0.0008;
|
||||
scene.add(keyLight);
|
||||
|
||||
const rimLights = [];
|
||||
NODE_POSITIONS.forEach(pos => {
|
||||
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||
scene.add(rim); rimLights.push(rim);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// ---------------------------------------------------------------------
|
||||
const composer = new THREE.EffectComposer(renderer);
|
||||
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||
const bloom = new THREE.UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
0.45, 0.40, 0.78,
|
||||
);
|
||||
composer.addPass(bloom);
|
||||
const filmShader = {
|
||||
uniforms: { tDiffuse: { value: null }, time: { value: 0 }, grain: { value: 0.04 },
|
||||
vignette: { value: 0.32 }, aberration: { value: 0.0018 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||
varying vec2 vUv;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
vec2 off = (vUv - 0.5) * aberration;
|
||||
float r = texture2D(tDiffuse, vUv + off).r;
|
||||
float g = texture2D(tDiffuse, vUv).g;
|
||||
float b = texture2D(tDiffuse, vUv - off).b;
|
||||
vec3 col = vec3(r, g, b);
|
||||
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||
col *= mix(1.0 - vignette, 1.0, v);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
};
|
||||
const filmPass = new THREE.ShaderPass(filmShader);
|
||||
composer.addPass(filmPass);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Floor (same procedural shader as cinematic / skinned-glb)
|
||||
// ---------------------------------------------------------------------
|
||||
const floorMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||
void main() {
|
||||
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||
scan = pow(scan, 14.0);
|
||||
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
scene.add(floor);
|
||||
|
||||
const shadowGround = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||
);
|
||||
shadowGround.rotation.x = -Math.PI / 2;
|
||||
shadowGround.position.y = 0.001;
|
||||
shadowGround.receiveShadow = true;
|
||||
scene.add(shadowGround);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ADR-097 helpers + sensor nodes (same as helpers-skinned.html)
|
||||
// ---------------------------------------------------------------------
|
||||
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||
polarHelper.position.y = 0.002;
|
||||
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||
scene.add(polarHelper);
|
||||
|
||||
let bboxHelper = null;
|
||||
let skeletonHelper = null;
|
||||
|
||||
const nodeBboxHelpers = [];
|
||||
const nodeRings = [];
|
||||
const nodeAnchors = [];
|
||||
NODE_POSITIONS.forEach((pos, i) => {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(pos[0], pos[1], pos[2]);
|
||||
|
||||
group.add(new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.14, 0.06, 0.20),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840 })
|
||||
));
|
||||
const antenna = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.018, 0.10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
antenna.position.y = 0.08; group.add(antenna);
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide,
|
||||
transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||
group.add(ring); nodeRings.push(ring);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.025, 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||
);
|
||||
core.position.y = 0.04; group.add(core);
|
||||
|
||||
scene.add(group); nodeAnchors.push(group);
|
||||
|
||||
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// God-ray cones — one per node, pointed at the subject. Visualizes
|
||||
// "the four ESP32s are jointly illuminating the body with RF". Each
|
||||
// cone has a volumetric-feeling gradient shader and is opacity-
|
||||
// modulated by that node's csiAmp × csiCoherence (so when a node's
|
||||
// signal degrades, its ray dims).
|
||||
// ---------------------------------------------------------------------
|
||||
const godRayMat = (color, idx) => new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
intensity: { value: 0.0 },
|
||||
color: { value: new THREE.Color(color) },
|
||||
seed: { value: idx * 17.3 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
varying float vY;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vY = position.y;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity, seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
varying float vY;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
void main() {
|
||||
// along the cone (uv.y goes 0=tip → 1=base), fade out at the base
|
||||
float edgeFade = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.65, vUv.y);
|
||||
// soft radial falloff (cone-edge transparency)
|
||||
float radial = sin(vUv.y * 3.14159);
|
||||
radial = pow(radial, 2.0);
|
||||
// volumetric noise (slow scrolling)
|
||||
float n = hash(floor(vUv * vec2(40.0, 60.0)) + vec2(seed, time * 0.4));
|
||||
float scroll = 0.85 + 0.30 * sin(vUv.y * 32.0 - time * 1.4 + seed);
|
||||
float a = edgeFade * radial * scroll * (0.55 + 0.45 * n);
|
||||
gl_FragColor = vec4(color, a * intensity * 0.25);
|
||||
}`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const godRays = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// cone with apex at node, expanding toward the subject
|
||||
// height 4 m (more than enough to reach subject), radius 0.45 m at base
|
||||
const geom = new THREE.ConeGeometry(0.45, 4.0, 28, 1, true);
|
||||
// ConeGeometry tip is at +Y, base at -Y — rotate so tip is along -Y
|
||||
// (we'll later orient each cone so its tip touches the node).
|
||||
geom.translate(0, -2.0, 0); // shift so apex is at origin
|
||||
const mat = godRayMat(0xffb840, i);
|
||||
const cone = new THREE.Mesh(geom, mat);
|
||||
scene.add(cone);
|
||||
godRays.push({ mesh: cone, mat });
|
||||
}
|
||||
function updateGodRays(t) {
|
||||
if (!model) return;
|
||||
const want = document.getElementById('t-rays').checked;
|
||||
const center = new THREE.Vector3();
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
godRays[i].mesh.visible = want;
|
||||
if (!want) continue;
|
||||
const node = nodeAnchors[i];
|
||||
const np = node.getWorldPosition(new THREE.Vector3());
|
||||
const dir = new THREE.Vector3().subVectors(center, np);
|
||||
const len = dir.length();
|
||||
dir.normalize();
|
||||
const ray = godRays[i];
|
||||
ray.mesh.position.copy(np);
|
||||
// align cone's -Y axis (apex direction after the geometry shift)
|
||||
// to point along `dir`
|
||||
ray.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir);
|
||||
// stretch cone length to actual distance, keep base width reasonable
|
||||
ray.mesh.scale.set(1, len / 4.0, 1);
|
||||
ray.mat.uniforms.time.value = t;
|
||||
// intensity follows that node's CSI amplitude * global coherence
|
||||
const target = csiAmp[i] * csiCoherence * 1.4;
|
||||
ray.mat.uniforms.intensity.value =
|
||||
ray.mat.uniforms.intensity.value * 0.85 + target * 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// FBX load with Mixamo-aware fixups
|
||||
// ---------------------------------------------------------------------
|
||||
let model = null;
|
||||
let mixer = null;
|
||||
let headBone = null;
|
||||
let boneCount = 0;
|
||||
const clipActions = {}; // by clip name
|
||||
let currentClip = null;
|
||||
|
||||
const loader = new THREE.FBXLoader();
|
||||
loader.load(MODEL_URL, (object) => {
|
||||
model = object;
|
||||
|
||||
// 1. Scale fix — Mixamo defaults to cm; detect by bbox height and
|
||||
// rescale so the rig reads as ~1.7 m human size.
|
||||
const raw = new THREE.Box3().setFromObject(model);
|
||||
const height = raw.max.y - raw.min.y;
|
||||
if (height > 10) {
|
||||
model.scale.setScalar(1 / 100); // cm → m
|
||||
} else if (height > 5) {
|
||||
model.scale.setScalar(1 / 50); // catch in-between rigs
|
||||
}
|
||||
// recenter on origin at floor
|
||||
const b2 = new THREE.Box3().setFromObject(model);
|
||||
model.position.y -= b2.min.y;
|
||||
|
||||
// 2. Material upgrade + shadow casting + head/bone scan
|
||||
model.traverse((obj) => {
|
||||
if (obj.isMesh) {
|
||||
obj.castShadow = true;
|
||||
obj.receiveShadow = true;
|
||||
// Phong → Standard for cleaner shading under our PBR lights.
|
||||
// Keep diffuse map + skinning intact.
|
||||
if (obj.material && obj.material.isMeshPhongMaterial) {
|
||||
const m = obj.material;
|
||||
const upgraded = new THREE.MeshStandardMaterial({
|
||||
map: m.map, normalMap: m.normalMap, color: m.color,
|
||||
skinning: !!obj.isSkinnedMesh,
|
||||
metalness: 0.0, roughness: 0.85,
|
||||
});
|
||||
obj.material = upgraded;
|
||||
}
|
||||
}
|
||||
if (obj.isBone) {
|
||||
boneCount++;
|
||||
if (!headBone && /head/i.test(obj.name)) headBone = obj;
|
||||
}
|
||||
});
|
||||
document.getElementById('bone-count').textContent = boneCount;
|
||||
|
||||
scene.add(model);
|
||||
|
||||
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||
skeletonHelper.visible = false;
|
||||
scene.add(skeletonHelper);
|
||||
|
||||
// 3. Animations — Mixamo exports one clip per FBX (sometimes none)
|
||||
const clips = object.animations || [];
|
||||
if (clips.length === 0) {
|
||||
document.getElementById('anim-name').textContent = 'none (rig-only)';
|
||||
document.getElementById('empty-hint').style.display = 'block';
|
||||
} else {
|
||||
mixer = new THREE.AnimationMixer(model);
|
||||
const btnHost = document.getElementById('clip-buttons');
|
||||
for (const clip of clips) {
|
||||
const action = mixer.clipAction(clip);
|
||||
clipActions[clip.name] = action;
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = clip.name || 'clip-' + Object.keys(clipActions).length;
|
||||
btn.addEventListener('click', () => playClip(clip.name));
|
||||
btnHost.appendChild(btn);
|
||||
}
|
||||
playClip(clips[0].name);
|
||||
}
|
||||
|
||||
// 4. Face point cloud
|
||||
if (headBone) buildFacePointCloud();
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}, (xhr) => {
|
||||
const total = xhr.total || 1750032;
|
||||
const pct = (xhr.loaded / total * 100).toFixed(0);
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = `▸ Loading skinned subject · X Bot.fbx · ${pct} %`;
|
||||
}, (err) => {
|
||||
console.error('FBX load failed', err);
|
||||
const txt = document.querySelector('#loading .text');
|
||||
if (txt) txt.textContent = '⚠ Load failed — see console';
|
||||
});
|
||||
|
||||
function playClip(name) {
|
||||
for (const k in clipActions) {
|
||||
const a = clipActions[k];
|
||||
if (k === name) {
|
||||
a.reset(); a.play(); currentClip = name;
|
||||
document.getElementById('anim-name').textContent = name;
|
||||
for (const btn of document.querySelectorAll('#anim button[data-base], #anim button')) {
|
||||
if (btn.dataset.base !== undefined || !btn.textContent) continue;
|
||||
btn.classList.toggle('active', btn.textContent === name);
|
||||
}
|
||||
} else a.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Face point cloud — anchored to head bone, same shimmer shader
|
||||
// ---------------------------------------------------------------------
|
||||
const FACE_POINTS = 220; // fewer points so each dot is visible as a tracked landmark
|
||||
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||
const facePhases = new Float32Array(FACE_POINTS);
|
||||
let facePoints = null;
|
||||
function buildFacePointCloud() {
|
||||
// Front-hemisphere only — points scattered on the +Z half of an
|
||||
// ellipsoid so the cloud reads as a FACE projection forward from
|
||||
// the head bone, not a halo wrapping the skull. Local coords:
|
||||
// +Z = forward (face direction), +Y = up, +X = right.
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
// theta in [0, 2π) around the local Z axis, phi in [0, π/2]
|
||||
// (front hemisphere only — no points behind the head)
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(1 - Math.random() * 0.95); // dense near face front
|
||||
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
|
||||
// ellipsoid radii (taller than wide, slightly squashed F-B)
|
||||
const rx = 0.085, ry = 0.108, rz = 0.075;
|
||||
// local coords with +Z = face forward
|
||||
faceOffsets[i*3+0] = rx * sinPhi * Math.cos(theta);
|
||||
faceOffsets[i*3+1] = ry * sinPhi * Math.sin(theta) * 1.05; // taller
|
||||
faceOffsets[i*3+2] = rz * cosPhi; // forward
|
||||
facePhases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 } },
|
||||
vertexShader: `
|
||||
attribute float aPhase; uniform float time;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
// Slow per-point shimmer + occasional "scan-lit" spike
|
||||
// so the cloud reads as discrete tracked landmarks
|
||||
// rather than a fluffy halo.
|
||||
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||
float spark = step(0.95, fract(sin(aPhase * 17.0 + time * 0.5) * 43.0));
|
||||
vAlpha = 0.10 + 0.25 * shimmer + 0.55 * spark;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
// 6× smaller — tracked dots, not a cloud
|
||||
gl_PointSize = (1.0 + shimmer * 0.6 + spark * 1.5) * (32.0 / -mv.z);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
if (d > 0.5) discard;
|
||||
float falloff = smoothstep(0.5, 0.0, d);
|
||||
vec3 col = vec3(0.40, 0.78, 1.00);
|
||||
gl_FragColor = vec4(col, vAlpha * falloff);
|
||||
}`,
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
facePoints = new THREE.Points(geom, mat);
|
||||
scene.add(facePoints);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pings + tomography + CSI driver — copied wholesale from skinned-glb
|
||||
// ---------------------------------------------------------------------
|
||||
const PING_POOL = 24;
|
||||
const pings = [];
|
||||
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||
for (let i = 0; i < PING_POOL; i++) {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(pingGeo, mat); mesh.visible = false; scene.add(mesh);
|
||||
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||
}
|
||||
let pingIndex = 0;
|
||||
function emitPing(origin, target) {
|
||||
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||
p.active = true; p.t0 = performance.now() * 0.001;
|
||||
p.duration = 0.55 + Math.random() * 0.20;
|
||||
p.origin.copy(origin); p.target.copy(target);
|
||||
p.mesh.position.copy(origin); p.mesh.visible = true; p.mesh.material.opacity = 0;
|
||||
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||
}
|
||||
|
||||
const tomoMat = new THREE.ShaderMaterial({
|
||||
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
uniform float time, intensity; varying vec2 vUv;
|
||||
void main() {
|
||||
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||
}`,
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||
});
|
||||
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||
tomoPlane.rotation.y = Math.PI / 2;
|
||||
tomoPlane.position.set(-2, 1.0, 0); tomoPlane.visible = false;
|
||||
scene.add(tomoPlane);
|
||||
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||
|
||||
const csiAmp = [0, 0, 0, 0];
|
||||
let csiCoherence = 0.5;
|
||||
const csiNoise = [0, 0, 0, 0];
|
||||
function tickCsi(t, targetWorld) {
|
||||
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||
let mean = 0; const amps = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const np = NODE_POSITIONS[i];
|
||||
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||
const r2 = dx*dx + dy*dy + dz*dz;
|
||||
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||
amps.push(a);
|
||||
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||
mean += a;
|
||||
}
|
||||
mean /= 4;
|
||||
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||
v = Math.sqrt(v / 4);
|
||||
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||
}
|
||||
|
||||
let lastPingT = [0, 0, 0, 0];
|
||||
// Subject hit-flash: when a sonar ping lands, briefly raise the
|
||||
// emissive on every mesh in the model. Decays each frame.
|
||||
let subjectFlash = 0;
|
||||
const modelMeshes = [];
|
||||
function collectModelMeshes() {
|
||||
if (!model || modelMeshes.length) return;
|
||||
model.traverse(o => {
|
||||
if (o.isMesh && o.material && o.material.isMeshStandardMaterial) {
|
||||
o.material.emissive = new THREE.Color(0xffb840);
|
||||
o.material.emissiveIntensity = 0;
|
||||
modelMeshes.push(o);
|
||||
}
|
||||
});
|
||||
}
|
||||
function updateSubjectFlash() {
|
||||
collectModelMeshes();
|
||||
subjectFlash *= 0.86;
|
||||
for (const m of modelMeshes) {
|
||||
m.material.emissiveIntensity = subjectFlash;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle root motion — even with a stationary Idle clip, give the
|
||||
// figure a gentle drift + look-around so it doesn't feel pinned.
|
||||
function updateRootMotion(t) {
|
||||
if (!model) return;
|
||||
model.position.x = Math.sin(t * 0.18) * 0.06;
|
||||
model.position.z = Math.cos(t * 0.13) * 0.05;
|
||||
model.rotation.y = Math.sin(t * 0.11) * 0.18;
|
||||
}
|
||||
|
||||
function updateNodes() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ring = nodeRings[i];
|
||||
const amp = csiAmp[i];
|
||||
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||
ring.scale.setScalar(1 + 0.30 * amp);
|
||||
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||
}
|
||||
}
|
||||
function maybeEmitPings(t, modelCenter) {
|
||||
if (!document.getElementById('t-pings').checked || !model) return;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||
if (t - lastPingT[i] > interval) {
|
||||
lastPingT[i] = t;
|
||||
const target = modelCenter.clone();
|
||||
target.y += (Math.random() - 0.3) * 0.8;
|
||||
target.x += (Math.random() - 0.5) * 0.2;
|
||||
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||
emitPing(origin, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
function updatePings(t) {
|
||||
for (const p of pings) {
|
||||
if (!p.active) continue;
|
||||
const u = (t - p.t0) / p.duration;
|
||||
if (u >= 1) {
|
||||
p.active = false; p.mesh.visible = false;
|
||||
// ping landed — flash the subject (drives emissiveIntensity)
|
||||
subjectFlash = Math.min(0.42, subjectFlash + 0.18);
|
||||
continue;
|
||||
}
|
||||
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||
}
|
||||
}
|
||||
function updateTomography(t) {
|
||||
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||
if (!tomoActive && t > tomoNextAt) {
|
||||
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||
const sf = document.getElementById('scan-flash');
|
||||
sf.style.animation = 'none';
|
||||
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||
}
|
||||
if (tomoActive) {
|
||||
const dur = 2.4;
|
||||
const e = (t - tomoT0) / dur;
|
||||
if (e >= 1) {
|
||||
tomoActive = false; tomoPlane.visible = false;
|
||||
tomoNextAt = t + 4 + Math.random() * 5;
|
||||
} else {
|
||||
tomoPlane.position.x = -3 + e * 6;
|
||||
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||
tomoMat.uniforms.time.value = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateBbox() {
|
||||
const want = document.getElementById('t-bbox').checked && model;
|
||||
if (!want) {
|
||||
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||
document.getElementById('bbox-vol').textContent = '—';
|
||||
return;
|
||||
}
|
||||
if (!bboxHelper) {
|
||||
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||
scene.add(bboxHelper);
|
||||
} else bboxHelper.setFromObject(model);
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||
}
|
||||
const tmpHeadPos = new THREE.Vector3();
|
||||
const tmpHeadQuat = new THREE.Quaternion();
|
||||
const tmpHeadScl = new THREE.Vector3();
|
||||
const tmpOffset = new THREE.Vector3();
|
||||
function updateFaceCloud(t) {
|
||||
if (!facePoints || !headBone) return;
|
||||
// Decompose the head bone's world matrix so we can apply its
|
||||
// orientation (face direction) to each local offset. This way
|
||||
// the cloud rotates with the head — turn left/right and the
|
||||
// face points stay in front of the face.
|
||||
headBone.updateMatrixWorld(true);
|
||||
headBone.matrixWorld.decompose(tmpHeadPos, tmpHeadQuat, tmpHeadScl);
|
||||
// Mixamo head bone forward is along +Y in some rigs (head looks up the
|
||||
// bone chain) — project the cloud along the model's actual forward
|
||||
// vector, which for Mixamo X Bot facing camera is world +Z.
|
||||
// Use the model's root rotation as the source of "forward".
|
||||
const forward = new THREE.Vector3(0, 0, 1);
|
||||
if (model) forward.applyQuaternion(model.getWorldQuaternion(new THREE.Quaternion()));
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
|
||||
const facingUp = up.clone();
|
||||
// anchor the cloud just in front of the head
|
||||
const anchor = tmpHeadPos.clone().addScaledVector(forward, 0.04);
|
||||
anchor.y += 0.04; // nudge up so cloud sits over the face, not the chin
|
||||
const pos = facePoints.geometry.attributes.position;
|
||||
for (let i = 0; i < FACE_POINTS; i++) {
|
||||
const ox = faceOffsets[i*3+0];
|
||||
const oy = faceOffsets[i*3+1];
|
||||
const oz = faceOffsets[i*3+2];
|
||||
// map local (ox, oy, oz) into world via (right, up, forward)
|
||||
tmpOffset.copy(right).multiplyScalar(ox)
|
||||
.addScaledVector(facingUp, oy)
|
||||
.addScaledVector(forward, oz);
|
||||
pos.array[i*3+0] = anchor.x + tmpOffset.x;
|
||||
pos.array[i*3+1] = anchor.y + tmpOffset.y;
|
||||
pos.array[i*3+2] = anchor.z + tmpOffset.z;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
facePoints.material.uniforms.time.value = t;
|
||||
}
|
||||
|
||||
let hudT = 0;
|
||||
function updateHud(t, fps) {
|
||||
if (t - hudT < 0.1) return;
|
||||
hudT = t;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const pct = Math.round(csiAmp[i] * 100);
|
||||
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||
document.getElementById('val-' + i).textContent = pct + '%';
|
||||
}
|
||||
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||
}
|
||||
|
||||
// UI wiring
|
||||
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||
const ts = parseFloat(e.target.value);
|
||||
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||
if (mixer) mixer.timeScale = ts;
|
||||
});
|
||||
function bindToggle(id, obj) {
|
||||
document.getElementById(id).addEventListener('change', e => {
|
||||
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||
else if (!e.target.checked) scene.remove(obj);
|
||||
});
|
||||
}
|
||||
bindToggle('t-grid', gridHelper);
|
||||
bindToggle('t-polar', polarHelper);
|
||||
document.getElementById('t-skel').addEventListener('change', e => {
|
||||
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||
});
|
||||
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||
for (const bb of nodeBboxHelpers) {
|
||||
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||
else if (!e.target.checked) scene.remove(bb);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main loop
|
||||
// ---------------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let lastMs = performance.now();
|
||||
let fpsEma = 60;
|
||||
function tick() {
|
||||
const nowMs = performance.now();
|
||||
const dt = nowMs - lastMs;
|
||||
lastMs = nowMs;
|
||||
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||
const t = nowMs * 0.001;
|
||||
const delta = clock.getDelta();
|
||||
|
||||
if (mixer) mixer.update(delta);
|
||||
floorMat.uniforms.time.value = t;
|
||||
filmShader.uniforms.time.value = t;
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
if (model) {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
box.getCenter(center);
|
||||
} else center.set(0, 0.9, 0);
|
||||
|
||||
tickCsi(t, center);
|
||||
updateRootMotion(t);
|
||||
updateNodes();
|
||||
updateGodRays(t);
|
||||
maybeEmitPings(t, center);
|
||||
updatePings(t);
|
||||
updateSubjectFlash();
|
||||
updateTomography(t);
|
||||
updateBbox();
|
||||
updateFaceCloud(t);
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
updateHud(t, fpsEma);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 682 KiB |
|
After Width: | Height: | Size: 596 KiB |
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ruvultra → browser CSI bridge.
|
||||
|
||||
Reads adaptive_ctrl tick lines from the ESP32-S3 RuView firmware on
|
||||
/dev/ttyACM0 and forwards normalized per-node metrics over a WebSocket
|
||||
that the helpers-skinned-realtime demo can subscribe to via Tailscale.
|
||||
|
||||
Sample serial line (1 Hz cadence from firmware):
|
||||
I (22890561) adaptive_ctrl: medium tick: state=6 yield=15pps motion=1.00 presence=5.35 rssi=-33
|
||||
|
||||
Output JSON (per tick):
|
||||
{
|
||||
"ts": 1716830400.123,
|
||||
"node": 0, # always 0 (single node), client expands to 4
|
||||
"motion": 1.00, # raw firmware metric
|
||||
"presence": 5.35,
|
||||
"rssi": -33,
|
||||
"yield_pps": 15,
|
||||
"amp": 0.78 # synthesized CSI amplitude in [0..1] for the bar
|
||||
}
|
||||
|
||||
Run on ruvultra:
|
||||
python3 -u ruvultra-csi-bridge.py
|
||||
"""
|
||||
import asyncio
|
||||
import builtins
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
# Force every print to flush — we're often piped to a log file
|
||||
_orig_print = builtins.print
|
||||
def _print(*a, **kw):
|
||||
kw.setdefault("flush", True)
|
||||
return _orig_print(*a, **kw)
|
||||
builtins.print = _print
|
||||
|
||||
import serial
|
||||
import websockets
|
||||
|
||||
PORT = "/dev/ttyACM0"
|
||||
BAUD = 115200
|
||||
WS_HOST = "0.0.0.0"
|
||||
WS_PORT = 8766
|
||||
|
||||
TICK_RE = re.compile(
|
||||
r"adaptive_ctrl:\s*\w+\s+tick:\s*"
|
||||
r"state=(?P<state>\d+)\s+"
|
||||
r"yield=(?P<yield>\d+)pps\s+"
|
||||
r"motion=(?P<motion>[\d.]+)\s+"
|
||||
r"presence=(?P<presence>[\d.]+)\s+"
|
||||
r"rssi=(?P<rssi>-?\d+)"
|
||||
)
|
||||
|
||||
clients = set()
|
||||
last_payload = None
|
||||
|
||||
|
||||
def amp_from_metrics(motion, presence, rssi):
|
||||
"""Map firmware metrics to a [0..1] CSI-style amplitude."""
|
||||
rssi_norm = max(0.0, min(1.0, (rssi + 80) / 50)) # -80..-30 → 0..1
|
||||
presence_norm = max(0.0, min(1.0, presence / 8.0)) # cap at 8
|
||||
motion_norm = max(0.0, min(1.0, motion)) # already 0..1ish
|
||||
return 0.40 * rssi_norm + 0.35 * presence_norm + 0.25 * motion_norm
|
||||
|
||||
|
||||
async def serial_reader_loop():
|
||||
global last_payload
|
||||
print(f"[bridge] opening {PORT} @ {BAUD}…")
|
||||
while True:
|
||||
try:
|
||||
ser = serial.Serial(PORT, BAUD, timeout=1)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
print(f"[bridge] serial open failed ({e}); retry in 3s")
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
print(f"[bridge] connected to {PORT}")
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
while True:
|
||||
line = await loop.run_in_executor(None, ser.readline)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
text = line.decode(errors="replace").strip()
|
||||
except Exception:
|
||||
continue
|
||||
m = TICK_RE.search(text)
|
||||
if not m:
|
||||
continue
|
||||
motion = float(m["motion"])
|
||||
presence = float(m["presence"])
|
||||
rssi = int(m["rssi"])
|
||||
payload = {
|
||||
"ts": time.time(),
|
||||
"node": 0,
|
||||
"state": int(m["state"]),
|
||||
"yield_pps": int(m["yield"]),
|
||||
"motion": motion,
|
||||
"presence": presence,
|
||||
"rssi": rssi,
|
||||
"amp": amp_from_metrics(motion, presence, rssi),
|
||||
}
|
||||
last_payload = payload
|
||||
msg = json.dumps(payload)
|
||||
if clients:
|
||||
dead = []
|
||||
for ws in list(clients):
|
||||
try:
|
||||
await ws.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
dead.append(ws)
|
||||
for d in dead:
|
||||
clients.discard(d)
|
||||
print(
|
||||
f"[tick] motion={motion:.2f} presence={presence:5.2f} "
|
||||
f"rssi={rssi:+d} yield={int(m['yield']):3d}pps "
|
||||
f"amp={payload['amp']:.2f} clients={len(clients)}"
|
||||
)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
print(f"[bridge] serial error ({e}); reopen in 1s")
|
||||
with suppress(Exception):
|
||||
ser.close()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def ws_handler(ws):
|
||||
addr = ws.remote_address
|
||||
clients.add(ws)
|
||||
print(f"[ws] client connected: {addr} total={len(clients)}")
|
||||
try:
|
||||
if last_payload is not None:
|
||||
await ws.send(json.dumps(last_payload))
|
||||
await ws.wait_closed()
|
||||
finally:
|
||||
clients.discard(ws)
|
||||
print(f"[ws] client gone: {addr} total={len(clients)}")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"[bridge] websocket on ws://{WS_HOST}:{WS_PORT}")
|
||||
async with websockets.serve(ws_handler, WS_HOST, WS_PORT):
|
||||
await serial_reader_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tiny threaded HTTP server for the three.js demos that fetch local files.
|
||||
|
||||
Why a sibling helper script instead of `python -m http.server`?
|
||||
The stdlib SimpleHTTPServer is single-threaded; Chrome opens many parallel
|
||||
connections (HTML + 9 script tags + FBX), the first eats the worker, the
|
||||
rest time out with net::ERR_EMPTY_RESPONSE. ThreadingHTTPServer fixes it.
|
||||
|
||||
Usage:
|
||||
python examples/three.js/server/serve-demo.py
|
||||
open http://localhost:8765/examples/three.js/demos/05-skinned-realtime.html
|
||||
"""
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
import os, sys
|
||||
|
||||
PORT = int(os.environ.get("PORT", 8765))
|
||||
# Always serve from the repo root regardless of where the script is launched.
|
||||
# This file lives at examples/three.js/server/serve-demo.py — three levels deep.
|
||||
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||
|
||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
# Aggressive no-cache so browser ALWAYS fetches the latest .html
|
||||
# after we edit it. Otherwise stale code sticks around even on hard
|
||||
# refresh and you debug a phantom.
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
DEMOS = [
|
||||
"01-helpers.html",
|
||||
"02-cinematic.html",
|
||||
"03-skinned.html",
|
||||
"04-skinned-fbx.html",
|
||||
"05-skinned-realtime.html",
|
||||
]
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
|
||||
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
|
||||
print("demos:")
|
||||
for d in DEMOS:
|
||||
print(f" http://127.0.0.1:{PORT}/examples/three.js/demos/{d}")
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -15,7 +15,7 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 and
|
||||
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
|
||||
> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM |
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM |
|
||||
> | **Presence sensing** | Phase variance + adaptive calibration | < 1 ms latency |
|
||||
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
@@ -37,18 +37,22 @@ MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
`bootloader=0x0`, `partition-table=0x8000`, `otadata=0xf000`, `app (ota_0)=0x20000`.
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
@@ -129,11 +133,32 @@ Adds real-time health and safety monitoring.
|
||||
|
||||
- **Breathing rate** -- biquad IIR bandpass 0.1-0.5 Hz, zero-crossing BPM (6-30 BPM)
|
||||
- **Heart rate** -- biquad IIR bandpass 0.8-2.0 Hz, zero-crossing BPM (40-120 BPM)
|
||||
- **Presence detection** -- adaptive threshold calibration (60 s ambient learning)
|
||||
- **Presence indicator** -- phase variance vs an adaptively-calibrated threshold (60 s ambient learning at boot). Heuristic, not a learned classifier — strong RF interferers (fans, microwaves, transmit-power swings) can push variance above threshold without anyone in the room. See "What this firmware does NOT do" below.
|
||||
- **Fall detection** -- phase acceleration exceeds configurable threshold
|
||||
- **Multi-person estimation** -- subcarrier group clustering (up to 4 persons)
|
||||
- **Multi-person slot count** -- partitions the top-K subcarriers into `top_k / 2` groups (clamped to `[1, EDGE_MAX_PERSONS]`), computes per-group filtered breathing/heart-rate estimates, and reports the slot count as `pkt.n_persons`. This is a **slot-capacity heuristic**, not a learned counter — the reported count tracks subcarrier diversity, not actual occupancy. See [`edge_processing.c:481-548`](main/edge_processing.c#L481-L548).
|
||||
- **Vitals packet** -- 32-byte UDP packet at 1 Hz (magic `0xC5110002`)
|
||||
|
||||
### What this firmware does NOT do (Tier 2 caveats)
|
||||
|
||||
- It does **not** run a trained neural model. The "person count" is an
|
||||
arithmetic slot-capacity heuristic over the top-K subcarrier groups
|
||||
(`firmware/esp32-csi-node/main/edge_processing.c:481`). It tracks
|
||||
subcarrier diversity, not actual occupancy.
|
||||
- It does **not** run pose estimation. Pose-related features in the host
|
||||
UI come from the Rust `wifi-densepose-sensing-server` running a separate
|
||||
pipeline. When no `.rvf` model file is loaded via `--model`, the server
|
||||
drives the on-screen skeleton from signal-based heuristics (amplitude
|
||||
variance, motion-band power), not from learned keypoint inference. The
|
||||
repository does not ship pre-trained weights — see issues
|
||||
[#509](../../issues/509) and [#506](../../issues/506) for context, and
|
||||
[ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for
|
||||
the planned training path (phases P7-P9 are `Pending`).
|
||||
- The presence indicator is a calibrated variance threshold and **will
|
||||
false-positive** under strong RF interference from non-human sources
|
||||
(fans near the antenna, microwave duty cycles, neighbouring AP power
|
||||
swings) without re-running the 60-second ambient calibration. If you
|
||||
see ghost detections, re-calibrate by power-cycling in an empty room.
|
||||
|
||||
### Tier 3 -- WASM Programmable Sensing (Alpha)
|
||||
|
||||
Turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules -- compiled from Rust, packaged in signed RVF containers.
|
||||
@@ -254,9 +279,10 @@ Find your serial port: `COM7` on Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.SLAB
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### Serial Monitor
|
||||
@@ -285,7 +311,7 @@ All settings can be changed at runtime via Non-Volatile Storage (NVS) without re
|
||||
The easiest way to write NVS settings:
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "MyPassword" \
|
||||
--target-ip 192.168.1.20
|
||||
|
||||
@@ -11,7 +11,26 @@ set(SRCS
|
||||
"adaptive_controller.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
# ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps).
|
||||
set(REQUIRES
|
||||
esp_wifi
|
||||
esp_netif
|
||||
esp_event
|
||||
nvs_flash
|
||||
app_update
|
||||
esp_http_server
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
esp_driver_spi
|
||||
esp_driver_i2c
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
)
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
@@ -21,7 +40,11 @@ endif()
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
if(CONFIG_DISPLAY_ENABLE)
|
||||
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
|
||||
set(REQUIRES esp_lcd esp_lcd_touch lvgl)
|
||||
list(APPEND REQUIRES esp_lcd esp_lcd_touch lvgl)
|
||||
endif()
|
||||
|
||||
if(CONFIG_WASM_ENABLE)
|
||||
list(APPEND REQUIRES wasm3)
|
||||
endif()
|
||||
|
||||
idf_component_register(
|
||||
|
||||
@@ -371,6 +371,30 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)");
|
||||
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
|
||||
* (bitfields), not the legacy 802.11n bool layout used on ESP32-S3. */
|
||||
wifi_csi_config_t csi_config;
|
||||
memset(&csi_config, 0, sizeof(csi_config));
|
||||
csi_config.enable = 1U;
|
||||
csi_config.acquire_csi_legacy = 1U;
|
||||
csi_config.acquire_csi_ht20 = 1U;
|
||||
csi_config.acquire_csi_ht40 = 1U;
|
||||
csi_config.acquire_csi_su = 1U;
|
||||
csi_config.acquire_csi_mu = 1U;
|
||||
csi_config.acquire_csi_dcm = 1U;
|
||||
csi_config.acquire_csi_beamformed = 1U;
|
||||
#if CONFIG_SOC_WIFI_MAC_VERSION_NUM >= 3
|
||||
csi_config.acquire_csi_force_lltf = 1U;
|
||||
csi_config.acquire_csi_vht = 1U;
|
||||
csi_config.acquire_csi_he_stbc_mode = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
|
||||
csi_config.val_scale_cfg = 0U;
|
||||
#else
|
||||
csi_config.acquire_csi_he_stbc = ESP_CSI_ACQUIRE_STBC_SAMPLE_HELTFS;
|
||||
csi_config.val_scale_cfg = 0U;
|
||||
#endif
|
||||
csi_config.dump_ack_en = 0U;
|
||||
#else
|
||||
wifi_csi_config_t csi_config = {
|
||||
.lltf_en = true,
|
||||
.htltf_en = true,
|
||||
@@ -380,6 +404,7 @@ void csi_collector_init(void)
|
||||
.manu_scale = false,
|
||||
.shift = false,
|
||||
};
|
||||
#endif
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file edge_processing.c
|
||||
* @brief ADR-039 Edge Intelligence — dual-core CSI processing pipeline.
|
||||
*
|
||||
* Core 0 (WiFi task): Pushes raw CSI frames into lock-free SPSC ring buffer.
|
||||
* Core 1 (DSP task): Pops frames, runs signal processing pipeline:
|
||||
* Core 0 (WiFi path): Pushes raw CSI frames into lock-free SPSC ring buffer.
|
||||
* Second core when present (DSP task): pops frames, runs signal processing pipeline.
|
||||
* On unicore targets (e.g. ESP32-C6), the DSP task is pinned to core 0.
|
||||
* 1. Phase extraction from I/Q pairs
|
||||
* 2. Phase unwrapping (continuous phase)
|
||||
* 3. Welford variance tracking per subcarrier
|
||||
@@ -1050,7 +1051,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Start DSP task on Core 1. */
|
||||
/* Pin DSP off WiFi's preferred core when SMP; else core 0 only (ESP32-C6). */
|
||||
const BaseType_t dsp_core = (portNUM_PROCESSORS > 1) ? (BaseType_t)1 : (BaseType_t)0;
|
||||
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
edge_task,
|
||||
"edge_dsp",
|
||||
@@ -1058,14 +1061,14 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
NULL,
|
||||
5, /* Priority 5 — above idle, below WiFi. */
|
||||
NULL,
|
||||
1 /* Pin to Core 1. */
|
||||
);
|
||||
dsp_core);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create edge DSP task");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Edge DSP task created on Core 1 (stack=8192, priority=5)");
|
||||
ESP_LOGI(TAG, "Edge DSP task created on core %d (stack=8192, priority=5)",
|
||||
(int)dsp_core);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,6 @@ dependencies:
|
||||
|
||||
## LCD touch abstraction
|
||||
espressif/esp_lcd_touch: "^1.0"
|
||||
|
||||
## Onboard WS2812 LED Disabling
|
||||
espressif/led_strip: "^3.0.0"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "nvs_flash.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "stream_sender.h"
|
||||
@@ -149,6 +150,23 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — v%s — Node ID: %d",
|
||||
app_desc->version, g_nvs_config.node_id);
|
||||
|
||||
/* Turn off onboard WS2812 LED on GPIO 38 */
|
||||
led_strip_handle_t led_strip;
|
||||
led_strip_config_t strip_config = {
|
||||
.strip_gpio_num = 38,
|
||||
.max_leds = 1,
|
||||
.led_model = LED_MODEL_WS2812,
|
||||
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
|
||||
.flags.invert_out = false,
|
||||
};
|
||||
led_strip_rmt_config_t rmt_config = {
|
||||
.resolution_hz = 10 * 1000 * 1000, // 10MHz
|
||||
.flags.with_dma = false,
|
||||
};
|
||||
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
|
||||
led_strip_clear(led_strip);
|
||||
}
|
||||
|
||||
/* Initialize WiFi STA (skip entirely under QEMU mock — no RF hardware) */
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
wifi_init_sta();
|
||||
|
||||
@@ -109,7 +109,7 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
|
||||
switch (type) {
|
||||
case MR60_TYPE_BREATHING:
|
||||
if (len >= 4) {
|
||||
if (len >= sizeof(float)) {
|
||||
/* Breathing rate as float32 (little-endian in payload). */
|
||||
float br;
|
||||
memcpy(&br, data, sizeof(float));
|
||||
@@ -120,7 +120,7 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
break;
|
||||
|
||||
case MR60_TYPE_HEARTRATE:
|
||||
if (len >= 4) {
|
||||
if (len >= sizeof(float)) {
|
||||
float hr;
|
||||
memcpy(&hr, data, sizeof(float));
|
||||
if (hr >= 0.0f && hr <= 250.0f) {
|
||||
@@ -130,13 +130,13 @@ static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
break;
|
||||
|
||||
case MR60_TYPE_DISTANCE:
|
||||
if (len >= 8) {
|
||||
if (len >= sizeof(uint32_t) + sizeof(float)) {
|
||||
/* Bytes 0-3: range flag (uint32 LE). 0 = no valid distance. */
|
||||
uint32_t range_flag;
|
||||
memcpy(&range_flag, data, sizeof(uint32_t));
|
||||
if (range_flag != 0 && len >= 8) {
|
||||
if (range_flag != 0) {
|
||||
float dist;
|
||||
memcpy(&dist, &data[4], sizeof(float));
|
||||
memcpy(&dist, &data[sizeof(uint32_t)], sizeof(float));
|
||||
s_state.distance_cm = dist;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,24 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
|
||||
|
||||
/**
|
||||
* ADR-050: Verify the Authorization header contains the correct PSK.
|
||||
* Returns true if auth is disabled (no PSK provisioned) or if the
|
||||
* Bearer token matches the stored PSK.
|
||||
* Returns true only when a PSK is provisioned AND the Bearer token
|
||||
* matches it. An unprovisioned node refuses all OTA requests
|
||||
* (fail-closed, see RuView#596 audit). The OTA server still starts so
|
||||
* the operator can `provision.py --ota-psk <hex>` over USB-CDC without
|
||||
* a reflash, but the upload endpoint will reject every request until
|
||||
* the PSK is set.
|
||||
*/
|
||||
static bool ota_check_auth(httpd_req_t *req)
|
||||
{
|
||||
if (s_ota_psk[0] == '\0') {
|
||||
/* No PSK provisioned — auth disabled (permissive for dev). */
|
||||
return true;
|
||||
/* No PSK provisioned — fail closed. Previously this returned
|
||||
* true ("permissive for dev"), which let any host on the WiFi
|
||||
* push attacker-controlled firmware to a freshly-flashed node.
|
||||
* Plain HTTP transport + no Secure Boot V2 + no signed-image
|
||||
* verification meant a single LAN call could brick or back-
|
||||
* door a node. Reject until provisioned. */
|
||||
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk <hex>)");
|
||||
return false;
|
||||
}
|
||||
|
||||
char auth_header[128] = {0};
|
||||
@@ -241,26 +251,45 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
/**
|
||||
* Load the OTA PSK from NVS into the module-local s_ota_psk cache and log
|
||||
* the resulting posture. Called by both ota_update_init() and
|
||||
* ota_update_init_ex() so the per-boot diagnostic prints no matter which
|
||||
* entry point main.c uses — historically only ota_update_init() loaded the
|
||||
* PSK, which left ota_update_init_ex() with an empty s_ota_psk and an
|
||||
* invisible fail-closed posture (RuView#596 follow-up).
|
||||
*/
|
||||
static void ota_load_psk_from_nvs(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(s_ota_psk);
|
||||
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until "
|
||||
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
|
||||
}
|
||||
nvs_close(nvs);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all "
|
||||
"requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
ota_load_psk_from_nvs();
|
||||
return ota_start_server(NULL);
|
||||
}
|
||||
|
||||
esp_err_t ota_update_init_ex(void **out_server)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. main.c uses this
|
||||
* variant (not ota_update_init), so without this call s_ota_psk
|
||||
* stayed empty forever and the fail-closed posture was invisible
|
||||
* in serial logs. */
|
||||
ota_load_psk_from_nvs();
|
||||
return ota_start_server((httpd_handle_t *)out_server);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "psa/crypto.h"
|
||||
|
||||
static const char *TAG = "rvf";
|
||||
|
||||
@@ -125,9 +125,13 @@ esp_err_t rvf_parse(const uint8_t *data, uint32_t data_len, rvf_parsed_t *out)
|
||||
|
||||
/* ---- Verify build hash (SHA-256 of WASM payload) ---- */
|
||||
uint8_t computed_hash[32];
|
||||
int ret = mbedtls_sha256(wasm_data, hdr->wasm_len, computed_hash, 0);
|
||||
if (ret != 0) {
|
||||
ESP_LOGE(TAG, "SHA-256 computation failed: %d", ret);
|
||||
size_t hash_len = 0;
|
||||
psa_status_t psa_st = psa_hash_compute(PSA_ALG_SHA_256, wasm_data,
|
||||
hdr->wasm_len, computed_hash,
|
||||
sizeof(computed_hash), &hash_len);
|
||||
if (psa_st != PSA_SUCCESS || hash_len != 32) {
|
||||
ESP_LOGE(TAG, "SHA-256 computation failed: psa=%d len=%u",
|
||||
(int)psa_st, (unsigned)hash_len);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -186,8 +190,7 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
/*
|
||||
* Ed25519 verification.
|
||||
*
|
||||
* ESP-IDF v5.2 mbedtls does NOT include Ed25519 (Curve25519 is
|
||||
* for ECDH/X25519 only). We use a SHA-256-HMAC integrity check:
|
||||
* Legacy mbedtls Ed25519 is optional. We use a SHA-256 keyed digest:
|
||||
*
|
||||
* expected = SHA-256(pubkey || signed_region)
|
||||
*
|
||||
@@ -196,35 +199,34 @@ esp_err_t rvf_verify_signature(const rvf_parsed_t *parsed, const uint8_t *data,
|
||||
* pubkey produces a different expected hash, so unauthorized
|
||||
* publishers cannot forge a valid signature.
|
||||
*
|
||||
* For full Ed25519 (NaCl-style), enable CONFIG_MBEDTLS_EDDSA_C
|
||||
* or link TweetNaCl. The RVF builder should match this scheme.
|
||||
* For full Ed25519, enable CONFIG_MBEDTLS_EDDSA_C or equivalent.
|
||||
* The RVF builder should match this scheme.
|
||||
*/
|
||||
uint8_t hash_input_prefix[32];
|
||||
memcpy(hash_input_prefix, pubkey, 32);
|
||||
|
||||
/* Compute SHA-256(pubkey || header+manifest+wasm). */
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
int ret = mbedtls_sha256_starts(&ctx, 0);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
/* Compute SHA-256(pubkey || header+manifest+wasm) via PSA Crypto. */
|
||||
psa_hash_operation_t op = PSA_HASH_OPERATION_INIT;
|
||||
psa_status_t st = psa_hash_setup(&op, PSA_ALG_SHA_256);
|
||||
if (st != PSA_SUCCESS) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, hash_input_prefix, 32);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
st = psa_hash_update(&op, hash_input_prefix, 32);
|
||||
if (st != PSA_SUCCESS) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ret = mbedtls_sha256_update(&ctx, data, signed_len);
|
||||
if (ret != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
st = psa_hash_update(&op, data, signed_len);
|
||||
if (st != PSA_SUCCESS) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint8_t expected[32];
|
||||
ret = mbedtls_sha256_finish(&ctx, expected);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
if (ret != 0) {
|
||||
size_t out_len = 0;
|
||||
st = psa_hash_finish(&op, expected, sizeof(expected), &out_len);
|
||||
if (st != PSA_SUCCESS || out_len != 32) {
|
||||
(void)psa_hash_abort(&op);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32-S3 CSI Node Provisioning Script
|
||||
ESP32 CSI node provisioning (ESP32-S3, ESP32-C6, other targets).
|
||||
|
||||
Writes WiFi credentials and aggregator target to the ESP32's NVS partition
|
||||
so users can configure a pre-built firmware binary without recompiling.
|
||||
|
||||
Usage:
|
||||
python provision.py --port COM7 --ssid "MyWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
python provision.py --port /dev/ttyUSB0 --chip esp32c6 --ssid "..." \\
|
||||
--password "..." --target-ip 192.168.1.20
|
||||
|
||||
Requirements:
|
||||
pip install 'esptool>=5.0' nvs-partition-gen
|
||||
@@ -143,7 +145,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 +154,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 +169,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")
|
||||
@@ -281,7 +290,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 +346,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; }
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.6.4
|
||||
0.6.5
|
||||
@@ -1,4 +1,4 @@
|
||||
# ESP32-S3 Hello World — Capability Discovery
|
||||
# ESP32 Hello World — Capability Discovery (S3 / C6 targets)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief ESP32-S3 Hello World — Full Capability Discovery
|
||||
* @brief ESP32 Hello World — Full Capability Discovery
|
||||
*
|
||||
* Boots up, prints "Hello World!", then probes and reports every major
|
||||
* hardware/software capability of the ESP32-S3: chip info, flash, PSRAM,
|
||||
* WiFi (including CSI), Bluetooth, GPIOs, peripherals, FreeRTOS stats,
|
||||
* and power management features. No WiFi connection required.
|
||||
* Boots up, prints "Hello World!", then probes chip info, flash, PSRAM,
|
||||
* WiFi (including CSI where enabled), 802.15.4/BLE on C6, GPIOs,
|
||||
* peripherals, FreeRTOS stats, and power management. No WiFi connection
|
||||
* required. Supports ESP32-S3 and ESP32-C6 (set IDF target accordingly).
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -18,7 +18,6 @@
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_flash.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_timer.h"
|
||||
@@ -33,7 +32,24 @@
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "hello";
|
||||
/*
|
||||
* Peripheral counts: ESP-IDF v6+ dropped some SOC_* macros; values below
|
||||
* match each target's HAL (esp_hal_* *_ll.h) where applicable.
|
||||
*/
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
#define PROBE_I2S_CTRL_NUM 2
|
||||
#define PROBE_RMT_CHAN_NUM 8
|
||||
#define PROBE_MCPWM_GROUPS 2
|
||||
#define PROBE_PCNT_UNITS 4
|
||||
#define PROBE_TOUCH_CHAN_NUM ((int)(SOC_TOUCH_MAX_CHAN_ID - SOC_TOUCH_MIN_CHAN_ID + 1))
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
#define PROBE_I2S_CTRL_NUM 1
|
||||
#define PROBE_RMT_CHAN_NUM 4
|
||||
#define PROBE_MCPWM_GROUPS 1
|
||||
#define PROBE_PCNT_UNITS 4
|
||||
#else
|
||||
#error "hello-world: add PROBE_* peripheral counts for this IDF target in main.c"
|
||||
#endif
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,6 +62,7 @@ static const char *chip_model_str(esp_chip_model_t model)
|
||||
case CHIP_ESP32C3: return "ESP32-C3";
|
||||
case CHIP_ESP32H2: return "ESP32-H2";
|
||||
case CHIP_ESP32C2: return "ESP32-C2";
|
||||
case CHIP_ESP32C6: return "ESP32-C6";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -168,7 +185,11 @@ static void probe_wifi_capabilities(void)
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
/* Protocol capabilities */
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Protocols: 802.11 b/g/n/ax (Wi-Fi 6, 2.4 GHz)\n");
|
||||
#else
|
||||
printf(" Protocols: 802.11 b/g/n\n");
|
||||
#endif
|
||||
|
||||
/* CSI (Channel State Information) */
|
||||
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
|
||||
@@ -246,7 +267,7 @@ static void probe_bluetooth(void)
|
||||
esp_chip_info(&info);
|
||||
|
||||
if (info.features & CHIP_FEATURE_BLE) {
|
||||
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
|
||||
printf(" BLE: Supported (Bluetooth LE)\n");
|
||||
printf(" - GATT Server/Client\n");
|
||||
printf(" - Advertising & Scanning\n");
|
||||
printf(" - Mesh Networking\n");
|
||||
@@ -256,10 +277,16 @@ static void probe_bluetooth(void)
|
||||
printf(" BLE: Not supported on this chip\n");
|
||||
}
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
if (info.features & CHIP_FEATURE_IEEE802154) {
|
||||
printf(" 802.15.4: Supported (Thread / Zigbee style MAC)\n");
|
||||
}
|
||||
#endif
|
||||
|
||||
if (info.features & CHIP_FEATURE_BT) {
|
||||
printf(" BT Classic: Supported (A2DP, SPP, HFP)\n");
|
||||
} else {
|
||||
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
|
||||
printf(" BT Classic: Not available (BLE-only on this chip)\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,24 +296,52 @@ static void probe_peripherals(void)
|
||||
|
||||
printf(" GPIOs: %d total\n", SOC_GPIO_PIN_COUNT);
|
||||
printf(" ADC:\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" - SAR ADC: %d channels (12-bit, one controller)\n",
|
||||
(int)SOC_ADC_CHANNEL_NUM(0));
|
||||
#else
|
||||
printf(" - ADC1: %d channels (12-bit SAR)\n", SOC_ADC_CHANNEL_NUM(0));
|
||||
printf(" - ADC2: %d channels (shared with WiFi)\n", SOC_ADC_CHANNEL_NUM(1));
|
||||
printf(" DAC: Not available on ESP32-S3\n");
|
||||
printf(" Touch Sensors: %d channels (capacitive)\n", SOC_TOUCH_SENSOR_NUM);
|
||||
printf(" SPI: %d controllers (SPI2/SPI3 for user)\n", SOC_SPI_PERIPH_NUM);
|
||||
printf(" I2C: %d controllers\n", SOC_I2C_NUM);
|
||||
printf(" I2S: %d controllers (audio/PDM/TDM)\n", SOC_I2S_NUM);
|
||||
printf(" UART: %d controllers\n", SOC_UART_NUM);
|
||||
#endif
|
||||
printf(" DAC: Not available on this chip\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" Touch Sensors: %d channels (capacitive)\n", PROBE_TOUCH_CHAN_NUM);
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Touch Sensors: Not available (no capacitive touch on ESP32-C6)\n");
|
||||
#endif
|
||||
printf(" SPI: %d controllers\n", SOC_SPI_PERIPH_NUM);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" (SPI2/SPI3 typical for user apps)\n");
|
||||
#endif
|
||||
printf(" I2C: %d controllers\n", (int)SOC_I2C_NUM);
|
||||
printf(" I2S: %d controller(s) (audio/PDM/TDM)\n", PROBE_I2S_CTRL_NUM);
|
||||
printf(" UART: %d controllers\n", (int)SOC_UART_NUM);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" USB: USB-OTG 1.1 (Host & Device)\n");
|
||||
printf(" USB-Serial: Built-in USB-JTAG/Serial (this console)\n");
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" USB: No native USB-OTG (use SPI/USB bridge or off-chip PHY)\n");
|
||||
printf(" USB-Serial: Built-in USB Serial/JTAG (this console)\n");
|
||||
#endif
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" TWAI (CAN): 1 controller (CAN 2.0B compatible)\n");
|
||||
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" TWAI (CAN): %d controller(s) (CAN 2.0B compatible)\n",
|
||||
(int)SOC_TWAI_CONTROLLER_NUM);
|
||||
#endif
|
||||
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", PROBE_RMT_CHAN_NUM);
|
||||
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
|
||||
printf(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
|
||||
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
|
||||
printf(" MCPWM: %d group(s) (motor control)\n", PROBE_MCPWM_GROUPS);
|
||||
printf(" PCNT: %d units (pulse counter / encoder)\n", PROBE_PCNT_UNITS);
|
||||
#if CONFIG_IDF_TARGET_ESP32S3
|
||||
printf(" LCD: Parallel 8/16-bit + SPI + I2C interfaces\n");
|
||||
printf(" Camera: DVP 8/16-bit parallel interface\n");
|
||||
printf(" SDMMC: SD/MMC host controller (1-bit / 4-bit)\n");
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" PARLIO: Parallel TX/RX (e.g. LED matrix / custom buses)\n");
|
||||
printf(" Camera: SPI / external bridge (no native DVP)\n");
|
||||
printf(" SDIO: SDIO slave peripheral (see TRM for capabilities)\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
static void probe_security(void)
|
||||
@@ -309,17 +364,29 @@ static void probe_power(void)
|
||||
{
|
||||
print_separator("POWER MANAGEMENT");
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Clock Modes:\n");
|
||||
printf(" - 160 MHz (max CPU on ESP32-C6)\n");
|
||||
printf(" - 120 MHz (balanced)\n");
|
||||
printf(" - 80 MHz (low power)\n");
|
||||
#else
|
||||
printf(" Clock Modes:\n");
|
||||
printf(" - 240 MHz (max performance)\n");
|
||||
printf(" - 160 MHz (balanced)\n");
|
||||
printf(" - 80 MHz (low power)\n");
|
||||
#endif
|
||||
printf(" Sleep Modes:\n");
|
||||
printf(" - Modem Sleep (WiFi off, CPU active)\n");
|
||||
printf(" - Light Sleep (CPU paused, fast wake)\n");
|
||||
printf(" - Deep Sleep (RTC only, ~10 uA)\n");
|
||||
printf(" - Hibernation (RTC timer only, ~5 uA)\n");
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
printf(" Wake Sources: GPIO, LP timer, UART, etc.\n");
|
||||
printf(" LP domain: LP core / LP peripherals (see TRM)\n");
|
||||
#else
|
||||
printf(" Wake Sources: GPIO, timer, touch, ULP, UART\n");
|
||||
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
|
||||
printf(" ULP Coprocessor: FSM (runs in deep sleep)\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
static void probe_temperature(void)
|
||||
@@ -389,6 +456,9 @@ static void probe_csi_details(void)
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip;
|
||||
esp_chip_info(&chip);
|
||||
|
||||
/* NVS required for WiFi */
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
@@ -401,7 +471,7 @@ void app_main(void)
|
||||
printf("\n");
|
||||
printf(" ╭─────────────────────────────────────────────────╮\n");
|
||||
printf(" │ │\n");
|
||||
printf(" │ HELLO WORLD from ESP32-S3! │\n");
|
||||
printf(" │ HELLO WORLD from %-24s │\n", chip_model_str(chip.model));
|
||||
printf(" │ │\n");
|
||||
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
|
||||
printf(" │ │\n");
|
||||
@@ -422,8 +492,9 @@ void app_main(void)
|
||||
probe_csi_details();
|
||||
|
||||
print_separator("DONE — ALL CAPABILITIES REPORTED");
|
||||
printf("\n This ESP32-S3 is ready for WiFi-DensePose!\n");
|
||||
printf(" Flash the full firmware (esp32-csi-node) to begin CSI sensing.\n\n");
|
||||
printf("\n This %s is ready for WiFi-DensePose experiments.\n",
|
||||
chip_model_str(chip.model));
|
||||
printf(" For production CSI on S3, flash esp32-csi-node; C6 path may differ.\n\n");
|
||||
|
||||
/* Keep alive — blink a status message every 10 seconds */
|
||||
int tick = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ESP32-S3 Hello World — SDK Configuration
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
# ESP32 Hello World — SDK Configuration (default: ESP32-C6)
|
||||
CONFIG_IDF_TARGET="esp32c6"
|
||||
|
||||
# Flash: 4MB (this chip has Embedded Flash 4MB)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-mock>=3.10.0
|
||||
pytest-benchmark>=4.0.0
|
||||
pytest-benchmark>=5.2.3
|
||||
|
||||
# Linting and formatting
|
||||
black>=23.0.0
|
||||
|
||||
@@ -7,7 +7,7 @@ torchvision>=0.13.0
|
||||
# API dependencies
|
||||
fastapi>=0.95.0
|
||||
uvicorn>=0.20.0
|
||||
websockets>=10.4
|
||||
websockets>=15.0.1
|
||||
pydantic>=1.10.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
python-multipart>=0.0.6
|
||||
@@ -18,7 +18,7 @@ pydantic-settings>=2.0.0
|
||||
# Database dependencies
|
||||
sqlalchemy>=2.0.0
|
||||
asyncpg>=0.28.0
|
||||
aiosqlite>=0.19.0
|
||||
aiosqlite>=0.22.1
|
||||
redis>=4.5.0
|
||||
|
||||
# CLI dependencies
|
||||
@@ -26,8 +26,8 @@ click>=8.0.0
|
||||
alembic>=1.10.0
|
||||
|
||||
# Hardware interface dependencies
|
||||
asyncio-mqtt>=0.11.0
|
||||
aiohttp>=3.8.0
|
||||
asyncio-mqtt>=0.16.2
|
||||
aiohttp>=3.13.5
|
||||
paramiko>=3.0.0
|
||||
|
||||
# Data processing dependencies
|
||||
|
||||
@@ -110,6 +110,109 @@
|
||||
"require": ["VERIFY.sh", "witness-bundle"],
|
||||
"rationale": "scripts/generate-witness-bundle.sh produces the self-contained, recipient-verifiable witness bundle (witness log + proof + test results + firmware hashes + VERIFY.sh). Part of the ADR-028 attestation chain.",
|
||||
"ref": "docs/WITNESS-LOG-028.md"
|
||||
},
|
||||
{
|
||||
"id": "RuView#559",
|
||||
"title": "./verify wrapper points at archive/v1/ paths (post-v1-archive layout)",
|
||||
"files": ["verify"],
|
||||
"require": ["${SCRIPT_DIR}/archive/v1/data/proof", "${SCRIPT_DIR}/archive/v1/src"],
|
||||
"rationale": "After v1 moved to archive/v1, the ./verify wrapper still pointed at the removed v1/ paths and failed before reaching verify.py on a fresh clone. Reverting to the un-prefixed paths reintroduces the FAIL-before-pipeline regression that #559 reported.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/559"
|
||||
},
|
||||
{
|
||||
"id": "RuView#561",
|
||||
"title": "ESP32 CSI firmware README documents the correct flash offsets (app at 0x20000, ota_data at 0xf000)",
|
||||
"files": ["firmware/esp32-csi-node/README.md"],
|
||||
"require": [
|
||||
"0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin",
|
||||
"0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin",
|
||||
"firmware/esp32-csi-node/provision.py"
|
||||
],
|
||||
"forbid": [
|
||||
"/0x10000 firmware\\/esp32-csi-node\\/build\\/esp32-csi-node\\.bin/",
|
||||
"/python scripts\\/provision\\.py/"
|
||||
],
|
||||
"rationale": "Partition tables (partitions_display.csv, partitions_4mb.csv) put ota_0 at 0x20000. The README previously said 0x10000 and pointed at scripts/provision.py (an older copy). Reverting causes first-time users to misflash and miss WiFi provisioning.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/561"
|
||||
},
|
||||
{
|
||||
"id": "RuView#588-SEC020",
|
||||
"title": "provision.py prints a fixed (set)/(empty) marker, not a length-leaking asterisk run",
|
||||
"files": ["scripts/provision.py", "firmware/esp32-csi-node/provision.py"],
|
||||
"require": ["(set)' if args.password else '(empty)"],
|
||||
"forbid": ["/'\\*' \\* len\\(args\\.password\\)/"],
|
||||
"rationale": "Both provision.py scripts previously printed '*' * len(args.password), masking the value but leaking the password length. Flagged as SEC020 by Repobility. Fix replaces with a fixed (set)/(empty) marker.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/588"
|
||||
},
|
||||
{
|
||||
"id": "RuView#593",
|
||||
"title": "vital_signs.rs uses circular variance for wrapped atan2 phase values",
|
||||
"files": ["v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs"],
|
||||
"require": [
|
||||
"phase_circular_variance",
|
||||
"standard circular variance (1 - mean resultant length)",
|
||||
"test_phase_variance_handles_wraparound"
|
||||
],
|
||||
"rationale": "Phases come from atan2 and are wrapped to (-pi, pi]. The original linear mean/variance treated two phases straddling +/-pi (physically ~0 rad apart) as ~2*pi apart, producing variance ~pi^2 instead of ~1e-6 and feeding that noise straight into the heart-rate FFT buffer. Caused jumpy vitals in #519 and +/-15 BPM jitter in #485.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/593"
|
||||
},
|
||||
{
|
||||
"id": "RuView#590-fuzz-stub",
|
||||
"title": "Fuzz host stubs declare WIFI_PS_NONE / wifi_ps_type_t / esp_wifi_set_ps()",
|
||||
"files": ["firmware/esp32-csi-node/test/stubs/esp_stubs.h"],
|
||||
"require": ["wifi_ps_type_t", "WIFI_PS_NONE", "esp_wifi_set_ps"],
|
||||
"rationale": "csi_collector.c:346 calls esp_wifi_set_ps(WIFI_PS_NONE) per the RuView#521 fix. The host-native fuzz target compiles csi_collector.c against test/stubs/esp_stubs.h; missing these symbols red-greens the Fuzz Testing (ADR-061 Layer 6) job. Was red on main for ~5 weeks before PR #590.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/590"
|
||||
},
|
||||
{
|
||||
"id": "RuView#590-swarm-test",
|
||||
"title": "QEMU swarm test passes --force-partial to provision.py for per-node overlays",
|
||||
"files": ["scripts/qemu_swarm.py"],
|
||||
"require": ["--force-partial"],
|
||||
"rationale": "The per-node TDM/channel overlay intentionally omits WiFi creds (those live in the base flash image). Without --force-partial the issue #391 wifi-trio guard in provision.py rejects the call and breaks the Swarm Test (ADR-062) job. Was red on main for ~5 weeks before PR #590.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/590"
|
||||
},
|
||||
{
|
||||
"id": "RuView#615",
|
||||
"title": "path_safety::safe_id gates user-controlled IDs at filesystem boundaries",
|
||||
"files": [
|
||||
"v2/crates/wifi-densepose-sensing-server/src/path_safety.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/recording.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/model_manager.rs",
|
||||
"v2/crates/wifi-densepose-sensing-server/src/training_api.rs"
|
||||
],
|
||||
"require": [
|
||||
"path_safety::safe_id",
|
||||
"pub fn safe_id"
|
||||
],
|
||||
"rationale": "Five endpoints used to embed user-controlled identifiers (session_name, model_id, dataset_id, recording id) into format!() paths with no sanitization, allowing classic '../../etc/passwd' reads, writes, and deletes on the server filesystem. The safe_id helper enforces [A-Za-z0-9._-] only (no leading '.', max 64 chars) and must run before any user input reaches a format!() that builds a path. Removing the helper or skipping it at any of these call sites reintroduces the #615 attack surface.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/615"
|
||||
},
|
||||
{
|
||||
"id": "RuView#596-ota-fail-closed",
|
||||
"title": "ESP32 OTA upload fails closed when no PSK is provisioned",
|
||||
"files": ["firmware/esp32-csi-node/main/ota_update.c"],
|
||||
"require": [
|
||||
"fail-closed, see RuView#596 audit",
|
||||
"OTA rejected: no PSK in NVS"
|
||||
],
|
||||
"forbid": [
|
||||
"/auth disabled \\(permissive for dev\\)/",
|
||||
"/No PSK provisioned \\u2014 auth disabled/"
|
||||
],
|
||||
"rationale": "ota_check_auth previously returned true when s_ota_psk[0] == '\\0', so any host on the WiFi could push attacker-controlled firmware to a freshly-flashed node over plain HTTP on port 8032 — no Secure Boot V2, no signed-image verification, single LAN call could brick or backdoor a node. Flagged in the deep-review of PR #596. Fail-closed means the OTA server still starts (so operators can provision a PSK via USB-CDC without reflashing) but the upload endpoint refuses every request until provision.py --ota-psk <hex> writes the NVS key. Reverting this lets the rogue-LAN attack reopen.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/596#pullrequestreview"
|
||||
},
|
||||
{
|
||||
"id": "RuView#560",
|
||||
"title": "verify.py quantizes features before SHA-256 for cross-platform hash stability",
|
||||
"files": ["archive/v1/data/proof/verify.py"],
|
||||
"require": [
|
||||
"HASH_QUANTIZATION_DECIMALS",
|
||||
"np.round(flat, HASH_QUANTIZATION_DECIMALS)"
|
||||
],
|
||||
"rationale": "Without quantization, the SHA-256 of features_to_bytes() diverges across SIMD backends (Intel AVX2/AVX-512 vs Apple Silicon NEON) because scipy.fft's pocketfft kernels reorder vectorized FP operations differently per build. IEEE 754 guarantees per-operation determinism, not associativity. Rounding to 9 decimal places (~5 orders of magnitude headroom over observed ULP drift) collapses the cross-platform divergence to a single canonical hash. Removing the round() call reintroduces the macOS arm64 vs Linux x86_64 hash mismatch in issue #560.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/560"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Platform probe: reproduce verify.py's hash-relevant FFT steps in isolation.
|
||||
|
||||
Runs the same scipy.fft.fft / scipy.signal calls that verify.py hashes
|
||||
(csi_processor.py:426, :438, :349) on a deterministic synthetic input,
|
||||
without dragging in src.app / pydantic Settings. Used to empirically
|
||||
locate the source of platform divergence in issue #560 — and now also to
|
||||
verify the quantize-before-hash fix shipped in archive/v1/data/proof/verify.py.
|
||||
|
||||
Usage: python3 scripts/probe-fft-platform.py
|
||||
Output: single JSON object on stdout. Run on each platform and diff.
|
||||
|
||||
The output now contains TWO hashes:
|
||||
- `sha256_raw` — hash of unrounded little-endian f64 bytes (legacy)
|
||||
- `sha256_quantized` — hash after np.round(.., 9) (matches verify.py
|
||||
behaviour after the issue-#560 fix; should be
|
||||
IDENTICAL across Intel AVX, ARM NEON, and any
|
||||
scipy pocketfft build)
|
||||
|
||||
If `sha256_raw` differs across machines but `sha256_quantized` matches,
|
||||
the quantize-before-hash fix is doing its job.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import scipy.fft
|
||||
import scipy.signal
|
||||
|
||||
# Deterministic synthetic input -- no IO, no .env, no Settings
|
||||
rng = np.random.RandomState(42)
|
||||
N_FRAMES = 100
|
||||
N_SUBC = 100
|
||||
amp = rng.randn(N_FRAMES, N_SUBC).astype(np.float64)
|
||||
|
||||
# Mirror the three scipy calls verify.py's hash depends on:
|
||||
# archive/v1/src/core/csi_processor.py:349 -> scipy.signal.windows.hamming
|
||||
# archive/v1/src/core/csi_processor.py:426 -> scipy.fft.fft(mean_phase_diff, n=64)
|
||||
# archive/v1/src/core/csi_processor.py:438 -> scipy.fft.fft(amp.flatten(), n=128)
|
||||
mean_phase_diff = amp.mean(axis=1)
|
||||
doppler = np.abs(scipy.fft.fft(mean_phase_diff, n=64)) ** 2
|
||||
psd = np.abs(scipy.fft.fft(amp.flatten(), n=128)) ** 2
|
||||
window = scipy.signal.windows.hamming(56)
|
||||
|
||||
# Quantization decimals — kept in sync with
|
||||
# archive/v1/data/proof/verify.py:HASH_QUANTIZATION_DECIMALS so this probe
|
||||
# verifies the production hash, not just the FFT outputs.
|
||||
HASH_QUANTIZATION_DECIMALS = 6
|
||||
|
||||
|
||||
def pack_floats(arrays, quantize):
|
||||
"""Pack arrays as little-endian f64, optionally rounding first."""
|
||||
parts = []
|
||||
for arr in arrays:
|
||||
flat = np.asarray(arr, dtype=np.float64).ravel()
|
||||
if quantize:
|
||||
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
arrays = (doppler, psd, window)
|
||||
blob_raw = pack_floats(arrays, quantize=False)
|
||||
blob_quantized = pack_floats(arrays, quantize=True)
|
||||
|
||||
try:
|
||||
blas_info = np.show_config(mode="dicts")
|
||||
except Exception:
|
||||
blas_info = {"error": "show_config(mode=dicts) unavailable"}
|
||||
|
||||
print(json.dumps({
|
||||
"uname": platform.uname()._asdict(),
|
||||
"python": sys.version.split()[0],
|
||||
"numpy": np.__version__,
|
||||
"scipy": __import__("scipy").__version__,
|
||||
"blob_len": len(blob_raw),
|
||||
"sha256_raw": hashlib.sha256(blob_raw).hexdigest(),
|
||||
"sha256_quantized": hashlib.sha256(blob_quantized).hexdigest(),
|
||||
"quantization_decimals": HASH_QUANTIZATION_DECIMALS,
|
||||
"first8_doppler_bytes_hex": doppler[:8].tobytes().hex(),
|
||||
"first4_psd_floats": psd[:4].tolist(),
|
||||
"blas_backend": blas_info if isinstance(blas_info, dict) else str(blas_info),
|
||||
}, indent=2, default=str))
|
||||
@@ -213,7 +213,7 @@ def main():
|
||||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
|
||||
@@ -259,11 +259,16 @@ def provision_node(
|
||||
if stale.exists():
|
||||
stale.unlink()
|
||||
|
||||
# Build provision.py arguments
|
||||
# Build provision.py arguments.
|
||||
# --force-partial: this is a per-node TDM/channel overlay; WiFi
|
||||
# credentials live in the base flash image, not the per-node NVS slice.
|
||||
# Without --force-partial, provision.py rejects calls missing the
|
||||
# --ssid/--password/--target-ip trio (issue #391 guard).
|
||||
args = [
|
||||
sys.executable, str(PROVISION_SCRIPT),
|
||||
"--port", "/dev/null",
|
||||
"--dry-run",
|
||||
"--force-partial",
|
||||
"--node-id", str(node.node_id),
|
||||
"--tdm-slot", str(node.tdm_slot),
|
||||
"--tdm-total", str(n_total),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UDP relay for Docker Desktop on Windows (issue #374, #386).
|
||||
|
||||
Docker Desktop on Windows multiplexes inbound UDP from multiple source IPs to
|
||||
a single source IP inside the container, which causes packets from all but one
|
||||
ESP32 node to be silently dropped at the WSL/Hyper-V boundary.
|
||||
|
||||
This relay listens on the host, then re-emits each datagram from its own
|
||||
single socket back to a localhost port that Docker forwards into the
|
||||
container. Because every forwarded datagram now has the same source IP/port
|
||||
(the relay's loopback socket), Docker passes them all through.
|
||||
|
||||
Usage:
|
||||
# Default: listen on host:5005, forward to 127.0.0.1:5006
|
||||
# Container should be started with -p 5006:5005/udp.
|
||||
python scripts/udp-relay.py
|
||||
|
||||
# Custom ports
|
||||
python scripts/udp-relay.py --listen-port 5005 --forward-port 5006
|
||||
|
||||
# Verbose (one line per packet)
|
||||
python scripts/udp-relay.py --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def run_relay(listen_host: str, listen_port: int, forward_host: str,
|
||||
forward_port: int, stats_interval: float, verbose: bool) -> int:
|
||||
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
rx.bind((listen_host, listen_port))
|
||||
except OSError as e:
|
||||
print(f"udp-relay: failed to bind {listen_host}:{listen_port}: {e}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
forward_addr = (forward_host, forward_port)
|
||||
|
||||
print(f"udp-relay: listening on {listen_host}:{listen_port} "
|
||||
f"-> forwarding to {forward_host}:{forward_port}")
|
||||
print("udp-relay: collapses multi-source UDP to a single loopback source "
|
||||
"so Docker Desktop on Windows forwards every packet (issue #374).")
|
||||
|
||||
sources: dict[tuple[str, int], int] = {}
|
||||
total = 0
|
||||
last_stats = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
data, src = rx.recvfrom(65535)
|
||||
tx.sendto(data, forward_addr)
|
||||
total += 1
|
||||
sources[src] = sources.get(src, 0) + 1
|
||||
|
||||
if verbose:
|
||||
print(f"udp-relay: {src[0]}:{src[1]} -> "
|
||||
f"{forward_host}:{forward_port} ({len(data)}B)")
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_stats >= stats_interval:
|
||||
print(f"udp-relay: forwarded {total} pkts from "
|
||||
f"{len(sources)} sources in last {stats_interval:.0f}s")
|
||||
sources.clear()
|
||||
total = 0
|
||||
last_stats = now
|
||||
except KeyboardInterrupt:
|
||||
print("udp-relay: stopping")
|
||||
return 0
|
||||
finally:
|
||||
rx.close()
|
||||
tx.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--listen-host", default="0.0.0.0",
|
||||
help="Host interface to bind (default: 0.0.0.0)")
|
||||
p.add_argument("--listen-port", type=int, default=5005,
|
||||
help="Port the ESP32 nodes send to (default: 5005)")
|
||||
p.add_argument("--forward-host", default="127.0.0.1",
|
||||
help="Where to forward packets (default: 127.0.0.1)")
|
||||
p.add_argument("--forward-port", type=int, default=5006,
|
||||
help="Port Docker maps into the container (default: 5006)")
|
||||
p.add_argument("--stats-interval", type=float, default=10.0,
|
||||
help="Seconds between stats lines (default: 10)")
|
||||
p.add_argument("--verbose", action="store_true",
|
||||
help="Log every forwarded packet")
|
||||
args = p.parse_args()
|
||||
|
||||
return run_relay(args.listen_host, args.listen_port, args.forward_host,
|
||||
args.forward_port, args.stats_interval, args.verbose)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,15 +13,15 @@
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.3",
|
||||
"@react-navigation/bottom-tabs": "^7.15.10",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@types/three": "^0.183.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"expo": "~55.0.4",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "0.85.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
@@ -32,20 +32,20 @@
|
||||
"react-native-wifi-reborn": "^4.13.6",
|
||||
"three": "^0.183.2",
|
||||
"victory-native": "^41.20.2",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"babel-preset-expo": "^55.0.10",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint": "^10.2.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-expo": "^55.0.9",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-worklets": "^0.7.4",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
|
||||
@@ -9,11 +9,25 @@
|
||||
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
|
||||
*/
|
||||
|
||||
// Derive WebSocket URL from the page origin so it works on any port.
|
||||
// The /ws/sensing endpoint is available on the same HTTP port (3000).
|
||||
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
|
||||
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
|
||||
const SENSING_WS_PORT_BY_HTTP_PORT = {
|
||||
// Docker image: HTTP UI/API on 3000, sensing stream on 3001.
|
||||
'3000': '3001',
|
||||
// Python sensing stack: UI on 8080, sensing stream on 8765.
|
||||
'8080': '8765',
|
||||
};
|
||||
|
||||
export function buildSensingWsUrl(locationLike = (typeof window !== 'undefined' ? window.location : null)) {
|
||||
const protocol = locationLike && locationLike.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = locationLike && locationLike.host ? locationLike.host : 'localhost:3001';
|
||||
const hostname = locationLike && locationLike.hostname ? locationLike.hostname : host.split(':')[0];
|
||||
const port = locationLike && locationLike.port ? locationLike.port : '';
|
||||
const wsPort = SENSING_WS_PORT_BY_HTTP_PORT[port];
|
||||
const wsHost = wsPort ? `${hostname}:${wsPort}` : host;
|
||||
|
||||
return `${protocol}//${wsHost}/ws/sensing`;
|
||||
}
|
||||
|
||||
const SENSING_WS_URL = buildSensingWsUrl();
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
// Number of failed attempts that must occur before simulation starts.
|
||||
|
||||
@@ -136,9 +136,22 @@ export class WebSocketService {
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
setupEventHandlers(url, ws, handlers) {
|
||||
const connection = this.connections.get(url);
|
||||
const getConnection = (eventName) => {
|
||||
const connection = this.connections.get(url);
|
||||
if (!connection) {
|
||||
this.logger.warn(`Ignoring WebSocket ${eventName} for unregistered connection`, {
|
||||
url,
|
||||
readyState: ws.readyState
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return connection;
|
||||
};
|
||||
|
||||
ws.onopen = (event) => {
|
||||
const connection = getConnection('open');
|
||||
if (!connection) return;
|
||||
|
||||
const connectionTime = Date.now() - connection.connectionStartTime;
|
||||
this.logger.info(`WebSocket connected successfully`, { url, connectionTime });
|
||||
|
||||
@@ -158,6 +171,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const connection = getConnection('message');
|
||||
if (!connection) return;
|
||||
|
||||
connection.lastActivity = Date.now();
|
||||
connection.messageCount++;
|
||||
|
||||
@@ -188,6 +204,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
const connection = getConnection('error');
|
||||
if (!connection) return;
|
||||
|
||||
connection.errorCount++;
|
||||
this.logger.error(`WebSocket error occurred`, {
|
||||
url,
|
||||
@@ -208,6 +227,9 @@ export class WebSocketService {
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const connection = getConnection('close');
|
||||
if (!connection) return;
|
||||
|
||||
const { code, reason, wasClean } = event;
|
||||
this.logger.info(`WebSocket closed`, { url, code, reason, wasClean });
|
||||
|
||||
@@ -607,4 +629,4 @@ export class WebSocketService {
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const wsService = new WebSocketService();
|
||||
export const wsService = new WebSocketService();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,18 +83,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ansi-str"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cf4578926a981ab0ca955dc023541d19de37112bc24c1a197bd806d3d86ad1d"
|
||||
checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansitok"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220044e6a1bb31ddee4e3db724d29767f352de47445a6cd75e1a173142136c83"
|
||||
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"vte",
|
||||
@@ -180,15 +180,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
@@ -200,9 +191,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.5.2"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
@@ -918,10 +909,22 @@ dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width 0.2.2",
|
||||
"unicode-width",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
@@ -1348,7 +1351,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1553,7 +1556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2092,7 +2095,7 @@ version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"num-traits",
|
||||
"rstar 0.10.0",
|
||||
"rstar 0.11.0",
|
||||
@@ -2687,7 +2690,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2880,10 +2883,10 @@ version = "0.17.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
||||
dependencies = [
|
||||
"console",
|
||||
"console 0.15.11",
|
||||
"number_prefix",
|
||||
"portable-atomic",
|
||||
"unicode-width 0.2.2",
|
||||
"unicode-width",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
@@ -2948,7 +2951,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3138,25 +3141,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lapack-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "447f56c85fb410a7a3d36701b2153c1018b1d2b908c5fbaf01c1b04fac33bcbe"
|
||||
checksum = "314b879030845b68571809a6978e52d3b67ac5fba07e77b1b317b484092e2fb5"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lax"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f96a229d9557112e574164f8024ce703625ad9f88a90964c1780809358e53da"
|
||||
checksum = "bda593cb87a3b1c06625e73710812005caf1523e45b0b898adcd7716602f8ba2"
|
||||
dependencies = [
|
||||
"cauchy",
|
||||
"katexit",
|
||||
"lapack-sys",
|
||||
"num-traits",
|
||||
"openblas-src",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3619,7 +3622,7 @@ version = "0.33.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"matrixmultiply",
|
||||
"nalgebra-macros",
|
||||
"num-complex",
|
||||
@@ -3672,9 +3675,6 @@ version = "0.15.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
|
||||
dependencies = [
|
||||
"approx 0.4.0",
|
||||
"cblas-sys",
|
||||
"libc",
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
@@ -3705,6 +3705,9 @@ version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"cblas-sys",
|
||||
"libc",
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
@@ -3716,18 +3719,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ndarray-linalg"
|
||||
version = "0.16.0"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b0e8dda0c941b64a85c5deb2b3e0144aca87aced64678adfc23eacea6d2cc42"
|
||||
checksum = "eb0783188ff249ab498417e0477f7fade3b312d1d287314ca76de570d9a83ee0"
|
||||
dependencies = [
|
||||
"cauchy",
|
||||
"katexit",
|
||||
"lax",
|
||||
"ndarray 0.15.6",
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3852,7 +3855,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3955,7 +3958,7 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
name = "nvsim"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"criterion",
|
||||
"js-sys",
|
||||
"rand 0.8.5",
|
||||
@@ -3964,7 +3967,7 @@ dependencies = [
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3979,10 +3982,10 @@ dependencies = [
|
||||
"nvsim",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -4057,6 +4060,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -4256,7 +4260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4286,15 +4290,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papergrid"
|
||||
version = "0.11.0"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb"
|
||||
checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
|
||||
dependencies = [
|
||||
"ansi-str",
|
||||
"ansitok",
|
||||
"bytecount",
|
||||
"fnv",
|
||||
"unicode-width 0.1.14",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4834,6 +4838,28 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-hack"
|
||||
version = "0.5.20+deprecated"
|
||||
@@ -4941,7 +4967,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4980,9 +5006,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5385,7 +5411,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -5418,7 +5444,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -5611,7 +5637,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5703,7 +5729,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5799,9 +5825,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-core"
|
||||
version = "2.0.5"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc7bc95e3682430c27228d7bc694ba9640cd322dde1bd5e7c9cf96a16afb4ca1"
|
||||
checksum = "83fe578d02c93613b26d40105c9663df5e86414195e91db0ea7470d3b9c64843"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode 2.0.1",
|
||||
@@ -5900,9 +5926,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-temporal-tensor"
|
||||
version = "2.0.4"
|
||||
version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac"
|
||||
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -6191,9 +6217,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -6377,7 +6403,7 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -6715,28 +6741,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tabled"
|
||||
version = "0.15.0"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e"
|
||||
checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
|
||||
dependencies = [
|
||||
"ansi-str",
|
||||
"ansitok",
|
||||
"papergrid",
|
||||
"tabled_derive",
|
||||
"unicode-width 0.1.14",
|
||||
"testing_table",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabled_derive"
|
||||
version = "0.7.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17"
|
||||
checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-error",
|
||||
"heck 0.5.0",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6938,9 +6964,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -6956,13 +6982,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -6972,7 +7000,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -7099,14 +7127,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tch"
|
||||
version = "0.14.0"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ed5dddab3812892bf5fb567136e372ea49f31672931e21cec967ca68aec03da"
|
||||
checksum = "0d3f84a069d8ba16dbf720b61e8bf131d90ffb8e958a664eae8e4993c5c2fa6f"
|
||||
dependencies = [
|
||||
"half",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"ndarray 0.15.6",
|
||||
"ndarray 0.16.1",
|
||||
"rand 0.8.5",
|
||||
"safetensors 0.3.3",
|
||||
"thiserror 1.0.69",
|
||||
@@ -7124,7 +7152,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7144,6 +7172,16 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "testing_table"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -7388,13 +7426,28 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
@@ -7415,9 +7468,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.0.0+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -7453,31 +7506,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.0.0+spec-1.1.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.9+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 0.7.14",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "torch-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "803446f89fb877a117503dbfb8375b6a29fa8b0e0f44810fac3863c798ecef22"
|
||||
checksum = "f4ba78777379cf09aaa79708c63e477cf0f95e021d04360c6821f1a9f56173f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
@@ -7524,9 +7577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
@@ -7538,33 +7591,17 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7802,12 +7839,6 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
@@ -7969,23 +8000,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.10.1"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983"
|
||||
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"utf8parse",
|
||||
"vte_generate_state_changes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte_generate_state_changes"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8333,10 +8353,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-api"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
@@ -8346,7 +8362,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored",
|
||||
"console",
|
||||
"console 0.16.3",
|
||||
"csv",
|
||||
"indicatif",
|
||||
"predicates",
|
||||
@@ -8354,7 +8370,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tabled",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -8362,10 +8378,6 @@ dependencies = [
|
||||
"wifi-densepose-mat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-config"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.0"
|
||||
@@ -8378,14 +8390,10 @@ dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-db"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-desktop"
|
||||
version = "0.3.0"
|
||||
@@ -8408,7 +8416,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-shell",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-serial",
|
||||
"tracing",
|
||||
@@ -8431,7 +8439,7 @@ dependencies = [
|
||||
name = "wifi-densepose-hardware"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -8442,7 +8450,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -8452,7 +8460,7 @@ name = "wifi-densepose-mat"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -8468,7 +8476,7 @@ dependencies = [
|
||||
"ruvector-temporal-tensor",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
@@ -8496,7 +8504,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tch",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -8514,14 +8522,14 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http 0.5.2",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-ruvector"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"criterion",
|
||||
"ruvector-attention 2.0.4",
|
||||
"ruvector-attn-mincut",
|
||||
@@ -8534,7 +8542,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8553,7 +8561,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wifi-densepose-signal",
|
||||
@@ -8580,7 +8588,7 @@ dependencies = [
|
||||
"ruvector-solver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-ruvector",
|
||||
]
|
||||
@@ -8590,7 +8598,7 @@ name = "wifi-densepose-train"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx 0.5.1",
|
||||
"approx",
|
||||
"chrono",
|
||||
"clap",
|
||||
"criterion",
|
||||
@@ -8613,7 +8621,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tch",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"tracing",
|
||||
@@ -8685,7 +8693,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9243,6 +9251,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
|
||||
@@ -4,9 +4,16 @@ members = [
|
||||
"crates/wifi-densepose-core",
|
||||
"crates/wifi-densepose-signal",
|
||||
"crates/wifi-densepose-nn",
|
||||
"crates/wifi-densepose-api",
|
||||
"crates/wifi-densepose-db",
|
||||
"crates/wifi-densepose-config",
|
||||
# wifi-densepose-api / -db / -config: removed in #578.
|
||||
# The crate names were reserved early for an envisioned REST/database/config
|
||||
# split, but no implementation followed and no code referenced them. The
|
||||
# functionality they would provide is covered today by:
|
||||
# - REST/WS: `wifi-densepose-sensing-server` (Axum)
|
||||
# - Config: per-crate config + CLI args in `wifi-densepose-sensing-server`
|
||||
# and `wifi-densepose-desktop`
|
||||
# - DB: no persistent state; system is real-time
|
||||
# If we ever need any of these as a published surface, they can be
|
||||
# reintroduced with a real implementation.
|
||||
"crates/wifi-densepose-hardware",
|
||||
"crates/wifi-densepose-wasm",
|
||||
"crates/wifi-densepose-cli",
|
||||
@@ -46,7 +53,7 @@ categories = ["science", "computer-vision", "wasm"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Core utilities
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -57,13 +64,13 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Signal processing
|
||||
ndarray = { version = "0.15", features = ["serde"] }
|
||||
ndarray-linalg = { version = "0.16", features = ["openblas-static"] }
|
||||
ndarray-linalg = { version = "0.18", features = ["openblas-static"] }
|
||||
rustfft = "6.1"
|
||||
num-complex = "0.4"
|
||||
num-traits = "0.2"
|
||||
|
||||
# Neural network
|
||||
tch = "0.14"
|
||||
tch = "0.24"
|
||||
ort = { version = "2.0.0-rc.11" }
|
||||
candle-core = "0.4"
|
||||
candle-nn = "0.4"
|
||||
@@ -71,7 +78,7 @@ candle-nn = "0.4"
|
||||
# Web framework
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
tower = { version = "0.4", features = ["full"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||
hyper = { version = "1.1", features = ["full"] }
|
||||
|
||||
# Database
|
||||
@@ -134,10 +141,10 @@ midstreamer-attractor = "0.1.0"
|
||||
|
||||
# ruvector integration (published on crates.io)
|
||||
# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
|
||||
ruvector-core = "2.0.4"
|
||||
ruvector-core = "2.2.0"
|
||||
ruvector-mincut = "2.0.4"
|
||||
ruvector-attn-mincut = "2.0.4"
|
||||
ruvector-temporal-tensor = "2.0.4"
|
||||
ruvector-temporal-tensor = "2.0.6"
|
||||
ruvector-solver = "2.0.4"
|
||||
ruvector-attention = "2.0.4"
|
||||
ruvector-crv = "0.1.1"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "REST API for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "api", "rest", "densepose", "websocket"]
|
||||
categories = ["web-programming::http-server", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,71 +0,0 @@
|
||||
# wifi-densepose-api
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-api)
|
||||
[](https://docs.rs/wifi-densepose-api)
|
||||
[](LICENSE)
|
||||
|
||||
REST and WebSocket API layer for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-api` provides the HTTP service boundary for WiFi-DensePose. Built on
|
||||
[axum](https://github.com/tokio-rs/axum), it exposes REST endpoints for pose queries, CSI frame
|
||||
ingestion, and model management, plus a WebSocket feed for real-time pose streaming to frontend
|
||||
clients.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **REST endpoints** -- CRUD for scan zones, pose queries, model configuration, and health checks.
|
||||
- **WebSocket streaming** -- Real-time pose estimate broadcasts with per-client subscription filters.
|
||||
- **Authentication** -- Token-based auth middleware via `tower` layers.
|
||||
- **Rate limiting** -- Configurable per-route limits to protect hardware-constrained deployments.
|
||||
- **OpenAPI spec** -- Auto-generated documentation via `utoipa`.
|
||||
- **CORS** -- Configurable cross-origin support for browser-based dashboards.
|
||||
- **Graceful shutdown** -- Clean connection draining on SIGTERM.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_api::Server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let server = Server::builder()
|
||||
.bind("0.0.0.0:3000")
|
||||
.with_websocket("/ws/poses")
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
server.run().await
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/v1/health` | Liveness and readiness probes |
|
||||
| `GET` | `/api/v1/poses` | Latest pose estimates |
|
||||
| `POST` | `/api/v1/csi` | Ingest raw CSI frames |
|
||||
| `GET` | `/api/v1/zones` | List scan zones |
|
||||
| `POST` | `/api/v1/zones` | Create a scan zone |
|
||||
| `WS` | `/ws/poses` | Real-time pose stream |
|
||||
| `WS` | `/ws/vitals` | Real-time vital sign stream |
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | Shared types and traits |
|
||||
| [`wifi-densepose-config`](../wifi-densepose-config) | Configuration loading |
|
||||
| [`wifi-densepose-db`](../wifi-densepose-db) | Database persistence |
|
||||
| [`wifi-densepose-nn`](../wifi-densepose-nn) | Neural network inference |
|
||||
| [`wifi-densepose-signal`](../wifi-densepose-signal) | CSI signal processing |
|
||||
| [`wifi-densepose-sensing-server`](../wifi-densepose-sensing-server) | Lightweight sensing UI server |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose REST API (stub)
|
||||
@@ -28,9 +28,9 @@ clap = { version = "4.4", features = ["derive", "env", "cargo"] }
|
||||
|
||||
# Output formatting
|
||||
colored = "2.1"
|
||||
tabled = { version = "0.15", features = ["ansi"] }
|
||||
tabled = { version = "0.20", features = ["ansi"] }
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
console = "0.16"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
@@ -42,7 +42,7 @@ csv = "1.3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Configuration management for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "configuration", "densepose", "settings", "toml"]
|
||||
categories = ["config", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,89 +0,0 @@
|
||||
# wifi-densepose-config
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-config)
|
||||
[](https://docs.rs/wifi-densepose-config)
|
||||
[](LICENSE)
|
||||
|
||||
Configuration management for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-config` provides a unified configuration layer that merges values from environment
|
||||
variables, TOML/YAML files, and CLI overrides into strongly-typed Rust structs. Built on the
|
||||
[config](https://docs.rs/config), [dotenvy](https://docs.rs/dotenvy), and
|
||||
[envy](https://docs.rs/envy) ecosystem from the workspace.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **Multi-source loading** -- Merge configuration from `.env`, TOML files, YAML files, and
|
||||
environment variables with well-defined precedence.
|
||||
- **Typed configuration** -- Strongly-typed structs for server, signal processing, neural network,
|
||||
hardware, and database settings.
|
||||
- **Validation** -- Schema validation with human-readable error messages on startup.
|
||||
- **Hot reload** -- Watch configuration files for changes and notify dependent services.
|
||||
- **Profile support** -- Named profiles (`development`, `production`, `testing`) with per-profile
|
||||
overrides.
|
||||
- **Secret filtering** -- Redact sensitive values (API keys, database passwords) in logs and debug
|
||||
output.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_config::AppConfig;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Loads from env, config.toml, and CLI overrides
|
||||
let config = AppConfig::load()?;
|
||||
|
||||
println!("Server bind: {}", config.server.bind_address);
|
||||
println!("CSI sample rate: {} Hz", config.signal.sample_rate);
|
||||
println!("Model path: {}", config.nn.model_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Configuration Structure
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
|
||||
[server]
|
||||
bind_address = "0.0.0.0:3000"
|
||||
websocket_path = "/ws/poses"
|
||||
|
||||
[signal]
|
||||
sample_rate = 100
|
||||
subcarrier_count = 56
|
||||
hampel_window = 5
|
||||
|
||||
[nn]
|
||||
model_path = "./models/densepose.rvf"
|
||||
backend = "ort" # ort | candle | tch
|
||||
batch_size = 8
|
||||
|
||||
[hardware]
|
||||
esp32_udp_port = 5005
|
||||
serial_baud = 921600
|
||||
|
||||
[database]
|
||||
url = "sqlite://data/wifi-densepose.db"
|
||||
max_connections = 5
|
||||
```
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | Shared types and traits |
|
||||
| [`wifi-densepose-api`](../wifi-densepose-api) | REST API (consumer) |
|
||||
| [`wifi-densepose-db`](../wifi-densepose-db) | Database layer (consumer) |
|
||||
| [`wifi-densepose-cli`](../wifi-densepose-cli) | CLI (consumer) |
|
||||
| [`wifi-densepose-sensing-server`](../wifi-densepose-sensing-server) | Sensing server (consumer) |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose configuration (stub)
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Database layer for WiFi-DensePose"
|
||||
license.workspace = true
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "database", "storage", "densepose", "persistence"]
|
||||
categories = ["database", "science"]
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,106 +0,0 @@
|
||||
# wifi-densepose-db
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-db)
|
||||
[](https://docs.rs/wifi-densepose-db)
|
||||
[](LICENSE)
|
||||
|
||||
Database persistence layer for the WiFi-DensePose pose estimation system.
|
||||
|
||||
## Overview
|
||||
|
||||
`wifi-densepose-db` implements the `DataStore` trait defined in `wifi-densepose-core`, providing
|
||||
persistent storage for CSI frames, pose estimates, scan sessions, and alert history. The intended
|
||||
backends are [SQLx](https://docs.rs/sqlx) for relational storage (PostgreSQL and SQLite) and
|
||||
[Redis](https://docs.rs/redis) for real-time caching and pub/sub.
|
||||
|
||||
> **Status:** This crate is currently a stub. The intended API surface is documented below.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **Dual backend** -- PostgreSQL for production deployments, SQLite for single-node and embedded
|
||||
use. Selectable at compile time via feature flags.
|
||||
- **Redis caching** -- Connection-pooled Redis for low-latency pose estimate lookups, session
|
||||
state, and pub/sub event distribution.
|
||||
- **Migrations** -- Embedded SQL migrations managed by SQLx, applied automatically on startup.
|
||||
- **Repository pattern** -- Typed repository structs (`PoseRepository`, `SessionRepository`,
|
||||
`AlertRepository`) implementing the core `DataStore` trait.
|
||||
- **Connection pooling** -- Configurable pool sizes via `sqlx::PgPool` / `sqlx::SqlitePool`.
|
||||
- **Transaction support** -- Scoped transactions for multi-table writes (e.g., survivor detection
|
||||
plus alert creation).
|
||||
- **Time-series optimisation** -- Partitioned tables and retention policies for high-frequency CSI
|
||||
frame storage.
|
||||
|
||||
### Planned feature flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------------|---------|-------------|
|
||||
| `postgres` | no | Enable PostgreSQL backend |
|
||||
| `sqlite` | yes | Enable SQLite backend |
|
||||
| `redis` | no | Enable Redis caching layer |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
// Intended usage (not yet implemented)
|
||||
use wifi_densepose_db::{Database, PoseRepository};
|
||||
use wifi_densepose_core::PoseEstimate;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db = Database::connect("sqlite://data/wifi-densepose.db").await?;
|
||||
db.run_migrations().await?;
|
||||
|
||||
let repo = PoseRepository::new(db.pool());
|
||||
|
||||
// Store a pose estimate
|
||||
repo.insert(&pose_estimate).await?;
|
||||
|
||||
// Query recent poses
|
||||
let recent = repo.find_recent(10).await?;
|
||||
println!("Last 10 poses: {:?}", recent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Planned Schema
|
||||
|
||||
```sql
|
||||
-- Core tables
|
||||
CREATE TABLE csi_frames (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
subcarriers BYTEA NOT NULL,
|
||||
antenna_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE pose_estimates (
|
||||
id UUID PRIMARY KEY,
|
||||
frame_id UUID REFERENCES csi_frames(id),
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
keypoints JSONB NOT NULL,
|
||||
confidence REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE scan_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
config JSONB NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`wifi-densepose-core`](../wifi-densepose-core) | `DataStore` trait definition |
|
||||
| [`wifi-densepose-config`](../wifi-densepose-config) | Database connection configuration |
|
||||
| [`wifi-densepose-api`](../wifi-densepose-api) | REST API (consumer) |
|
||||
| [`wifi-densepose-mat`](../wifi-densepose-mat) | Disaster detection (consumer) |
|
||||
| [`wifi-densepose-signal`](../wifi-densepose-signal) | CSI signal processing |
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1 +0,0 @@
|
||||
//! WiFi-DensePose database layer (stub)
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "ruview-desktop-ui",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ruview-desktop-ui",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.4",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -53,7 +53,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1165,12 +1164,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
@@ -1247,20 +1246,19 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
@@ -1317,7 +1315,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1587,7 +1584,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1629,7 +1625,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -1638,16 +1633,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
@@ -1706,13 +1700,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -1752,9 +1743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1802,7 +1793,6 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ byteorder = "1.5"
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
# Serialization
|
||||
|
||||
@@ -39,7 +39,7 @@ axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Serialization
|
||||
|
||||
@@ -22,7 +22,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { workspace = true }
|
||||
tower-http = { version = "0.5", features = ["fs", "cors", "set-header"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "cors", "set-header"] }
|
||||
tokio = { workspace = true, features = ["full", "process"] }
|
||||
futures-util = "0.3"
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
@@ -91,7 +91,11 @@ fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
|
||||
// IQR (inter-quartile range).
|
||||
let mut sorted = amps.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
// partial_cmp returns None on NaN — fall back to Equal so a single NaN
|
||||
// frame from real ESP32 hardware (silent DSP div-by-zero, empty buffer)
|
||||
// can't panic the whole sensing server (#611). The same file already
|
||||
// uses unwrap_or(Equal) at lines 149-150 and 155; this was an oversight.
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let q1 = sorted[sorted.len() / 4];
|
||||
let q3 = sorted[3 * sorted.len() / 4];
|
||||
let iqr = q3 - q1;
|
||||
|
||||
@@ -19,8 +19,8 @@ pub struct Args {
|
||||
#[arg(long, default_value = "5005")]
|
||||
pub udp_port: u16,
|
||||
|
||||
/// Path to UI static files
|
||||
#[arg(long, default_value = "../../ui")]
|
||||
/// Path to UI static files (from `v2/` cwd use `../ui`)
|
||||
#[arg(long, default_value = "../ui")]
|
||||
pub ui_path: PathBuf,
|
||||
|
||||
/// Tick interval in milliseconds (default 100 ms = 10 fps for smooth pose animation)
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Host-header allowlist for the sensing-server HTTP + WS surface.
|
||||
//!
|
||||
//! Defense against DNS rebinding: when the server is bound to loopback
|
||||
//! (default `127.0.0.1`), a foreign page (e.g. `evil.com`) can lower its DNS
|
||||
//! TTL and re-resolve to `127.0.0.1` after the browser has already accepted
|
||||
//! the origin. From the browser's point of view the request is same-origin
|
||||
//! against `evil.com`, so it reads the response — even though the bytes come
|
||||
//! from the local sensing-server. Without `Host`-header validation the server
|
||||
//! happily serves the request because every other axum layer treats it as a
|
||||
//! normal connection.
|
||||
//!
|
||||
//! For RuView this means any website the user visits can stream live pose,
|
||||
//! breathing rate, and heart-rate data out of the sensing-server (`/ws/sensing`,
|
||||
//! `/api/v1/pose/current`, `/api/v1/vital-signs`, …), and trigger state-mutating
|
||||
//! POSTs (`/api/v1/recording/start`, `/api/v1/models/load`, …) when bearer-auth
|
||||
//! is not configured (the default LAN-only deployment posture from #443).
|
||||
//!
|
||||
//! The middleware here rejects any request whose `Host` header is not in the
|
||||
//! configured allowlist with `421 Misdirected Request`. Defaults cover the
|
||||
//! common local-only deployment (`localhost`, `127.0.0.1`, `[::1]` with or
|
||||
//! without `:PORT`). Operators who bind to a routable address (`--bind-addr
|
||||
//! 0.0.0.0` or a LAN IP) extend the allowlist with `--allowed-host` flags or
|
||||
//! the `SENSING_ALLOWED_HOSTS` env var.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header::HOST, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
/// Environment variable that supplies additional allowed hosts
|
||||
/// (comma-separated). Whitespace around each entry is trimmed; empty entries
|
||||
/// are ignored.
|
||||
pub const ALLOWED_HOSTS_ENV: &str = "SENSING_ALLOWED_HOSTS";
|
||||
|
||||
/// Built-in allowlist entries. Each entry is also accepted with an optional
|
||||
/// trailing `:PORT` (any port).
|
||||
const DEFAULT_LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
/// Cheap, cloneable handle to the configured Host allowlist.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostAllowlist {
|
||||
/// Lower-cased exact-match hostnames (with or without `:PORT` already
|
||||
/// baked in). Empty set ⇒ middleware accepts everything and is a no-op,
|
||||
/// matching the historical behaviour for callers that want to opt out.
|
||||
entries: Arc<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl HostAllowlist {
|
||||
/// Build an allowlist with only the default loopback names (bare and
|
||||
/// with any `:PORT`). Use this when the server is bound to loopback and
|
||||
/// no operator overrides have been supplied.
|
||||
pub fn loopback_only() -> Self {
|
||||
let mut entries: HashSet<String> = HashSet::new();
|
||||
for h in DEFAULT_LOOPBACK_HOSTS {
|
||||
entries.insert((*h).to_string());
|
||||
}
|
||||
HostAllowlist {
|
||||
entries: Arc::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an allowlist from an iterator of additional hostnames (each may
|
||||
/// optionally include a `:PORT` suffix). The default loopback set is
|
||||
/// always included so `--bind-addr 0.0.0.0` deployments do not lock out
|
||||
/// local browsers on `http://localhost:8080/…`.
|
||||
pub fn with_extra<I, S>(extras: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut entries: HashSet<String> = HashSet::new();
|
||||
for h in DEFAULT_LOOPBACK_HOSTS {
|
||||
entries.insert((*h).to_string());
|
||||
}
|
||||
for h in extras {
|
||||
let h = h.as_ref().trim();
|
||||
if !h.is_empty() {
|
||||
entries.insert(h.to_lowercase());
|
||||
}
|
||||
}
|
||||
HostAllowlist {
|
||||
entries: Arc::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an allowlist by joining (a) the default loopback set, (b) any
|
||||
/// CLI-supplied extras, and (c) the comma-separated `SENSING_ALLOWED_HOSTS`
|
||||
/// env var. Order of precedence does not matter — the result is a set.
|
||||
pub fn from_cli_and_env<I, S>(cli_extras: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let env_extras: Vec<String> = std::env::var(ALLOWED_HOSTS_ENV)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let cli_vec: Vec<String> = cli_extras
|
||||
.into_iter()
|
||||
.map(|s| s.as_ref().to_string())
|
||||
.collect();
|
||||
HostAllowlist::with_extra(cli_vec.into_iter().chain(env_extras.into_iter()))
|
||||
}
|
||||
|
||||
/// Disable host-header validation entirely. Provided as an explicit escape
|
||||
/// hatch for operators who deploy the server behind a reverse proxy that
|
||||
/// already canonicalises `Host`, or for unit tests that need to bypass
|
||||
/// the layer.
|
||||
pub fn disabled() -> Self {
|
||||
HostAllowlist::default()
|
||||
}
|
||||
|
||||
/// True if the middleware will enforce host validation. `false` ⇒ no-op.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Test-only accessor returning a sorted, lower-cased copy of the
|
||||
/// configured allowlist. Exposed via the `pub(crate)` boundary so we can
|
||||
/// unit-test the env-var parsing without reaching into the `Arc`.
|
||||
pub fn entries_for_test(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.entries.iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Check whether `host` (the raw `Host` header value, e.g.
|
||||
/// `127.0.0.1:8080` or `[::1]`) is permitted. Comparison is case-insensitive
|
||||
/// on the host part; ports are matched verbatim if the allowlist entry
|
||||
/// pins one, otherwise the port is ignored.
|
||||
pub fn is_allowed(&self, host: &str) -> bool {
|
||||
if self.entries.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let host = host.trim().to_lowercase();
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match (e.g. allowlist contains `127.0.0.1:8080` and request
|
||||
// sent `Host: 127.0.0.1:8080`).
|
||||
if self.entries.contains(&host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match on host-only when the allowlist entry has no port and the
|
||||
// request includes a port. Handles `Host: 127.0.0.1:8080` against
|
||||
// `127.0.0.1` in the allowlist, and `Host: [::1]:8080` against
|
||||
// `[::1]`.
|
||||
let host_only = strip_port(&host);
|
||||
if self.entries.contains(host_only) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a `:PORT` suffix from `host`, leaving the host portion. IPv6 literals
|
||||
/// are wrapped in brackets (`[::1]:PORT`) so the last `:` is the port
|
||||
/// separator; bracketed IPv6 without a port stays intact.
|
||||
fn strip_port(host: &str) -> &str {
|
||||
if let Some(close) = host.strip_prefix('[').and_then(|_| host.find(']')) {
|
||||
// Bracketed IPv6: `[::1]` or `[::1]:8080`.
|
||||
if let Some(after) = host.get(close + 1..) {
|
||||
if after.starts_with(':') {
|
||||
return &host[..=close];
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
match host.rfind(':') {
|
||||
Some(idx) => &host[..idx],
|
||||
None => host,
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rejects any request whose `Host` header is not in the
|
||||
/// configured allowlist. Use with [`axum::middleware::from_fn_with_state`].
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * No `Host` header → `400 Bad Request` (HTTP/1.1 requires one; HTTP/2
|
||||
/// synthesises it from `:authority`, so a missing value is a real protocol
|
||||
/// violation, not a rebinding signal).
|
||||
/// * `Host` header present but not in the allowlist → `421 Misdirected Request`.
|
||||
/// * Empty allowlist → no-op (the operator explicitly opted out).
|
||||
pub async fn require_allowed_host(
|
||||
State(allowlist): State<HostAllowlist>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if !allowlist.is_enabled() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let host_header = request
|
||||
.headers()
|
||||
.get(HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let host_header = match host_header {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing Host header\n",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if allowlist.is_allowed(&host_header) {
|
||||
next.run(request).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"Host header not in allowlist (DNS-rebinding defense). \
|
||||
Set --allowed-host <name[:port]> or SENSING_ALLOWED_HOSTS=<comma-list> \
|
||||
to permit this hostname.\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn router(allowlist: HostAllowlist) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/v1/pose/current", get(|| async { "ok" }))
|
||||
.route("/ws/sensing", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
allowlist,
|
||||
require_allowed_host,
|
||||
))
|
||||
}
|
||||
|
||||
async fn status(router: Router, path: &str, host: Option<&str>) -> StatusCode {
|
||||
let mut req = Request::builder().method("GET").uri(path);
|
||||
if let Some(h) = host {
|
||||
req = req.header(HOST, h);
|
||||
}
|
||||
let req = req.body(Body::empty()).unwrap();
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_only_allows_default_hosts_with_any_port() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in [
|
||||
"localhost",
|
||||
"localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:8080",
|
||||
"127.0.0.1:65535",
|
||||
"[::1]",
|
||||
"[::1]:8080",
|
||||
] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::OK,
|
||||
"host {h} should be allowed under loopback_only()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_only_rejects_foreign_hosts() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in [
|
||||
"evil.com",
|
||||
"evil.com:8080",
|
||||
"127.0.0.1.evil.com",
|
||||
"192.168.1.10",
|
||||
"192.168.1.10:8080",
|
||||
"sensing.local",
|
||||
] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"host {h} should be rejected under loopback_only()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_host_header() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", None).await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_host_header() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", Some("")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejection_applies_to_health_and_ws_routes_too() {
|
||||
// The whole router is fronted by the middleware — there is no
|
||||
// bypass for `/health` or `/ws/*`, because rebinding doesn't care
|
||||
// which route it targets, it cares about what bytes flow back.
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r.clone(), "/health", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "/ws/sensing", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extras_extend_loopback_set() {
|
||||
let r = router(HostAllowlist::with_extra(["sensing.local", "192.168.1.10"]));
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("sensing.local")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("sensing.local:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("192.168.1.10:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
// Loopback defaults are still in:
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("127.0.0.1")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
// Foreign hosts still rejected:
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_allowlist_is_no_op() {
|
||||
let r = router(HostAllowlist::disabled());
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("evil.com")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", None).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_insensitive_host_match() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in ["LOCALHOST", "LocalHost:8080", "127.0.0.1"] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::OK,
|
||||
"host {h} should be allowed (case-insensitive)"
|
||||
);
|
||||
}
|
||||
let r2 = router(HostAllowlist::with_extra(["Sensing.Local"]));
|
||||
assert_eq!(
|
||||
status(r2, "/api/v1/pose/current", Some("sensing.local:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_port_handles_ipv4_ipv6_and_bare_hostnames() {
|
||||
assert_eq!(strip_port("localhost"), "localhost");
|
||||
assert_eq!(strip_port("localhost:8080"), "localhost");
|
||||
assert_eq!(strip_port("127.0.0.1"), "127.0.0.1");
|
||||
assert_eq!(strip_port("127.0.0.1:8080"), "127.0.0.1");
|
||||
assert_eq!(strip_port("[::1]"), "[::1]");
|
||||
assert_eq!(strip_port("[::1]:8080"), "[::1]");
|
||||
// No `:` at all
|
||||
assert_eq!(strip_port("sensing.local"), "sensing.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_extra_trims_whitespace_and_skips_empty() {
|
||||
let allowlist = HostAllowlist::with_extra([" sensing.local ", "", "192.168.1.10"]);
|
||||
let entries = allowlist.entries_for_test();
|
||||
assert!(entries.contains(&"sensing.local".to_string()));
|
||||
assert!(entries.contains(&"192.168.1.10".to_string()));
|
||||
assert!(!entries.iter().any(|s| s.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_only_includes_all_three_defaults() {
|
||||
let entries = HostAllowlist::loopback_only().entries_for_test();
|
||||
assert!(entries.contains(&"localhost".to_string()));
|
||||
assert!(entries.contains(&"127.0.0.1".to_string()));
|
||||
assert!(entries.contains(&"[::1]".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_to_with_extra_still_includes_loopback_defaults() {
|
||||
// Calling `with_extra` with no extras (e.g. operator passed no
|
||||
// `--allowed-host` flags) must keep the loopback defaults so a fresh
|
||||
// 127.0.0.1 deployment isn't bricked.
|
||||
let entries: Vec<String> = Vec::new();
|
||||
let allowlist = HostAllowlist::with_extra(entries);
|
||||
assert!(allowlist.is_allowed("127.0.0.1"));
|
||||
assert!(allowlist.is_allowed("127.0.0.1:8080"));
|
||||
assert!(allowlist.is_allowed("localhost"));
|
||||
assert!(!allowlist.is_allowed("evil.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_constants_are_stable() {
|
||||
assert_eq!(ALLOWED_HOSTS_ENV, "SENSING_ALLOWED_HOSTS");
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@
|
||||
//! - Vital sign detection from WiFi CSI amplitude data
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
//! - Host-header allowlist / DNS-rebinding defense (`host_validation`)
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod path_safety;
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
|
||||
@@ -83,8 +83,8 @@ struct Args {
|
||||
#[arg(long, default_value = "5005")]
|
||||
udp_port: u16,
|
||||
|
||||
/// Path to UI static files
|
||||
#[arg(long, default_value = "../../ui")]
|
||||
/// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect)
|
||||
#[arg(long, default_value = "../ui")]
|
||||
ui_path: PathBuf,
|
||||
|
||||
/// Tick interval in milliseconds (default 100 ms = 10 fps for smooth pose animation)
|
||||
@@ -95,6 +95,21 @@ struct Args {
|
||||
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
|
||||
bind_addr: String,
|
||||
|
||||
/// Additional hostname (with or without `:PORT`) to permit in the `Host`
|
||||
/// header — defends loopback-bound deployments against DNS rebinding.
|
||||
/// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted
|
||||
/// implicitly. Pass multiple times to add several entries. Comma-separated
|
||||
/// values are also accepted via the `SENSING_ALLOWED_HOSTS` env var.
|
||||
#[arg(long = "allowed-host", value_name = "HOST")]
|
||||
allowed_hosts: Vec<String>,
|
||||
|
||||
/// Disable Host-header validation entirely. Use only when the server sits
|
||||
/// behind a reverse proxy that already canonicalises `Host` (e.g. nginx
|
||||
/// `proxy_set_header Host`) — bare deployments stay vulnerable to DNS
|
||||
/// rebinding without it.
|
||||
#[arg(long)]
|
||||
disable_host_validation: bool,
|
||||
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
@@ -2008,6 +2023,10 @@ async fn handle_ws_client(mut socket: WebSocket, state: SharedState) {
|
||||
|
||||
info!("WebSocket client connected (sensing)");
|
||||
|
||||
// ADR-044/045: ping/pong keepalive to prevent proxy idle timeouts.
|
||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => {
|
||||
@@ -2017,13 +2036,24 @@ async fn handle_ws_client(mut socket: WebSocket, state: SharedState) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
// Lagged: client fell behind — skip missed frames, don't disconnect.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::debug!("WS client lagged by {n} frames, skipping");
|
||||
continue;
|
||||
}
|
||||
Err(_) => break, // channel closed
|
||||
}
|
||||
}
|
||||
_ = ping_interval.tick() => {
|
||||
if socket.send(Message::Ping(vec![].into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = socket.recv() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {} // ignore client messages
|
||||
Some(Ok(Message::Pong(_))) => {} // keepalive response
|
||||
_ => {} // ignore other client messages
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2192,7 +2222,12 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
// Lagged: skip missed frames, don't disconnect.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::debug!("WS pose client lagged by {n} frames, skipping");
|
||||
continue;
|
||||
}
|
||||
Err(_) => break, // channel closed
|
||||
}
|
||||
}
|
||||
msg = socket.recv() => {
|
||||
@@ -2207,6 +2242,7 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(Message::Pong(_))) => {} // keepalive response
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -4295,7 +4331,18 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
|
||||
if s.tx.receiver_count() > 0 {
|
||||
// Re-broadcast the latest sensing_update so pose WS clients
|
||||
// always get data even when ESP32 pauses between frames.
|
||||
if let Ok(json) = serde_json::to_string(update) {
|
||||
//
|
||||
// Issue #618: overwrite `source` with `effective_source()`
|
||||
// before each broadcast so a stale latest_update (frozen
|
||||
// payload from a now-offline ESP32) is emitted with
|
||||
// `source: "esp32:offline"` instead of `source: "esp32"`.
|
||||
// The REST `/health` endpoint already does this; before
|
||||
// this fix the WS path was the only consumer that didn't,
|
||||
// so the UI's "LIVE — ESP32 HARDWARE Connected" banner
|
||||
// stayed green long after the hardware went away.
|
||||
let mut tagged = update.clone();
|
||||
tagged.source = s.effective_source();
|
||||
if let Ok(json) = serde_json::to_string(&tagged) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
}
|
||||
@@ -4305,6 +4352,25 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
|
||||
fn coalesce_ui_path(initial: std::path::PathBuf) -> std::path::PathBuf {
|
||||
if initial.is_dir() {
|
||||
return initial;
|
||||
}
|
||||
for rel in &["../ui", "./ui", "../../ui"] {
|
||||
let p = std::path::PathBuf::from(rel);
|
||||
if p.is_dir() {
|
||||
warn!(
|
||||
"UI path {} not found; using {} (set --ui-path explicitly if wrong)",
|
||||
initial.display(),
|
||||
p.display()
|
||||
);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
initial
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
@@ -4315,7 +4381,8 @@ async fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
let mut args = Args::parse();
|
||||
args.ui_path = coalesce_ui_path(args.ui_path);
|
||||
|
||||
// Handle --benchmark mode: run vital sign benchmark and exit
|
||||
if args.benchmark {
|
||||
@@ -4969,11 +5036,39 @@ async fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// DNS-rebinding defense: validate the `Host` header against an allowlist
|
||||
// before any handler runs. Default is loopback-only (`localhost`,
|
||||
// `127.0.0.1`, `[::1]`, each with or without a port). Operators extend
|
||||
// the set via `--allowed-host` flags or the `SENSING_ALLOWED_HOSTS` env
|
||||
// var; `--disable-host-validation` opts out entirely for reverse-proxy
|
||||
// setups that already canonicalise `Host`.
|
||||
let host_allowlist = if args.disable_host_validation {
|
||||
warn!(
|
||||
"Host-header validation DISABLED — server is reachable via any Host. \
|
||||
Only use this behind a reverse proxy that pins Host."
|
||||
);
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled()
|
||||
} else {
|
||||
let allowlist =
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env(
|
||||
args.allowed_hosts.iter().cloned(),
|
||||
);
|
||||
info!(
|
||||
"Host-header validation ON ({} entries; loopback names always included)",
|
||||
allowlist.entries_for_test().len()
|
||||
);
|
||||
allowlist
|
||||
};
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
.route("/health", get(health))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(ws_state);
|
||||
|
||||
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
|
||||
@@ -5066,6 +5161,14 @@ async fn main() {
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
// `/health` and `/ws/*` — observes the body.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
|
||||
@@ -215,6 +215,11 @@ async fn scan_models() -> Vec<ModelInfo> {
|
||||
|
||||
/// Load a model from disk by ID and return its `LoadedModelState`.
|
||||
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
|
||||
// Path-traversal guard (#615). Reject any model_id that contains '/',
|
||||
// '..', null bytes, or anything outside [A-Za-z0-9._-]. The reject
|
||||
// happens before format!() so the path can never escape models_dir().
|
||||
let model_id = crate::path_safety::safe_id(model_id)
|
||||
.map_err(|e| format!("Invalid model_id: {e}"))?;
|
||||
let file_path = models_dir().join(format!("{model_id}.rvf"));
|
||||
let reader = RvfReader::from_file(&file_path)?;
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Identifier sanitization for filesystem paths.
|
||||
//!
|
||||
//! Defense against directory traversal: the sensing-server has several REST
|
||||
//! endpoints that take a user-controlled identifier and use it directly to
|
||||
//! build a filesystem path:
|
||||
//!
|
||||
//! * `recording.rs` — `{session_name}.csi.jsonl` under `RECORDINGS_DIR`
|
||||
//! * `model_manager.rs` — `{model_id}.rvf` under `models_dir()`
|
||||
//! * `training_api.rs` — `{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`
|
||||
//!
|
||||
//! Without validation, an attacker can pass `../../etc/passwd` or similar to
|
||||
//! read, write, or delete arbitrary files the server process can access. See
|
||||
//! issue #615 for the full exploit catalogue.
|
||||
//!
|
||||
//! [`safe_id`] returns the input only when it is safe to embed in a
|
||||
//! `format!()` that builds a path under a fixed parent directory.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Maximum length for a safe identifier. 64 is generous for human-typed
|
||||
/// session names while keeping the resulting filename well under
|
||||
/// most filesystem limits.
|
||||
pub const MAX_ID_LEN: usize = 64;
|
||||
|
||||
/// Error returned by [`safe_id`] when the input is not safe to embed in a
|
||||
/// filesystem path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathSafetyError {
|
||||
/// Empty string is never a valid identifier.
|
||||
Empty,
|
||||
/// Identifier exceeds `MAX_ID_LEN` bytes.
|
||||
TooLong { len: usize, max: usize },
|
||||
/// Identifier contains a character not in the allowed set
|
||||
/// `[A-Za-z0-9._-]` (and the leading character is not `.`).
|
||||
/// Path separators, null bytes, parent-directory references, and any
|
||||
/// non-printable or non-ASCII characters all hit this.
|
||||
InvalidChar { ch: char, position: usize },
|
||||
/// Identifier is `"."` or `".."`, or any leading `.` that would
|
||||
/// otherwise be interpreted as a hidden file / parent reference.
|
||||
LeadingDot,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathSafetyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PathSafetyError::Empty => write!(f, "identifier is empty"),
|
||||
PathSafetyError::TooLong { len, max } => {
|
||||
write!(f, "identifier is {len} bytes (max {max})")
|
||||
}
|
||||
PathSafetyError::InvalidChar { ch, position } => write!(
|
||||
f,
|
||||
"identifier contains invalid character {ch:?} at position {position} \
|
||||
(only A-Z, a-z, 0-9, '.', '_', '-' are allowed)"
|
||||
),
|
||||
PathSafetyError::LeadingDot => write!(
|
||||
f,
|
||||
"identifier may not start with '.' (would be a hidden file \
|
||||
or parent-directory reference)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PathSafetyError {}
|
||||
|
||||
/// Return `Ok(input)` if the string is safe to embed in a filesystem path
|
||||
/// built under a fixed parent directory; otherwise return a structured error.
|
||||
///
|
||||
/// The allowed character set is `[A-Za-z0-9._-]`. The first character must
|
||||
/// not be `.` (rules out `..`, `.`, and hidden-file shenanigans).
|
||||
///
|
||||
/// Examples:
|
||||
/// ```ignore
|
||||
/// assert!(safe_id("my-session_42").is_ok());
|
||||
/// assert!(safe_id("session.v2").is_ok());
|
||||
/// assert!(safe_id("../../etc/passwd").is_err());
|
||||
/// assert!(safe_id("foo/bar").is_err());
|
||||
/// assert!(safe_id("..").is_err());
|
||||
/// assert!(safe_id(".env").is_err());
|
||||
/// assert!(safe_id("").is_err());
|
||||
/// ```
|
||||
pub fn safe_id(input: &str) -> Result<&str, PathSafetyError> {
|
||||
if input.is_empty() {
|
||||
return Err(PathSafetyError::Empty);
|
||||
}
|
||||
if input.len() > MAX_ID_LEN {
|
||||
return Err(PathSafetyError::TooLong {
|
||||
len: input.len(),
|
||||
max: MAX_ID_LEN,
|
||||
});
|
||||
}
|
||||
// Reject leading '.' to block `.`, `..`, `.env`, etc.
|
||||
if input.starts_with('.') {
|
||||
return Err(PathSafetyError::LeadingDot);
|
||||
}
|
||||
for (position, ch) in input.chars().enumerate() {
|
||||
let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
|
||||
if !ok {
|
||||
return Err(PathSafetyError::InvalidChar { ch, position });
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_simple_alphanumeric() {
|
||||
assert!(safe_id("foo").is_ok());
|
||||
assert!(safe_id("MyModel123").is_ok());
|
||||
assert!(safe_id("session-2026-05-17_v2").is_ok());
|
||||
assert!(safe_id("a.b.c").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert_eq!(safe_id(""), Err(PathSafetyError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_separators() {
|
||||
assert!(matches!(
|
||||
safe_id("foo/bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '/', .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo\\bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '\\', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_directory_traversal() {
|
||||
assert_eq!(safe_id("."), Err(PathSafetyError::LeadingDot));
|
||||
assert_eq!(safe_id(".."), Err(PathSafetyError::LeadingDot));
|
||||
assert_eq!(safe_id(".env"), Err(PathSafetyError::LeadingDot));
|
||||
// The classic attack vector — even after rejecting leading-dot,
|
||||
// the InvalidChar guard catches the embedded slash.
|
||||
assert!(matches!(
|
||||
safe_id("../../etc/passwd"),
|
||||
Err(PathSafetyError::LeadingDot)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_null_byte() {
|
||||
assert!(matches!(
|
||||
safe_id("foo\0bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '\0', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_whitespace_and_specials() {
|
||||
assert!(matches!(
|
||||
safe_id("foo bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: ' ', .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo;rm -rf /"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
safe_id("foo$bar"),
|
||||
Err(PathSafetyError::InvalidChar { ch: '$', .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ascii() {
|
||||
// Reject unicode that could normalise to path separators in
|
||||
// weird filesystems, or just look like ASCII.
|
||||
assert!(matches!(
|
||||
safe_id("café"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
// Fullwidth slash (U+FF0F) — visually similar to '/'.
|
||||
assert!(matches!(
|
||||
safe_id("foo\u{FF0F}bar"),
|
||||
Err(PathSafetyError::InvalidChar { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
let too_long = "a".repeat(MAX_ID_LEN + 1);
|
||||
assert_eq!(
|
||||
safe_id(&too_long),
|
||||
Err(PathSafetyError::TooLong {
|
||||
len: MAX_ID_LEN + 1,
|
||||
max: MAX_ID_LEN
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_max_len() {
|
||||
let at_max = "a".repeat(MAX_ID_LEN);
|
||||
assert!(safe_id(&at_max).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -274,9 +274,22 @@ async fn start_recording(
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate session_name BEFORE embedding it in a path. The legacy
|
||||
// `replace(' ', "_")` only normalised whitespace, not path traversal
|
||||
// (#615). Reject any session_name containing path separators or
|
||||
// parent-directory references.
|
||||
let safe_name = match crate::path_safety::safe_id(&body.session_name) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid session_name: {e}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let session_id = format!(
|
||||
"{}-{}",
|
||||
body.session_name.replace(' ', "_"),
|
||||
safe_name,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
||||
);
|
||||
let file_name = format!("{session_id}.csi.jsonl");
|
||||
@@ -346,6 +359,23 @@ async fn download_recording(
|
||||
State(_state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Path-traversal guard (#615). Reject any id that contains '/', '..',
|
||||
// null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
|
||||
// path. Otherwise GET /api/v1/recording/download/../../.env leaks
|
||||
// arbitrary files the server process can read.
|
||||
let id = match crate::path_safety::safe_id(&id) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid recording id: {e}"),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let dir = PathBuf::from(RECORDINGS_DIR);
|
||||
// Find the JSONL file matching the ID.
|
||||
let file_path = dir.join(format!("{id}.csi.jsonl"));
|
||||
@@ -390,6 +420,19 @@ async fn delete_recording(
|
||||
State(_state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Path-traversal guard (#615). Reject any id that contains '/', '..',
|
||||
// null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
|
||||
// paths. Otherwise DELETE /api/v1/recording/delete/../../config/database
|
||||
// can remove arbitrary files the server process can write.
|
||||
let id = match crate::path_safety::safe_id(&id) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Invalid recording id: {e}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let dir = PathBuf::from(RECORDINGS_DIR);
|
||||
let jsonl_path = dir.join(format!("{id}.csi.jsonl"));
|
||||
let meta_path = dir.join(format!("{id}.csi.meta.json"));
|
||||
|
||||
@@ -239,7 +239,18 @@ async fn load_recording_frames(dataset_ids: &[String]) -> Vec<RecordedFrame> {
|
||||
let recordings_dir = PathBuf::from(RECORDINGS_DIR);
|
||||
|
||||
for id in dataset_ids {
|
||||
let file_path = recordings_dir.join(format!("{id}.csi.jsonl"));
|
||||
// Path-traversal guard (#615). Reject any dataset_id that contains
|
||||
// '/', '..', null bytes, or anything outside [A-Za-z0-9._-] BEFORE
|
||||
// building the format!() path. Otherwise an attacker could read any
|
||||
// file the server process can access via `dataset_ids: ["../../etc/passwd"]`.
|
||||
let safe = match crate::path_safety::safe_id(id) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("Skipping invalid dataset_id {id:?}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_path = recordings_dir.join(format!("{safe}.csi.jsonl"));
|
||||
let data = match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
|
||||
@@ -139,13 +139,15 @@ impl VitalSignDetector {
|
||||
// Cardiac-induced body surface displacement is < 0.5 mm, producing
|
||||
// tiny phase changes. Cross-subcarrier phase variance captures this
|
||||
// more sensitively than amplitude alone.
|
||||
//
|
||||
// Phases come from atan2() and are wrapped to (-pi, pi]. Linear mean
|
||||
// and variance on wrapped values is wrong: two phases close across
|
||||
// the +/-pi discontinuity (e.g. pi-eps and -pi+eps) are physically
|
||||
// ~2*eps rad apart but produce arithmetic variance ~pi^2. Use the
|
||||
// standard circular variance (1 - mean resultant length), which is
|
||||
// stable across the wrap.
|
||||
let phase_var = if phase.len() > 1 {
|
||||
let mean_phase: f64 = phase.iter().sum::<f64>() / phase.len() as f64;
|
||||
phase
|
||||
.iter()
|
||||
.map(|p| (p - mean_phase).powi(2))
|
||||
.sum::<f64>()
|
||||
/ phase.len() as f64
|
||||
phase_circular_variance(phase)
|
||||
} else {
|
||||
// Fallback: use amplitude high-pass residual when phase is unavailable
|
||||
let half = amplitude.len() / 2;
|
||||
@@ -367,6 +369,25 @@ impl VitalSignDetector {
|
||||
/// Constructs a bandpass by subtracting two lowpass filters (LPF_high - LPF_low)
|
||||
/// with a Hamming window. This is a zero-external-dependency implementation
|
||||
/// suitable for the buffer sizes we encounter (up to ~600 samples).
|
||||
/// Circular variance of wrapped phase samples in radians.
|
||||
///
|
||||
/// Returns `1 - R` where `R` is the mean resultant length of the unit-circle
|
||||
/// representation of the input. `0.0` means all samples point in the same
|
||||
/// direction; `1.0` means they are uniformly spread. Stable across the +/-pi
|
||||
/// discontinuity of `atan2`-derived phases. Returns `0.0` for inputs shorter
|
||||
/// than 2 samples.
|
||||
pub(crate) fn phase_circular_variance(phase: &[f64]) -> f64 {
|
||||
if phase.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let (sin_sum, cos_sum) = phase
|
||||
.iter()
|
||||
.fold((0.0_f64, 0.0_f64), |(s, c), &p| (s + p.sin(), c + p.cos()));
|
||||
let n = phase.len() as f64;
|
||||
let r = (sin_sum * sin_sum + cos_sum * cos_sum).sqrt() / n;
|
||||
(1.0 - r).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub fn bandpass_filter(data: &[f64], low_hz: f64, high_hz: f64, sample_rate: f64) -> Vec<f64> {
|
||||
if data.len() < 3 || sample_rate < f64::EPSILON {
|
||||
return data.to_vec();
|
||||
@@ -605,6 +626,89 @@ pub fn run_benchmark(n_frames: usize) -> (std::time::Duration, std::time::Durati
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test for the linear-vs-circular phase variance bug.
|
||||
///
|
||||
/// Two CSI subcarriers whose phases land just either side of the +/-pi
|
||||
/// wrap are physically ~2*eps rad apart, but the previous code computed
|
||||
/// arithmetic mean + arithmetic variance on the wrapped values, treating
|
||||
/// them as ~2*pi apart and producing variance ~pi^2 ~= 9.87. The corrected
|
||||
/// circular variance returns ~1e-6 for the same input.
|
||||
#[test]
|
||||
fn test_phase_variance_handles_wraparound() {
|
||||
// Two phases ~0.002 rad apart, straddling the +/-pi discontinuity.
|
||||
let phases = [PI - 0.001, -PI + 0.001];
|
||||
|
||||
let v = phase_circular_variance(&phases);
|
||||
assert!(
|
||||
v < 0.01,
|
||||
"circular variance of nearly-identical wrapped phases must be tiny, got {v}"
|
||||
);
|
||||
|
||||
// For reference, the *old* (buggy) linear formula on the same input
|
||||
// produced ~9.87. Document that gap here so the assertion above
|
||||
// explicitly fails on any regression to the linear computation.
|
||||
let mean_linear: f64 = phases.iter().sum::<f64>() / phases.len() as f64;
|
||||
let v_linear_buggy: f64 = phases
|
||||
.iter()
|
||||
.map(|p| (p - mean_linear).powi(2))
|
||||
.sum::<f64>()
|
||||
/ phases.len() as f64;
|
||||
assert!(
|
||||
v_linear_buggy > 9.0,
|
||||
"sanity: linear formula on wrapped input should be ~pi^2, got {v_linear_buggy}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: `process_frame` must not blow up `heartbeat_buffer` when
|
||||
/// the input phases happen to straddle the +/-pi wrap. Anyone can run this
|
||||
/// against `main` (with the inline linear-formula bug) and observe the
|
||||
/// stored value of ~9.86; with the fix it is ~1e-6.
|
||||
#[test]
|
||||
fn test_process_frame_handles_wrapped_phases() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let amp = vec![1.0_f64; 8];
|
||||
// 8 subcarriers, all physically ~aligned at ~+/-pi, alternating sign.
|
||||
let phase = vec![
|
||||
PI - 0.001, -PI + 0.001, PI - 0.001, -PI + 0.001,
|
||||
PI - 0.001, -PI + 0.001, PI - 0.001, -PI + 0.001,
|
||||
];
|
||||
|
||||
detector.process_frame(&, &phase);
|
||||
|
||||
let stored = *detector
|
||||
.heartbeat_buffer
|
||||
.back()
|
||||
.expect("process_frame should push exactly one phase_var");
|
||||
|
||||
assert!(
|
||||
stored < 0.1,
|
||||
"phase_var pushed into heartbeat_buffer should be near 0 for \
|
||||
physically-aligned wrapped phases, got {stored}. The linear \
|
||||
(buggy) formula produces ~pi^2 = 9.87 here; the circular \
|
||||
(fixed) formula produces ~1e-6."
|
||||
);
|
||||
}
|
||||
|
||||
/// Diametrically opposite phases must yield maximum circular variance.
|
||||
#[test]
|
||||
fn test_phase_variance_opposite_phases() {
|
||||
let v = phase_circular_variance(&[0.0, PI]);
|
||||
assert!(
|
||||
v > 0.99,
|
||||
"two opposite phases must give circular variance near 1.0, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Identical phases must yield zero circular variance, even far from zero.
|
||||
#[test]
|
||||
fn test_phase_variance_identical_phases() {
|
||||
let v = phase_circular_variance(&[2.5, 2.5, 2.5, 2.5]);
|
||||
assert!(
|
||||
v < 1e-9,
|
||||
"identical phases must give circular variance ~0, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fft_magnitude_dc() {
|
||||
let signal = vec![1.0; 8];
|
||||
|
||||