mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6420ac9ba | |||
| c353255672 | |||
| 872d7593bb | |||
| 2c136aca74 | |||
| 69e61e3437 | |||
| d9e87e13b4 | |||
| be48143f77 | |||
| c453268002 | |||
| 6ee21a0941 | |||
| 0cfd255730 | |||
| f5d0e1e69e | |||
| b12662a54d | |||
| 573b00fd98 | |||
| 91b0e625bd | |||
| 88b835dd89 | |||
| f8f08076eb | |||
| 55f6a74e1e | |||
| b5a91c5635 | |||
| 308d2fc89d | |||
| 5038e3c8e1 | |||
| e239af3636 | |||
| 4856afbd0c | |||
| 4d205a05c4 | |||
| bc42ae7903 | |||
| b7b8c1109b | |||
| 786e834dae | |||
| 8703ade9b6 | |||
| 4c87f04919 | |||
| 9df908d898 | |||
| f34b94aa46 | |||
| 27edf153dc | |||
| 3fec67654a | |||
| 898c536eac | |||
| 9ddcf0c9fc |
+46
-17
@@ -108,16 +108,18 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
|
||||
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
|
||||
# intermittent failure source — several CI runs this cycle died at the
|
||||
# cache/setup step (after toolchain install, before "Run Rust tests"),
|
||||
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
|
||||
# registry + git + a pruned target, evicts stale deps, and restores far more
|
||||
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
|
||||
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
workspaces: v2
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
@@ -265,23 +267,45 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install locust
|
||||
pip install pytest # the perf suite is pytest, not locust
|
||||
|
||||
- name: Start application
|
||||
working-directory: archive/v1
|
||||
run: |
|
||||
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
|
||||
sleep 10
|
||||
# No "Start application" step: the gated test (test_frame_budget.py) drives
|
||||
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
|
||||
# uvicorn server + `sleep 10` were dead weight — they only existed for the
|
||||
# now-excluded api_throughput/inference_speed tests, and on every run dumped
|
||||
# ~50 misleading "router requires hardware setup" ERROR lines for a server
|
||||
# no test touched. MOCK_POSE_DATA is server-only and unused here.
|
||||
|
||||
- name: Run performance tests
|
||||
working-directory: archive/v1
|
||||
run: |
|
||||
locust -f tests/performance/locustfile.py --headless --users 50 --spawn-rate 5 --run-time 60s --host http://localhost:8000
|
||||
# Gate only on the genuine, deterministic perf guard:
|
||||
# test_frame_budget.py times the *real* CSIProcessor pipeline against
|
||||
# the ADR 50 ms per-frame budget (single-frame, p95 over 100 frames,
|
||||
# +Doppler) — a true regression signal.
|
||||
#
|
||||
# test_api_throughput.py / test_inference_speed.py are excluded: every
|
||||
# test there is a TDD red-phase stub (suffix `_should_fail_initially`)
|
||||
# that times a *mock that sleeps* — meaningless as a perf signal, with
|
||||
# machine-dependent wall-clock asserts (e.g. `actual_rps >= 40`,
|
||||
# `batch_time < individual_time`) that are inherently flaky on shared
|
||||
# CI runners, plus a cross-class fixture-scope bug. Forcing them green
|
||||
# would be manufacturing a false signal; they stay in-repo for local
|
||||
# TDD but do not gate CI until the underlying features are implemented.
|
||||
#
|
||||
# `python -m pytest` (not the bare `pytest` script) puts the cwd
|
||||
# (archive/v1) on sys.path so `from src.core...` resolves — the bare
|
||||
# script omits cwd and raises ModuleNotFoundError: No module named 'src'.
|
||||
# -o addopts="" drops the root pyproject's --cov/--cov-fail-under=100.
|
||||
python -m pytest tests/performance/test_frame_budget.py \
|
||||
-o addopts="" -v --junitxml=perf-junit.xml
|
||||
|
||||
- name: Upload performance results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-results
|
||||
path: locust_report.html
|
||||
path: archive/v1/perf-junit.xml
|
||||
|
||||
# Docker Build and Test
|
||||
# NOTE: the canonical Docker build for the sensing-server is now
|
||||
@@ -367,6 +391,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-build]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -384,6 +410,8 @@ jobs:
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
MOCK_POSE_DATA: "true" # no CSI hardware in CI
|
||||
run: |
|
||||
python -c "
|
||||
from src.api.main import app
|
||||
@@ -394,6 +422,7 @@ jobs:
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
continue-on-error: true # openapi generation above is the real validation; deploy is best-effort (Pages may be disabled)
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs
|
||||
|
||||
@@ -46,7 +46,10 @@ jobs:
|
||||
|
||||
- name: Run Bandit security scan
|
||||
run: |
|
||||
bandit -r src/ -f sarif -o bandit-results.sarif
|
||||
# The Python codebase lives under archive/v1/src (it moved there when
|
||||
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
|
||||
# so this SAST step was a silent no-op.
|
||||
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
@@ -57,22 +60,20 @@ jobs:
|
||||
sarif_file: bandit-results.sarif
|
||||
category: bandit
|
||||
|
||||
- name: Run Semgrep security scan
|
||||
continue-on-error: true
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
p/secrets
|
||||
p/python
|
||||
p/docker
|
||||
p/kubernetes
|
||||
env:
|
||||
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
|
||||
|
||||
- name: Generate Semgrep SARIF
|
||||
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
|
||||
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
|
||||
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
|
||||
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
|
||||
# which intermittently timed out and turned this check red. The pip semgrep
|
||||
# (installed above) needs no Docker pull. The action's `p/docker` +
|
||||
# `p/kubernetes` rulesets are folded into the command below so coverage is
|
||||
# preserved.
|
||||
- name: Run Semgrep + generate SARIF
|
||||
run: |
|
||||
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
|
||||
semgrep \
|
||||
--config=p/security-audit --config=p/secrets --config=p/python \
|
||||
--config=p/docker --config=p/kubernetes \
|
||||
--sarif --output=semgrep.sarif archive/v1/src/
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
|
||||
+8
-1
@@ -8,8 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
|
||||
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
|
||||
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat` — `TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
|
||||
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
|
||||
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
|
||||
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
|
||||
|
||||
### Added
|
||||
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.2–3.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
|
||||
@@ -31,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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.
|
||||
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
|
||||
- **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`)
|
||||
@@ -428,7 +435,7 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
|
||||
- Security fix merged via PR #310.
|
||||
|
||||
### Performance
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples.
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
|
||||
- Inference: 0.008 ms per sample, 164K embeddings/sec.
|
||||
- Contrastive self-supervised training: 51.6% improvement over baseline.
|
||||
|
||||
|
||||
@@ -107,16 +107,25 @@ class PoseService:
|
||||
async def _initialize_models(self):
|
||||
"""Initialize neural network models."""
|
||||
try:
|
||||
# Initialize DensePose model
|
||||
# Initialize DensePose model. DensePoseHead requires a config
|
||||
# dict — input_channels matches the modality translator's output
|
||||
# (256), with the standard DensePose 24 body parts and 2 (U,V)
|
||||
# coordinates. (Previously called with no args → TypeError at
|
||||
# startup, which broke the API service.)
|
||||
densepose_config = {
|
||||
'input_channels': 256,
|
||||
'num_body_parts': 24,
|
||||
'num_uv_coordinates': 2,
|
||||
}
|
||||
if self.settings.pose_model_path:
|
||||
self.densepose_model = DensePoseHead()
|
||||
self.densepose_model = DensePoseHead(densepose_config)
|
||||
# Load model weights if path is provided
|
||||
# model_state = torch.load(self.settings.pose_model_path)
|
||||
# self.densepose_model.load_state_dict(model_state)
|
||||
self.logger.info("DensePose model loaded")
|
||||
else:
|
||||
self.logger.warning("No pose model path provided, using default model")
|
||||
self.densepose_model = DensePoseHead()
|
||||
self.densepose_model = DensePoseHead(densepose_config)
|
||||
|
||||
# Initialize modality translation
|
||||
config = {
|
||||
|
||||
@@ -24,10 +24,13 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||
# Options: auto (default) — probe for ESP32 UDP then host WiFi; **fail
|
||||
# hard with exit 78 if neither is detected**.
|
||||
# Synthetic data is no longer a silent fallback
|
||||
# (issue #937 fix) — operators must opt in.
|
||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||
# wifi — use host Wi-Fi RSSI/scan data (Windows netsh)
|
||||
# simulated — generate synthetic CSI data (no hardware required)
|
||||
# simulated — explicitly generate synthetic CSI for demo mode
|
||||
- CSI_SOURCE=${CSI_SOURCE:-auto}
|
||||
# MODELS_DIR controls where the server scans for .rvf model files.
|
||||
# Mount a host directory and set this to make models visible:
|
||||
|
||||
@@ -11,10 +11,65 @@
|
||||
# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf
|
||||
#
|
||||
# Environment variables:
|
||||
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
||||
# CSI_SOURCE — data source. Valid values:
|
||||
# auto — try ESP32 then Windows WiFi, **fail-loud if no
|
||||
# real hardware is detected** (issue #937 fix:
|
||||
# the server no longer silently falls back to
|
||||
# synthetic data — that's now opt-in only).
|
||||
# esp32 — listen for UDP CSI on the configured port.
|
||||
# wifi — Windows-native WiFi capture.
|
||||
# simulated — explicit demo mode with synthetic CSI.
|
||||
# Default is `auto`. Set CSI_SOURCE=simulated when you want
|
||||
# fake data tagged as such; never set it implicitly.
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# ── Issue #864: fail-closed on default posture ───────────────────────────────
|
||||
# The pre-fix default was: empty RUVIEW_API_TOKEN (auth off) + --bind-addr
|
||||
# 0.0.0.0 + docker-compose publishing :3000/:3001/:5005 → an unauthenticated
|
||||
# attacker on any reachable network segment could read /api/v1/sensing/latest
|
||||
# and the /ws/sensing live stream. That posture is unsafe on guest WiFi,
|
||||
# untrusted LANs, accidentally-port-forwarded hosts, or any reverse-proxied
|
||||
# deployment. Refuse to start with this combination.
|
||||
#
|
||||
# Escape hatches (operator must opt in explicitly):
|
||||
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
|
||||
# only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
|
||||
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
|
||||
#
|
||||
# This check runs only for the default sensing-server path (no args + flag-only
|
||||
# args). The `cog-ha-matter` / `homecore` routes below are excluded because
|
||||
# they own their own auth lifecycle.
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
|
||||
*)
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
|
||||
# If the operator hasn't overridden the bind, refuse outright on
|
||||
# the default 0.0.0.0. If they've nailed it to loopback (or a
|
||||
# specific private address they trust), let it run.
|
||||
__bind_default="${RUVIEW_BIND_ADDR:-0.0.0.0}"
|
||||
case "$__bind_default" in
|
||||
127.*|localhost|::1)
|
||||
: ;; # loopback bind is safe even without a token
|
||||
*)
|
||||
echo "[entrypoint] ERROR: refusing to start sensing-server with default" >&2
|
||||
echo "[entrypoint] posture: RUVIEW_API_TOKEN is unset AND bind is" >&2
|
||||
echo "[entrypoint] ${__bind_default}. /ws/sensing streams live sensing" >&2
|
||||
echo "[entrypoint] frames; that data would be readable by anyone who" >&2
|
||||
echo "[entrypoint] can reach this host. Pick one:" >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_API_TOKEN=\$(openssl rand -hex 32) ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_BIND_ADDR=127.0.0.1 ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # only on trusted network" >&2
|
||||
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/864" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||
# docker run <image> cog-ha-matter [--flags]
|
||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||
@@ -48,7 +103,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
--ui-path /app/ui \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--bind-addr 0.0.0.0 \
|
||||
--bind-addr "${RUVIEW_BIND_ADDR:-0.0.0.0}" \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
|
||||
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric on the last 20% by time (v1's "100% presence" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
|
||||
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
|
||||
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
|
||||
@@ -233,7 +233,7 @@ python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
|
||||
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
|
||||
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
|
||||
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
|
||||
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
|
||||
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 82.3% held-out temporal-triplet accuracy (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) | Download from release |
|
||||
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
|
||||
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
|
||||
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
|
||||
@@ -262,7 +262,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
|
||||
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
|
||||
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |
|
||||
|
||||
+5
-5
@@ -1048,7 +1048,7 @@ The Rust sensing server binary accepts the following flags:
|
||||
| `--dataset` | (none) | Path to dataset directory (MM-Fi or Wi-Pose) |
|
||||
| `--dataset-type` | `mmfi` | Dataset format: `mmfi` or `wipose` |
|
||||
| `--epochs` | `100` | Training epochs |
|
||||
| `--export-rvf` | (none) | Export RVF model container and exit |
|
||||
| `--export-rvf` | (none) | Export a **placeholder** RVF container-format demo and exit — **not a trained model**. For a real model use `--train` (+ `--save-rvf`) or download a pretrained encoder. |
|
||||
| `--save-rvf` | (none) | Save model state to RVF on shutdown |
|
||||
| `--model` | (none) | Load a trained `.rvf` model for inference |
|
||||
| `--load-rvf` | (none) | Load model config from RVF container |
|
||||
@@ -1119,7 +1119,7 @@ What it ships (and what it does not):
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
|
||||
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
|
||||
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
@@ -1359,7 +1359,7 @@ docker run --rm \
|
||||
-v $(pwd)/output:/output \
|
||||
--entrypoint /app/sensing-server \
|
||||
ruvnet/wifi-densepose:latest \
|
||||
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
|
||||
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
|
||||
```
|
||||
|
||||
The pipeline runs 10 phases:
|
||||
@@ -1824,7 +1824,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
|
||||
# model.safetensors — 48 KB contrastive encoder
|
||||
# model-q4.bin — 8 KB quantized (recommended)
|
||||
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
|
||||
# presence-head.json — presence detection head (100% accuracy)
|
||||
# presence-head.json — presence detection head (v2 encoder: 82.3% held-out triplet acc)
|
||||
# node-1.json — LoRA adapter for room 1
|
||||
# node-2.json — LoRA adapter for room 2
|
||||
```
|
||||
@@ -1833,7 +1833,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
|
||||
|
||||
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
|
||||
|
||||
- **Presence detection** — 100% accuracy, never misses, never false alarms
|
||||
- **Presence detection** — v2 encoder: 82.3% held-out temporal-triplet accuracy (v1's "100%" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882))
|
||||
- **Environment fingerprinting** — kNN search finds "states like this one"
|
||||
- **Anomaly detection** — embeddings that don't match known clusters = anomaly
|
||||
- **Activity classification** — different activities cluster in embedding space
|
||||
|
||||
@@ -65,6 +65,15 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC
|
||||
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
|
||||
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
|
||||
WASM3_AVAILABLE=1 # Flag for conditional compilation
|
||||
# Issue #946: GCC 15.2.0 for Xtensa (ESP-IDF v6.0.1) rejects wasm3's
|
||||
# `M3_MUSTTAIL` aggressive tail-call attribute with
|
||||
# "cannot tail-call: machine description does not have a sibcall_epilogue
|
||||
# instruction pattern". wasm3 falls back to a regular call sequence when
|
||||
# M3_NO_MUSTTAIL is defined — slightly slower per opcode but functionally
|
||||
# identical. Forcing it off unconditionally on Xtensa is fine because the
|
||||
# tail-call optimisation was never reliable on this target anyway. Older
|
||||
# IDF/GCC builds also accept the define (it just becomes a no-op).
|
||||
M3_NO_MUSTTAIL=1
|
||||
)
|
||||
|
||||
# Suppress warnings from third-party code.
|
||||
|
||||
@@ -220,11 +220,20 @@ static void fast_loop_cb(TimerHandle_t t)
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
/* ADR-081 Layer 4/5: emit compact feature state at 1 Hz (the spec's
|
||||
* 1–10 Hz floor). Was previously emitted on every fast tick (~5 Hz at
|
||||
* the default 200 ms fast period), which combined with CSI promiscuous
|
||||
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
|
||||
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
|
||||
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
|
||||
* on the rate because the bottleneck was radio TX time, not pool size.
|
||||
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
|
||||
* for CSI sends and lands well within the spec. */
|
||||
static uint8_t s_emit_divider = 0;
|
||||
if (++s_emit_divider >= 5) {
|
||||
s_emit_divider = 0;
|
||||
emit_feature_state();
|
||||
}
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
@@ -144,11 +145,27 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
||||
}
|
||||
}
|
||||
|
||||
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
|
||||
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
|
||||
* to
|
||||
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
* Both signatures ignore the address-side argument here — we only inspect
|
||||
* `status` to bump the TX-fail counter — so the body is identical; only the
|
||||
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
|
||||
*/
|
||||
#if ESP_IDF_VERSION_MAJOR >= 6
|
||||
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
{
|
||||
(void)tx_info;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#else
|
||||
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
|
||||
{
|
||||
(void)mac;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
|
||||
@@ -637,6 +637,23 @@ static void hop_timer_cb(void *arg)
|
||||
csi_hop_next_channel();
|
||||
}
|
||||
|
||||
void csi_collector_enable_data_capture(void)
|
||||
{
|
||||
/* MGMT-only (RuView#396) starves the CSI callback on display-less boards
|
||||
* (RuView#521/#893): beacons alone are sparse, yield collapses to 0 pps.
|
||||
* Without a display there is no QSPI/SPI-flash cache contention with the
|
||||
* DATA-frame interrupt load, so capture DATA frames too. */
|
||||
wifi_promiscuous_filter_t filt = {
|
||||
.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA,
|
||||
};
|
||||
esp_err_t err = esp_wifi_set_promiscuous_filter(&filt);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "CSI filter upgraded to MGMT+DATA (no display, RuView#893)");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to enable DATA-frame CSI capture: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
void csi_collector_start_hop_timer(void)
|
||||
{
|
||||
if (s_hop_count <= 1) {
|
||||
|
||||
@@ -90,6 +90,19 @@ void csi_hop_next_channel(void);
|
||||
*/
|
||||
void csi_collector_start_hop_timer(void);
|
||||
|
||||
/**
|
||||
* Upgrade the promiscuous filter to capture DATA frames in addition to MGMT
|
||||
* (RuView#893/#521).
|
||||
*
|
||||
* Called on display-less boards: the MGMT-only filter (the #396 display-crash
|
||||
* workaround set in csi_collector_init) only fires the CSI callback on sparse
|
||||
* management frames, so yield collapses to 0 pps under real traffic and the
|
||||
* node looks dead. A board with no AMOLED panel has no QSPI/SPI-flash cache
|
||||
* contention, so it can safely capture DATA frames — restoring abundant CSI.
|
||||
* Display boards keep MGMT-only to avoid the #396 crash.
|
||||
*/
|
||||
void csi_collector_enable_data_capture(void);
|
||||
|
||||
/**
|
||||
* Inject an NDP (Null Data Packet) frame for sensing.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
#include "display_task.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
/* Set true once an AMOLED panel is detected and the display task starts.
|
||||
* Defined outside the CONFIG_DISPLAY_ENABLE guard so display_is_active()
|
||||
* exists on headless builds too (where it stays false → CSI captures DATA
|
||||
* frames; see RuView#893). */
|
||||
static bool s_display_active = false;
|
||||
|
||||
bool display_is_active(void) { return s_display_active; }
|
||||
|
||||
#if CONFIG_DISPLAY_ENABLE
|
||||
|
||||
#include <string.h>
|
||||
@@ -162,6 +170,7 @@ esp_err_t display_task_start(void)
|
||||
|
||||
ESP_LOGI(TAG, "Display task started (Core %d, priority %d, %d fps)",
|
||||
DISP_TASK_CORE, DISP_TASK_PRIORITY, DISP_FPS_LIMIT);
|
||||
s_display_active = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#define DISPLAY_TASK_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -22,6 +23,15 @@ extern "C" {
|
||||
*/
|
||||
esp_err_t display_task_start(void);
|
||||
|
||||
/**
|
||||
* @return true once an AMOLED panel has been detected and the display task
|
||||
* is running; false on headless boards (no panel, or built without display
|
||||
* support). Used to choose the CSI promiscuous filter (RuView#893): a board
|
||||
* with no display has no QSPI/SPI-flash contention, so it can safely capture
|
||||
* DATA frames for proper CSI yield instead of starving on MGMT-only.
|
||||
*/
|
||||
bool display_is_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -410,6 +410,21 @@ void app_main(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
/* RuView#893/#521: the MGMT-only promiscuous filter (set in
|
||||
* csi_collector_init as the #396 display-crash workaround) starves the CSI
|
||||
* callback on display-less boards — yield collapses to 0 pps and the node
|
||||
* looks dead despite being on the network. Now that the display probe has
|
||||
* run, boards with no AMOLED panel (no QSPI/SPI-flash cache contention)
|
||||
* upgrade the filter to capture DATA frames too, restoring CSI yield. */
|
||||
#ifdef CONFIG_DISPLAY_ENABLE
|
||||
bool has_display = display_is_active(); /* runtime panel probe result */
|
||||
#else
|
||||
bool has_display = false; /* display support not compiled in */
|
||||
#endif
|
||||
if (!has_display) {
|
||||
csi_collector_enable_data_capture();
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s, adapt=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
|
||||
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
|
||||
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
|
||||
* 0xC5110006 — ADR-081 feature state (this file) ← new
|
||||
* 0xC5110006 — ADR-081 feature state (this file)
|
||||
* 0xC5110007 — ADR-040 WASM output (wasm_runtime.h, reassigned per issue #928)
|
||||
*/
|
||||
|
||||
#ifndef RV_FEATURE_STATE_H
|
||||
|
||||
@@ -23,7 +23,16 @@
|
||||
static const char *TAG = "swarm";
|
||||
|
||||
/* ---- Task parameters ---- */
|
||||
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
|
||||
/* Issue #949: 3 KB was sized for plain HTTP (~2.5 KB). The bug reporter
|
||||
* configured `--seed-url https://…` which exercises TLS — mbedTLS handshake
|
||||
* alone needs 4-6 KB on the stack (cipher suite + cert chain + ECDH), and on
|
||||
* top of that esp_http_client adds another 1.5-2 KB. The task panicked with
|
||||
* `0xa5a5a5a5` (FreeRTOS stack-fill sentinel) immediately after "bridge init
|
||||
* OK". 8 KB comfortably fits TLS with margin for the cert chain + headers;
|
||||
* confirmed against mbedTLS's stack analyser. Plain-HTTP deployments waste
|
||||
* ~5 KB of headroom but that's <0.1 % of PSRAM, an acceptable cost for the
|
||||
* bug class this prevents. */
|
||||
#define SWARM_TASK_STACK 8192 /**< 8 KB stack — fits mbedTLS handshake. */
|
||||
#define SWARM_TASK_PRIO 3
|
||||
#define SWARM_TASK_CORE 0
|
||||
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
|
||||
|
||||
@@ -43,7 +43,16 @@
|
||||
|
||||
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
|
||||
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
|
||||
/* Issue #928: WASM output was originally 0xC5110004, but that magic is
|
||||
* canonically owned by ADR-063 fused vitals (edge_processing.h). Both packets
|
||||
* were transmitted on the same magic, and the host parser only knew the WASM
|
||||
* shape, so on the ESP32-C6 + MR60BHA2 mmWave config the 48-byte fused-vitals
|
||||
* packet was being read as garbage WASM events. Reassigned to 0xC5110007 (next
|
||||
* free slot in the registry — see rv_feature_state.h). Firmware older than
|
||||
* this commit will silently lose its WASM event stream against an updated host
|
||||
* — that's the deliberate "fail loud" choice over silent misparsing.
|
||||
*/
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110007 /**< WASM output packet magic (post-#928). */
|
||||
#define WASM_MAX_EVENTS 16 /**< Max events per output packet. */
|
||||
|
||||
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
|
||||
@@ -54,7 +63,7 @@ typedef struct __attribute__((packed)) {
|
||||
|
||||
/* ---- WASM Output Packet ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110007 (issue #928). */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t module_id; /**< Module slot index. */
|
||||
uint16_t event_count; /**< Number of events in this packet. */
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
889715e9d698ad78f9978ad8b93b6af24a726b0494247201c8f0d920d9fc80ca *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
|
||||
d8539e47c6f10a3344679118619e3fe01cfd66eb560ea8883268ca7c9a12efa4 *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
|
||||
b0fb1f217a39c80bc95b5eb8208a0b8572ae64efa0f6d580b76caff4affe0f4d *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
|
||||
4764c5b20a353895f70122816adc98f861ec20e9a8ea9b344dc0648b6341073c *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
|
||||
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f *firmware/esp32-csi-node/release_bins/c6-adr110/ota_data_initial.bin
|
||||
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 *firmware/esp32-csi-node/release_bins/c6-adr110/partition-table.bin
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
3c4905dd202ccabf4230cbabcc9320f250a60b1a7254eff7424780201bcb2072 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
|
||||
7a8bf9582c9031fed32f1ada44f5c41dd99bd07fadff8e5c86e07aa0f343e847 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
|
||||
b973d7eda65affb746adcfa63ceb18f779f206d240b76f01b8c9ae7485455660 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
|
||||
e21ef94aba779d534dc048c1b9da731c81e5dbe09d0645cfd70a05ad3642d3e9 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
|
||||
67222c257c0477501fd4002275638dc4262b34eb68235b8289fb1337054d322b *firmware/esp32-csi-node/release_bins/s3-adr110/partition-table.bin
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
0.6.6
|
||||
git-sha: cbcb389cb (pre-commit)
|
||||
built: 2026-05-21
|
||||
0.6.7
|
||||
git-sha: 8703ade9b
|
||||
built: 2026-06-02
|
||||
note: RuView#893 — display-less boards capture DATA frames (CSI yield 0pps fix); hardware-verified on ESP32-C6 (0->27 pps)
|
||||
|
||||
@@ -29,6 +29,30 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
# LWIP: enable extended socket options for UDP multicast
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
|
||||
# Issue (sibling of #946/#949/#864 cluster): UDP `sendto` returned ENOMEM
|
||||
# in a tight loop on both ESP32-S3 (COM8) and ESP32-C6 (COM9) at the v0.7.0
|
||||
# CSI packet rate (CSI cb + status + sync + feature_state all sharing the
|
||||
# LWIP/WiFi pools). stream_sender.c has a cooldown path so the device
|
||||
# doesn't crash, but ~90 % of CSI frames were dropped before reaching the
|
||||
# host — boot trace showed `sendto ENOMEM — backing off 100 ms` repeating
|
||||
# every capture cycle. Stock IDF v5.4 defaults: UDP recv mbox=6, TCPIP
|
||||
# mbox=32, WiFi dynamic TX buffers=32 — too small once CSI promiscuous
|
||||
# mode is active. These bumps roughly quadruple the relevant pools at
|
||||
# ~3 KB extra heap cost, measured live on both targets Jun 8 2026.
|
||||
CONFIG_LWIP_UDP_RECVMBOX_SIZE=32
|
||||
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=64
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
|
||||
# NOTE: Empirical 25 s measurements on the S3 at COM8 showed these bumps
|
||||
# eliminate the csi_collector.sendto failure path (`fail #1..5` →
|
||||
# `fail #0`) — real improvement — but do NOT eliminate the broader
|
||||
# `feature_state emit` ENOMEM at ~10/s. That residual is the WiFi
|
||||
# radio's TX airtime saturating under CSI promiscuous RX, and bigger
|
||||
# buffers cap out at the 100 ms backoff window regardless of size
|
||||
# (verified at WIFI_DYNAMIC_TX=128 + PBUF_POOL=32 — identical count).
|
||||
# The proper fix is rate-limiting adaptive_controller.c's emit cadence
|
||||
# from ~50 ms to the intended 1 Hz, which is a code refactor tracked
|
||||
# in a separate follow-up issue.
|
||||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
|
||||
@@ -36,3 +36,4 @@ scikit-learn>=1.2.0
|
||||
|
||||
# Monitoring dependencies
|
||||
prometheus-client>=0.16.0
|
||||
psutil>=5.9.0 # system metrics — imported by health.py / metrics.py / status.py / monitoring.py
|
||||
|
||||
@@ -108,8 +108,14 @@ pub async fn start_server(
|
||||
cmd.args(["--log-level", log_level]);
|
||||
}
|
||||
|
||||
// Set data source (default to "simulate" if not specified for demo mode)
|
||||
let source = config.source.as_deref().unwrap_or("simulate");
|
||||
// Default to explicit "simulated" demo mode when the desktop user hasn't
|
||||
// chosen a source — this is the *Tauri demo* app, not a production
|
||||
// sensing endpoint, so the demo default is correct here. Critically, the
|
||||
// value passed downstream is the **explicit** "simulated", not "auto",
|
||||
// which means the sensing-server will tag the data as synthetic in its
|
||||
// API responses rather than silently fall back (issue #937 fix in
|
||||
// sensing-server's `auto` handler).
|
||||
let source = config.source.as_deref().unwrap_or("simulated");
|
||||
cmd.args(["--source", source]);
|
||||
|
||||
// Redirect stdout/stderr to pipes for monitoring
|
||||
@@ -317,7 +323,7 @@ pub async fn restart_server(
|
||||
log_level: None,
|
||||
bind_address: None,
|
||||
server_path: None,
|
||||
source: None, // Use default (simulate)
|
||||
source: None, // Falls through to explicit "simulated" — Tauri demo default.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -172,6 +172,14 @@ impl EnsembleClassifier {
|
||||
let has_movement = reading.movement.movement_type != MovementType::None;
|
||||
|
||||
if !has_breathing && !has_movement {
|
||||
// SAFETY: a detectable heartbeat means the survivor is ALIVE. No
|
||||
// sensed breathing/movement *with* a pulse is respiratory arrest —
|
||||
// the most time-critical savable state (Immediate), never Deceased.
|
||||
// Only the total absence of breathing, movement AND heartbeat is
|
||||
// reported Deceased.
|
||||
if reading.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
return TriageStatus::Deceased;
|
||||
}
|
||||
|
||||
@@ -295,6 +303,27 @@ mod tests {
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
|
||||
/// respiratory arrest — Immediate, never Deceased. Only the *total* absence
|
||||
/// of breathing, movement AND heartbeat (the test above) is Deceased.
|
||||
#[test]
|
||||
fn test_heartbeat_with_no_breathing_or_movement_is_immediate() {
|
||||
// breathing: None, heartbeat: Some(72 bpm), movement: None
|
||||
let reading = make_reading(None, Some(72.0), MovementType::None);
|
||||
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
min_ensemble_confidence: 0.0,
|
||||
..EnsembleConfig::default()
|
||||
});
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(
|
||||
result.recommended_triage,
|
||||
TriageStatus::Immediate,
|
||||
"a survivor with a pulse must never be triaged Deceased"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensemble_confidence_weighting() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
|
||||
@@ -104,7 +104,20 @@ impl TriageCalculator {
|
||||
let movement_status = Self::assess_movement(vitals);
|
||||
|
||||
// Step 4: Combine assessments
|
||||
Self::combine_assessments(breathing_status, movement_status)
|
||||
let status = Self::combine_assessments(breathing_status, movement_status);
|
||||
|
||||
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
|
||||
// ALIVE. `combine_assessments` only sees breathing + movement, so a
|
||||
// person with a pulse but no *sensed* breathing/movement (respiratory
|
||||
// arrest, or breathing too shallow for CSI to pick up) would otherwise
|
||||
// be reported Deceased and deprioritized for rescue. No breathing + a
|
||||
// pulse is the most time-critical *savable* state, so escalate to
|
||||
// Immediate rather than ever calling a survivor with a heartbeat dead.
|
||||
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
/// Assess breathing status
|
||||
@@ -217,7 +230,9 @@ enum MovementAssessment {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
|
||||
use crate::domain::{
|
||||
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_vitals(
|
||||
@@ -233,6 +248,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
|
||||
/// breathing or movement is in respiratory arrest — Immediate (Red), and
|
||||
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
|
||||
/// ignored heartbeat and returned Deceased; that path was in fact only
|
||||
/// reachable *because* a heartbeat made `has_vitals()` true.)
|
||||
#[test]
|
||||
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
|
||||
let vitals = VitalSignsReading {
|
||||
breathing: None,
|
||||
heartbeat: Some(HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Moderate,
|
||||
}),
|
||||
movement: MovementProfile::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
};
|
||||
let status = TriageCalculator::calculate(&vitals);
|
||||
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
|
||||
assert_ne!(status, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_vitals_is_unknown() {
|
||||
let vitals = create_vitals(None, MovementProfile::default());
|
||||
|
||||
@@ -100,7 +100,17 @@ pub async fn require_bearer(
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
|
||||
// case-insensitive. Match it as such (and tolerate extra leading
|
||||
// whitespace before the token) so a correct token isn't rejected
|
||||
// just because a client sent `bearer`/`BEARER`. The token compare
|
||||
// below stays exact + constant-time.
|
||||
.and_then(|s| {
|
||||
let (scheme, token) = s.split_once(' ')?;
|
||||
scheme
|
||||
.eq_ignore_ascii_case("Bearer")
|
||||
.then(|| token.trim_start())
|
||||
});
|
||||
let ok = supplied
|
||||
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
||||
.unwrap_or(false);
|
||||
@@ -185,6 +195,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_case_insensitive_bearer_scheme() {
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
|
||||
// A correct token must authenticate regardless of scheme casing or
|
||||
// extra whitespace; a wrong token must still be rejected.
|
||||
async fn req_status(auth_value: &str) -> StatusCode {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, auth_value.parse().unwrap());
|
||||
r.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
|
||||
// Scheme leniency must NOT weaken the token check.
|
||||
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_blocks_api_with_wrong_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
|
||||
@@ -45,13 +45,14 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
|
||||
pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ const ENERGY_THRESH_2: f64 = 12.0;
|
||||
/// Perturbation energy threshold for detecting a third person.
|
||||
const ENERGY_THRESH_3: f64 = 25.0;
|
||||
|
||||
/// Maximum occupancy a single ESP32 link can plausibly resolve (#894).
|
||||
/// The score heuristic (`score_to_person_count`) and the perturbation-energy
|
||||
/// fallback below both cap here; the eigenvalue path is bounded to match,
|
||||
/// rather than leaking its internal `min(10)` ceiling on noisy / under-
|
||||
/// calibrated CSI (the "10 persons reported when 1 present" symptom).
|
||||
/// Resolving more than this from one link's subcarrier covariance is not
|
||||
/// reliable — genuine higher counts come from the multistatic fusion path.
|
||||
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
|
||||
|
||||
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
|
||||
/// This avoids the DimensionMismatch error when feeding single-frame observations.
|
||||
pub fn single_link_config() -> FieldModelConfig {
|
||||
@@ -55,9 +64,15 @@ pub fn occupancy_or_fallback(
|
||||
return score_to_person_count(smoothed_score, prev_count);
|
||||
}
|
||||
|
||||
// Try eigenvalue-based occupancy first (best accuracy).
|
||||
// Try eigenvalue-based occupancy first (best accuracy). Bound it to
|
||||
// the same single-link maximum the sibling estimators use — the
|
||||
// perturbation fallback below and score_to_person_count both cap at
|
||||
// MAX_SINGLE_LINK_OCCUPANCY. Without this, estimate_occupancy's
|
||||
// internal min(10) ceiling leaks up to 10 persons on noisy / under-
|
||||
// calibrated CSI (#894), while every other path on the same data
|
||||
// would report ≤3.
|
||||
if let Ok(count) = field.estimate_occupancy(&frames) {
|
||||
return count;
|
||||
return count.min(MAX_SINGLE_LINK_OCCUPANCY);
|
||||
} // else fall through to perturbation energy
|
||||
|
||||
// Fallback: perturbation energy thresholds.
|
||||
|
||||
@@ -1114,7 +1114,7 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0004) ───────────────────────────
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
|
||||
|
||||
/// Single WASM event (type + value).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -1131,13 +1131,14 @@ struct WasmOutputPacket {
|
||||
events: Vec<WasmEvent>,
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
|
||||
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1170,187 @@ fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-063: Edge Fused Vitals Packet (magic 0xC511_0004) ─────────────────────
|
||||
//
|
||||
// 48-byte packed struct emitted by the ESP32-C6 + MR60BHA2 mmWave config when
|
||||
// `mmwave_sensor_get_state().detected` is true. Byte layout from
|
||||
// `firmware/esp32-csi-node/main/edge_processing.h` line 129 — kept in lockstep
|
||||
// with the firmware's `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)`.
|
||||
// Issue #928 surfaced that this magic was being parsed as WASM output and the
|
||||
// fused vitals were silently lost. Adding the proper parser here.
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct EdgeFusedVitalsPacket {
|
||||
node_id: u8,
|
||||
/// Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present.
|
||||
flags: u8,
|
||||
/// Fused breathing rate in BPM (firmware sends BPM*100; we scale here).
|
||||
breathing_rate_bpm: f32,
|
||||
/// Fused heartrate in BPM (firmware sends BPM*10000; we scale here).
|
||||
heartrate_bpm: f32,
|
||||
rssi: i8,
|
||||
n_persons: u8,
|
||||
/// `mmwave_type_t` enum value from firmware.
|
||||
mmwave_type: u8,
|
||||
/// 0-100 fusion quality score.
|
||||
fusion_confidence: u8,
|
||||
motion_energy: f32,
|
||||
presence_score: f32,
|
||||
timestamp_ms: u32,
|
||||
/// Raw mmWave heart rate (BPM).
|
||||
mmwave_hr_bpm: f32,
|
||||
/// Raw mmWave breathing rate (BPM).
|
||||
mmwave_br_bpm: f32,
|
||||
/// Distance to nearest target (cm).
|
||||
mmwave_distance_cm: f32,
|
||||
/// Target count from mmWave.
|
||||
mmwave_targets: u8,
|
||||
/// mmWave signal quality 0-100.
|
||||
mmwave_confidence: u8,
|
||||
}
|
||||
|
||||
/// Parse an ADR-063 edge fused vitals packet (magic 0xC511_0004, 48 bytes).
|
||||
fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
|
||||
if buf.len() < 48 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let node_id = buf[4];
|
||||
let flags = buf[5];
|
||||
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let rssi = buf[12] as i8;
|
||||
let n_persons = buf[13];
|
||||
let mmwave_type = buf[14];
|
||||
let fusion_confidence = buf[15];
|
||||
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
|
||||
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
|
||||
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
|
||||
let mmwave_hr_bpm = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
|
||||
let mmwave_br_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]);
|
||||
let mmwave_distance_cm = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]);
|
||||
let mmwave_targets = buf[40];
|
||||
let mmwave_confidence = buf[41];
|
||||
// buf[42..48] are firmware reserved fields (reserved3 u16 + reserved4 u32).
|
||||
|
||||
Some(EdgeFusedVitalsPacket {
|
||||
node_id,
|
||||
flags,
|
||||
breathing_rate_bpm: breathing_raw as f32 / 100.0,
|
||||
heartrate_bpm: heartrate_raw as f32 / 10000.0,
|
||||
rssi,
|
||||
n_persons,
|
||||
mmwave_type,
|
||||
fusion_confidence,
|
||||
motion_energy,
|
||||
presence_score,
|
||||
timestamp_ms,
|
||||
mmwave_hr_bpm,
|
||||
mmwave_br_bpm,
|
||||
mmwave_distance_cm,
|
||||
mmwave_targets,
|
||||
mmwave_confidence,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod issue_928_magic_collision_tests {
|
||||
//! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the
|
||||
//! C6+mmWave fused-vitals packets. After this fix, `0xC511_0004` routes to
|
||||
//! `parse_edge_fused_vitals` and WASM output owns the freshly-allocated
|
||||
//! `0xC511_0007` slot. Tests guard both halves of the swap.
|
||||
use super::*;
|
||||
|
||||
/// Build a 48-byte synthetic fused-vitals packet matching the firmware's
|
||||
/// `edge_fused_vitals_pkt_t` layout from `edge_processing.h:129`.
|
||||
fn build_fused_vitals_packet() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 48];
|
||||
buf[0..4].copy_from_slice(&0xC511_0004u32.to_le_bytes());
|
||||
buf[4] = 9; // node_id
|
||||
buf[5] = 0b0000_1001; // flags: presence | mmwave_present
|
||||
buf[6..8].copy_from_slice(&1600u16.to_le_bytes()); // breathing 16.00 BPM
|
||||
buf[8..12].copy_from_slice(&720_000u32.to_le_bytes()); // heartrate 72.0 BPM
|
||||
buf[12] = (-55i8) as u8; // rssi
|
||||
buf[13] = 1; // n_persons
|
||||
buf[14] = 2; // mmwave_type
|
||||
buf[15] = 85; // fusion_confidence
|
||||
buf[16..20].copy_from_slice(&0.42f32.to_le_bytes()); // motion_energy
|
||||
buf[20..24].copy_from_slice(&0.95f32.to_le_bytes()); // presence_score
|
||||
buf[24..28].copy_from_slice(&1_234_567u32.to_le_bytes()); // timestamp_ms
|
||||
buf[28..32].copy_from_slice(&71.5f32.to_le_bytes()); // mmwave_hr_bpm
|
||||
buf[32..36].copy_from_slice(&15.8f32.to_le_bytes()); // mmwave_br_bpm
|
||||
buf[36..40].copy_from_slice(&182.0f32.to_le_bytes()); // mmwave_distance_cm
|
||||
buf[40] = 1; // mmwave_targets
|
||||
buf[41] = 90; // mmwave_confidence
|
||||
// bytes 42..48 — firmware reserved fields, left as zero
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_extracts_fields_correctly() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
let pkt = parse_edge_fused_vitals(&buf).expect("must parse a well-formed packet");
|
||||
assert_eq!(pkt.node_id, 9);
|
||||
assert_eq!(pkt.flags, 0b0000_1001);
|
||||
assert!((pkt.breathing_rate_bpm - 16.0).abs() < 1e-3, "breathing scale 100");
|
||||
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
|
||||
assert_eq!(pkt.rssi, -55);
|
||||
assert_eq!(pkt.n_persons, 1);
|
||||
assert_eq!(pkt.mmwave_type, 2);
|
||||
assert_eq!(pkt.fusion_confidence, 85);
|
||||
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
|
||||
assert!((pkt.presence_score - 0.95).abs() < 1e-6);
|
||||
assert_eq!(pkt.timestamp_ms, 1_234_567);
|
||||
assert!((pkt.mmwave_hr_bpm - 71.5).abs() < 1e-6);
|
||||
assert!((pkt.mmwave_br_bpm - 15.8).abs() < 1e-3);
|
||||
assert!((pkt.mmwave_distance_cm - 182.0).abs() < 1e-6);
|
||||
assert_eq!(pkt.mmwave_targets, 1);
|
||||
assert_eq!(pkt.mmwave_confidence, 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_short_buffer() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
// Truncate to 47 bytes — one short of the 48-byte minimum.
|
||||
assert!(parse_edge_fused_vitals(&buf[..47]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_wrong_magic() {
|
||||
let mut buf = build_fused_vitals_packet();
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes()); // WASM magic, not fused
|
||||
assert!(parse_edge_fused_vitals(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_rejects_legacy_0004_magic() {
|
||||
// The old WASM magic collided with fused vitals — must no longer be
|
||||
// accepted. A real fused-vitals packet starts with 0xC511_0004 and
|
||||
// would have been misparsed before this fix.
|
||||
let buf = build_fused_vitals_packet();
|
||||
assert!(parse_wasm_output(&buf).is_none(),
|
||||
"issue #928: WASM parser must NOT accept 0xC511_0004");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_accepts_new_0007_magic() {
|
||||
// Build a tiny well-formed WASM output packet on the new magic.
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes());
|
||||
buf[4] = 5; // node_id
|
||||
buf[5] = 1; // module_id
|
||||
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // event_count = 0
|
||||
let pkt = parse_wasm_output(&buf).expect("0xC511_0007 must parse");
|
||||
assert_eq!(pkt.node_id, 5);
|
||||
assert_eq!(pkt.module_id, 1);
|
||||
assert!(pkt.events.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESP32 UDP frame parser ───────────────────────────────────────────────────
|
||||
|
||||
fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
@@ -4979,7 +5161,45 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0004).
|
||||
// ADR-063: Try edge fused vitals packet (magic 0xC511_0004).
|
||||
// Must come BEFORE the WASM parser — issue #928: these two
|
||||
// packet types shared a magic and the WASM parser was eating
|
||||
// fused-vitals frames on the C6+mmWave config. The reassign of
|
||||
// WASM_OUTPUT_MAGIC → 0xC511_0007 (firmware side) plus this
|
||||
// dedicated parser resolve the collision.
|
||||
if let Some(fused) = parse_edge_fused_vitals(&buf[..len]) {
|
||||
debug!(
|
||||
"Edge fused vitals from {src}: node={} br={:.1} hr={:.1} \
|
||||
mmwave_targets={} fusion_conf={}",
|
||||
fused.node_id, fused.breathing_rate_bpm, fused.heartrate_bpm,
|
||||
fused.mmwave_targets, fused.fusion_confidence,
|
||||
);
|
||||
let s = state.write().await;
|
||||
if let Ok(json) = serde_json::to_string(&serde_json::json!({
|
||||
"type": "edge_fused_vitals",
|
||||
"node_id": fused.node_id,
|
||||
"breathing_rate_bpm": fused.breathing_rate_bpm,
|
||||
"heartrate_bpm": fused.heartrate_bpm,
|
||||
"n_persons": fused.n_persons,
|
||||
"fusion_confidence": fused.fusion_confidence,
|
||||
"mmwave": {
|
||||
"hr_bpm": fused.mmwave_hr_bpm,
|
||||
"br_bpm": fused.mmwave_br_bpm,
|
||||
"distance_cm": fused.mmwave_distance_cm,
|
||||
"targets": fused.mmwave_targets,
|
||||
"confidence": fused.mmwave_confidence,
|
||||
"type": fused.mmwave_type,
|
||||
},
|
||||
"motion_energy": fused.motion_energy,
|
||||
"presence_score": fused.presence_score,
|
||||
"timestamp_ms": fused.timestamp_ms,
|
||||
})) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0007 post-#928).
|
||||
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
|
||||
debug!(
|
||||
"WASM output from {src}: node={} module={} events={}",
|
||||
@@ -5476,6 +5696,159 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to
|
||||
/// publish over MQTT (issues #872/#898).
|
||||
///
|
||||
/// Multi-node sources carry a `nodes` array where **each node has its own
|
||||
/// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so
|
||||
/// each node must surface its *own* presence/motion, not the room-level
|
||||
/// aggregate. Previously the bridge applied the aggregate `classification` to
|
||||
/// every per-node Home-Assistant device, so a node in an empty corner inherited
|
||||
/// another node's "present" (and `motion_level: "absent"` was mis-mapped to full
|
||||
/// motion). Vitals (breathing / heart rate) and the person count are room-level
|
||||
/// and shared across the per-node devices. Falls back to a single aggregate
|
||||
/// snapshot when there is no per-node data (e.g. wifi / simulate sources).
|
||||
#[cfg(feature = "mqtt")]
|
||||
fn vitals_snapshots_from_sensing_json(
|
||||
v: &serde_json::Value,
|
||||
base_id: &str,
|
||||
) -> Vec<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
|
||||
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
|
||||
|
||||
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
|
||||
// are non-moving; anything else (walking, …) is motion. `fallback` is used
|
||||
// when the field is absent so a partial per-node payload defers to the
|
||||
// room aggregate rather than silently reading 0.
|
||||
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
|
||||
match level {
|
||||
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
|
||||
Some(_) => 1.0,
|
||||
None => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
|
||||
let vit = &v["vital_signs"];
|
||||
let breathing = vit["breathing_rate_bpm"].as_f64();
|
||||
let hr = vit["heart_rate_bpm"].as_f64();
|
||||
let n_persons = v["persons"]
|
||||
.as_array()
|
||||
.map(|a| a.len() as u32)
|
||||
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
|
||||
.unwrap_or(0);
|
||||
|
||||
// Room-level aggregate: the no-nodes fallback, and the per-node default for
|
||||
// any field a node omits.
|
||||
let acls = &v["classification"];
|
||||
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
|
||||
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
|
||||
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
|
||||
VitalsSnapshot {
|
||||
node_id,
|
||||
timestamp_ms: ts,
|
||||
presence,
|
||||
motion,
|
||||
presence_score: if presence { conf.max(0.0) } else { 0.0 },
|
||||
breathing_rate_bpm: breathing,
|
||||
heartrate_bpm: hr,
|
||||
n_persons,
|
||||
rssi_dbm: rssi,
|
||||
vital_confidence: conf,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
match v["nodes"].as_array() {
|
||||
Some(arr) if !arr.is_empty() => arr
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let n = node["node_id"].as_u64().unwrap_or(0);
|
||||
// Each node carries its OWN classification — use it, deferring to
|
||||
// the room aggregate only for fields the node omits.
|
||||
let ncls = &node["classification"];
|
||||
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
|
||||
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
|
||||
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
|
||||
mk(
|
||||
format!("{base_id}-node{n}"),
|
||||
presence,
|
||||
motion,
|
||||
conf,
|
||||
node["rssi_dbm"].as_f64(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![mk(
|
||||
base_id.to_string(),
|
||||
agg_presence,
|
||||
agg_motion,
|
||||
agg_conf,
|
||||
v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
|
||||
///
|
||||
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
|
||||
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
|
||||
/// different *format* — and a different encoder architecture — than the RVF
|
||||
/// binary container the `--model` progressive loader expects (`RVFS` magic
|
||||
/// `0x52564653`). Feeding one to `--model` produced a bare
|
||||
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
|
||||
/// cases and explain plainly what's loadable instead.
|
||||
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
|
||||
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
|
||||
// JSONL manifest: starts with '{' (or the well-known suffix).
|
||||
let looks_jsonl =
|
||||
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
|
||||
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
|
||||
let looks_quant_bin = ext == "bin" || name.contains("-q");
|
||||
|
||||
let kind = if looks_safetensors {
|
||||
"a safetensors weight file"
|
||||
} else if looks_jsonl {
|
||||
"a JSONL manifest, not the binary container"
|
||||
} else if looks_quant_bin {
|
||||
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
|
||||
} else {
|
||||
"not an RVF binary container"
|
||||
};
|
||||
|
||||
format!(
|
||||
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
|
||||
RVF binary container (`RVFS` magic 0x52564653) produced by the \
|
||||
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
|
||||
files are a different format and encoder architecture, so they do not load \
|
||||
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `--export-rvf` should emit the placeholder container-format demo.
|
||||
///
|
||||
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
|
||||
/// real model is produced by the training pipeline, so short-circuiting here
|
||||
/// would silently skip training and write placeholder weights — the #894 bug
|
||||
/// where the documented `--train … --export-rvf` workflow produced a fake model.
|
||||
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
|
||||
export_set && !train && !pretrain
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
|
||||
@@ -5519,9 +5892,24 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --export-rvf mode: build an RVF container package and exit
|
||||
if let Some(ref rvf_path) = args.export_rvf {
|
||||
eprintln!("Exporting RVF container package...");
|
||||
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
|
||||
// weights — it is NOT a trained model. Only short-circuit when standalone:
|
||||
// combined with --train/--pretrain the real model is exported by the
|
||||
// training pipeline, and short-circuiting here would silently skip training
|
||||
// and write placeholder weights (#894 — the documented `--train …
|
||||
// --export-rvf` workflow produced a placeholder and never trained).
|
||||
if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) {
|
||||
let rvf_path = args
|
||||
.export_rvf
|
||||
.as_ref()
|
||||
.expect("export_emits_placeholder_demo implies export_rvf is set");
|
||||
eprintln!(
|
||||
"WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \
|
||||
weights — it is NOT a trained model. Train one with \
|
||||
`--train --dataset <DIR>` (which exports a calibrated .rvf to the \
|
||||
models/ directory), or download a pretrained encoder. See issue #894."
|
||||
);
|
||||
eprintln!("Exporting RVF container package (placeholder weights)...");
|
||||
use rvf_pipeline::RvfModelBuilder;
|
||||
|
||||
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
|
||||
@@ -5570,6 +5958,13 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if args.export_rvf.is_some() {
|
||||
// --export-rvf alongside --train/--pretrain: don't emit a placeholder.
|
||||
// Fall through so training runs; it exports the real calibrated model.
|
||||
eprintln!(
|
||||
"Note: --export-rvf is ignored in training mode — the trained model \
|
||||
is exported by the training pipeline to the models/ directory."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024)
|
||||
@@ -6026,7 +6421,17 @@ async fn main() {
|
||||
info!(" UI path: {}", args.ui_path.display());
|
||||
info!(" Source: {}", args.source);
|
||||
|
||||
// Auto-detect data source
|
||||
// Auto-detect data source.
|
||||
//
|
||||
// Issue #937 / sibling fix: previously `auto` silently fell back to the
|
||||
// synthetic data source when no ESP32 or Windows WiFi was reachable, with
|
||||
// only an `info!` log line as the signal. Downstream API consumers
|
||||
// (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they
|
||||
// were being served fake CSI tagged as production telemetry. That is the
|
||||
// exact "where's the real data?" pattern external reviewers (#943, #934)
|
||||
// cited as the most damaging evidence of the project misrepresenting its
|
||||
// posture. Synthetic-data is now opt-in only — operators who want demo
|
||||
// mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`.
|
||||
let source = match args.source.as_str() {
|
||||
"auto" => {
|
||||
info!("Auto-detecting data source...");
|
||||
@@ -6037,10 +6442,23 @@ async fn main() {
|
||||
info!(" Windows WiFi detected");
|
||||
"wifi"
|
||||
} else {
|
||||
info!(" No hardware detected, using simulation");
|
||||
"simulate"
|
||||
error!(
|
||||
"No real CSI source detected. Auto-detection refuses to silently \
|
||||
fall back to synthetic data because that would expose downstream \
|
||||
consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \
|
||||
tagged as production. To run with synthetic data, set the source \
|
||||
explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \
|
||||
To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \
|
||||
install the Windows WiFi capture driver. See \
|
||||
https://github.com/ruvnet/RuView/issues/937 for context.",
|
||||
args.udp_port
|
||||
);
|
||||
std::process::exit(78); // EX_CONFIG
|
||||
}
|
||||
}
|
||||
// "simulate" is a synonym for "simulated" (back-compat alias kept so
|
||||
// existing operators who already opted in don't get broken by this fix).
|
||||
"simulate" => "simulated",
|
||||
other => other,
|
||||
};
|
||||
|
||||
@@ -6113,7 +6531,9 @@ async fn main() {
|
||||
model_loaded = true;
|
||||
progressive_loader = Some(loader);
|
||||
}
|
||||
Err(e) => error!("Progressive loader init failed: {e}"),
|
||||
Err(e) => {
|
||||
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
|
||||
}
|
||||
},
|
||||
Err(e) => error!("Failed to read model file: {e}"),
|
||||
}
|
||||
@@ -6200,37 +6620,14 @@ async fn main() {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
|
||||
continue;
|
||||
};
|
||||
let cls = &v["classification"];
|
||||
let vit = &v["vital_signs"];
|
||||
let presence = cls["presence"].as_bool().unwrap_or(false);
|
||||
let n_persons = v["persons"]
|
||||
.as_array()
|
||||
.map(|a| a.len() as u32)
|
||||
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
|
||||
.unwrap_or(0);
|
||||
let motion = match cls["motion_level"].as_str() {
|
||||
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
|
||||
Some(_) => 1.0,
|
||||
None => 0.0,
|
||||
};
|
||||
let snap = mqtt::state::VitalsSnapshot {
|
||||
node_id: node_id.clone(),
|
||||
timestamp_ms: (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64,
|
||||
presence,
|
||||
motion,
|
||||
presence_score: if presence {
|
||||
cls["confidence"].as_f64().unwrap_or(1.0)
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
breathing_rate_bpm: vit["breathing_rate_bpm"].as_f64(),
|
||||
heartrate_bpm: vit["heart_rate_bpm"].as_f64(),
|
||||
n_persons,
|
||||
rssi_dbm: v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||
vital_confidence: cls["confidence"].as_f64().unwrap_or(0.0),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = vtx.send(snap);
|
||||
// #898/#872: emit one snapshot per physical node so
|
||||
// each surfaces as its own Home-Assistant device with
|
||||
// its *own* presence/motion/RSSI (see
|
||||
// vitals_snapshots_from_sensing_json). Falls back to a
|
||||
// single aggregate snapshot for per-node-less sources.
|
||||
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
|
||||
let _ = vtx.send(snap);
|
||||
}
|
||||
}
|
||||
});
|
||||
tracing::info!("MQTT publisher started -> {host}:{port}");
|
||||
@@ -7048,3 +7445,169 @@ mod rolling_p95_tests {
|
||||
assert_eq!(p.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "mqtt"))]
|
||||
mod mqtt_bridge_tests {
|
||||
use super::vitals_snapshots_from_sensing_json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Regression for the per-node presence bug (#872/#898): each node must
|
||||
/// surface its OWN classification, not the room-level aggregate. Node 1 is
|
||||
/// present+moving; node 2 is absent — node 2 must NOT inherit node 1's
|
||||
/// "present".
|
||||
#[test]
|
||||
fn per_node_presence_uses_each_nodes_own_classification() {
|
||||
let v = json!({
|
||||
"timestamp": 1.0,
|
||||
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 },
|
||||
"vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 },
|
||||
"persons": [{}, {}],
|
||||
"nodes": [
|
||||
{ "node_id": 1, "rssi_dbm": -40.0,
|
||||
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } },
|
||||
{ "node_id": 2, "rssi_dbm": -70.0,
|
||||
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } }
|
||||
]
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
|
||||
assert_eq!(snaps.len(), 2, "one snapshot per node");
|
||||
|
||||
let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap();
|
||||
let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap();
|
||||
|
||||
assert!(n1.presence && n1.motion > 0.0, "node1 present + moving");
|
||||
assert!(
|
||||
!n2.presence && n2.motion == 0.0,
|
||||
"node2 must be absent — not inherit the room aggregate"
|
||||
);
|
||||
// Per-node RSSI preserved.
|
||||
assert_eq!(n1.rssi_dbm, Some(-40.0));
|
||||
assert_eq!(n2.rssi_dbm, Some(-70.0));
|
||||
// Vitals + person count are room-level, shared across node devices.
|
||||
assert_eq!(n1.n_persons, 2);
|
||||
assert_eq!(n2.n_persons, 2);
|
||||
assert_eq!(n1.breathing_rate_bpm, Some(14.0));
|
||||
assert_eq!(n2.heartrate_bpm, Some(60.0));
|
||||
// presence_score is gated on presence.
|
||||
assert!(n1.presence_score > 0.0);
|
||||
assert_eq!(n2.presence_score, 0.0);
|
||||
}
|
||||
|
||||
/// A node that omits a classification field defers to the room aggregate
|
||||
/// rather than silently reading false/0.
|
||||
#[test]
|
||||
fn per_node_missing_fields_fall_back_to_aggregate() {
|
||||
let v = json!({
|
||||
"timestamp": 1.0,
|
||||
"classification": { "presence": true, "motion_level": "still", "confidence": 0.7 },
|
||||
"vital_signs": {},
|
||||
"nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "n");
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].node_id, "n-node3");
|
||||
assert!(snaps[0].presence, "defers to aggregate presence");
|
||||
assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion");
|
||||
}
|
||||
|
||||
/// No `nodes` array (wifi / simulate sources): single aggregate snapshot
|
||||
/// keyed by the base id.
|
||||
#[test]
|
||||
fn falls_back_to_single_aggregate_when_no_nodes() {
|
||||
let v = json!({
|
||||
"timestamp": 2.0,
|
||||
"classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 },
|
||||
"vital_signs": { "breathing_rate_bpm": 12.0 },
|
||||
"persons": [{}]
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].node_id, "ruview");
|
||||
assert!(snaps[0].presence);
|
||||
assert_eq!(snaps[0].motion, 0.0, "idle => no motion");
|
||||
assert_eq!(snaps[0].n_persons, 1);
|
||||
}
|
||||
|
||||
/// `motion_level: "absent"` must map to zero motion (the old aggregate
|
||||
/// match fell through to `Some(_) => 1.0`, treating absent as full motion).
|
||||
#[test]
|
||||
fn absent_motion_level_is_zero_motion() {
|
||||
let v = json!({
|
||||
"timestamp": 0.0,
|
||||
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 },
|
||||
"vital_signs": {}
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "x");
|
||||
assert_eq!(snaps[0].motion, 0.0);
|
||||
assert!(!snaps[0].presence);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod model_load_diagnostic_tests {
|
||||
use super::diagnose_model_load_error;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn safetensors_is_named_and_points_at_894() {
|
||||
// 8-byte LE header length then '{' — the safetensors signature.
|
||||
let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"'];
|
||||
let msg = diagnose_model_load_error(
|
||||
Path::new("models/wifi-densepose-pretrained/model.safetensors"),
|
||||
&data,
|
||||
"invalid magic at offset 0",
|
||||
);
|
||||
assert!(msg.contains("safetensors"), "{msg}");
|
||||
assert!(msg.contains("#894"), "{msg}");
|
||||
assert!(msg.contains("signal heuristics"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantized_bin_is_identified() {
|
||||
let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports
|
||||
let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic");
|
||||
assert!(msg.contains("quantized weight blob"), "{msg}");
|
||||
assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_manifest_is_identified() {
|
||||
let data = *b"{\"seg\":0}";
|
||||
let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x");
|
||||
assert!(msg.contains("JSONL manifest"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_format_still_gives_guidance() {
|
||||
let data = [0u8, 1, 2, 3];
|
||||
let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x");
|
||||
assert!(msg.contains("RVF binary container"), "{msg}");
|
||||
assert!(msg.contains("wifi-densepose-train"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod export_rvf_mode_tests {
|
||||
use super::export_emits_placeholder_demo;
|
||||
|
||||
#[test]
|
||||
fn standalone_export_emits_placeholder() {
|
||||
// --export-rvf alone → the container-format demo (placeholder weights).
|
||||
assert!(export_emits_placeholder_demo(true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_with_train_does_not_short_circuit() {
|
||||
// #894: `--train --export-rvf` must NOT emit a placeholder + skip
|
||||
// training — it must fall through to the real training pipeline.
|
||||
assert!(!export_emits_placeholder_demo(true, true, false));
|
||||
assert!(!export_emits_placeholder_demo(true, false, true));
|
||||
assert!(!export_emits_placeholder_demo(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_export_flag_never_emits() {
|
||||
assert!(!export_emits_placeholder_demo(false, false, false));
|
||||
assert!(!export_emits_placeholder_demo(false, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,23 @@ impl OwnedDiscoveryBuilder {
|
||||
via_device: self.via_device.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a per-node builder from this base (issue #898). Each physical
|
||||
/// RuView node must surface as its own Home-Assistant device — the base
|
||||
/// builder's `node_id` (the MQTT client id) is replaced with the actual
|
||||
/// node id, giving a distinct `wifi_densepose_<node>` device identifier
|
||||
/// and a per-node friendly name, instead of collapsing every node into a
|
||||
/// single hard-coded device.
|
||||
pub fn for_node(&self, node_id: &str) -> OwnedDiscoveryBuilder {
|
||||
OwnedDiscoveryBuilder {
|
||||
discovery_prefix: self.discovery_prefix.clone(),
|
||||
node_id: node_id.to_string(),
|
||||
node_friendly_name: Some(format!("RuView node {node_id}")),
|
||||
sw_version: self.sw_version.clone(),
|
||||
model: self.model.clone(),
|
||||
via_device: self.via_device.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core run loop. Pumps the broadcast channel + the MQTT event loop in
|
||||
@@ -129,20 +146,19 @@ async fn run(
|
||||
let opts = build_mqtt_options(&cfg);
|
||||
let (client, mut eventloop): (AsyncClient, EventLoop) = AsyncClient::new(opts, 256);
|
||||
|
||||
let builder_borrowed = builder_owned.as_borrowed();
|
||||
let entities = DiscoveryBuilder::enabled_entities(
|
||||
cfg.privacy_mode,
|
||||
cfg.publish_pose,
|
||||
&[], // no_semantic — wire from cli::Args in P3.5
|
||||
);
|
||||
|
||||
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
|
||||
warn!("[mqtt] initial discovery publish failed: {e}");
|
||||
}
|
||||
let avail = NodeAvailability::for_builder(&builder_borrowed, &entities);
|
||||
if let Err(e) = publish_availability(&client, &avail, "online").await {
|
||||
warn!("[mqtt] initial availability publish failed: {e}");
|
||||
}
|
||||
// #898: one Home-Assistant device per node. Discovery + availability are
|
||||
// published lazily the first time a snapshot for a given node_id arrives;
|
||||
// each node's builder + availability are retained here for heartbeats and
|
||||
// the offline LWT. (Previously a single hard-coded builder collapsed every
|
||||
// node into one device.)
|
||||
let mut nodes: std::collections::HashMap<String, (OwnedDiscoveryBuilder, NodeAvailability)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
let mut rate_limiter = RateLimiter::new();
|
||||
let mut last_heartbeat = Instant::now();
|
||||
@@ -179,14 +195,20 @@ async fn run(
|
||||
// Periodic heartbeat / discovery refresh.
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
||||
if last_heartbeat.elapsed() >= AVAILABILITY_HEARTBEAT {
|
||||
if let Err(e) = publish_availability(&client, &avail, "online").await {
|
||||
warn!("[mqtt] heartbeat publish failed: {e}");
|
||||
for (_, na) in nodes.values() {
|
||||
if let Err(e) = publish_availability(&client, na, "online").await {
|
||||
warn!("[mqtt] heartbeat publish failed: {e}");
|
||||
}
|
||||
}
|
||||
last_heartbeat = Instant::now();
|
||||
}
|
||||
if last_refresh.elapsed() >= Duration::from_secs(cfg.refresh_secs) {
|
||||
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
|
||||
warn!("[mqtt] discovery refresh failed: {e}");
|
||||
for (nb, _) in nodes.values() {
|
||||
if let Err(e) =
|
||||
publish_all_discovery(&client, &nb.as_borrowed(), &entities).await
|
||||
{
|
||||
warn!("[mqtt] discovery refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
last_refresh = Instant::now();
|
||||
}
|
||||
@@ -197,18 +219,39 @@ async fn run(
|
||||
match recv {
|
||||
Ok(snap) => {
|
||||
let elapsed = start_instant.elapsed();
|
||||
publish_snapshot(&client, &builder_borrowed, &snap, &cfg, &mut rate_limiter, elapsed).await;
|
||||
// #898: on first sight of a node_id, publish that
|
||||
// node's discovery + availability; then route its
|
||||
// state to per-node topics.
|
||||
if !nodes.contains_key(&snap.node_id) {
|
||||
let nb = builder_owned.for_node(&snap.node_id);
|
||||
let borrowed = nb.as_borrowed();
|
||||
if let Err(e) =
|
||||
publish_all_discovery(&client, &borrowed, &entities).await
|
||||
{
|
||||
warn!("[mqtt] node {} discovery failed: {e}", snap.node_id);
|
||||
}
|
||||
let na = NodeAvailability::for_builder(&borrowed, &entities);
|
||||
if let Err(e) = publish_availability(&client, &na, "online").await {
|
||||
warn!("[mqtt] node {} availability failed: {e}", snap.node_id);
|
||||
}
|
||||
nodes.insert(snap.node_id.clone(), (nb, na));
|
||||
}
|
||||
let borrowed = nodes[&snap.node_id].0.as_borrowed();
|
||||
publish_snapshot(&client, &borrowed, &snap, &cfg, &mut rate_limiter, elapsed).await;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("[mqtt] lagged behind broadcast by {n} messages — dropped");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("[mqtt] broadcast channel closed, draining");
|
||||
// Publish offline before exit.
|
||||
let _ = publish_availability(&client, &avail, "offline").await;
|
||||
// Publish offline for every known node before exit.
|
||||
for (_, na) in nodes.values() {
|
||||
let _ = publish_availability(&client, na, "offline").await;
|
||||
}
|
||||
let _ = client.disconnect().await;
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,3 +339,52 @@ async fn publish_state(client: &AsyncClient, m: &StateMessage) -> Result<(), Cli
|
||||
};
|
||||
client.publish(&m.topic, qos, m.retain, m.payload.clone()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod per_node_device_tests {
|
||||
//! Issue #898 — each physical node must surface as its own Home-Assistant
|
||||
//! device, not collapse into one hard-coded device.
|
||||
use super::*;
|
||||
|
||||
fn base() -> OwnedDiscoveryBuilder {
|
||||
OwnedDiscoveryBuilder {
|
||||
discovery_prefix: "homeassistant".into(),
|
||||
node_id: "wifi-densepose-1".into(),
|
||||
node_friendly_name: Some("RuView".into()),
|
||||
sw_version: "0.0.0".into(),
|
||||
model: "test".into(),
|
||||
via_device: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn device_identifiers(b: &OwnedDiscoveryBuilder) -> Vec<String> {
|
||||
b.as_borrowed().build(EntityKind::Presence).device.identifiers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_node_overrides_node_id_and_friendly_name() {
|
||||
let n = base().for_node("node-A");
|
||||
assert_eq!(n.node_id, "node-A");
|
||||
assert_eq!(n.node_friendly_name.as_deref(), Some("RuView node node-A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_nodes_yield_distinct_ha_device_identifiers() {
|
||||
let b = base();
|
||||
let a = device_identifiers(&b.for_node("node-A"));
|
||||
let c = device_identifiers(&b.for_node("node-B"));
|
||||
assert_eq!(a, vec!["wifi_densepose_node-A".to_string()]);
|
||||
assert_eq!(c, vec!["wifi_densepose_node-B".to_string()]);
|
||||
assert_ne!(a, c, "#898: two nodes must not collapse into one device");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_node_keeps_a_stable_identity() {
|
||||
// Two snapshots from the same node map to the same device.
|
||||
let b = base();
|
||||
assert_eq!(
|
||||
device_identifiers(&b.for_node("node-7")),
|
||||
device_identifiers(&b.for_node("node-7"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +171,28 @@ async fn discovery_topics_appear_on_broker() {
|
||||
// Spawn the publisher.
|
||||
let cfg = make_cfg(port, false, "discovery");
|
||||
let builder = make_builder("inttest1");
|
||||
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let _handle = spawn(cfg, builder, rx);
|
||||
|
||||
// #898: discovery is now published per-node the first time a snapshot for
|
||||
// that node_id arrives (not eagerly at startup). Drive snapshots for
|
||||
// "inttest1" throughout the window so its device's discovery lands — same
|
||||
// pattern as state_messages_published_on_snapshot_broadcast.
|
||||
let tx_bg = tx.clone();
|
||||
let drive = tokio::spawn(async move {
|
||||
for _ in 0..60 {
|
||||
let _ = tx_bg.send(VitalsSnapshot {
|
||||
node_id: "inttest1".into(),
|
||||
..Default::default()
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Drain the subscriber for up to 6 s — enough for initial discovery
|
||||
// + first availability publication.
|
||||
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
|
||||
drive.abort();
|
||||
let _ = sub.disconnect().await;
|
||||
|
||||
// Assertions: at least the presence + heart_rate + fall discovery
|
||||
@@ -221,10 +237,23 @@ async fn privacy_mode_suppresses_biometric_discovery() {
|
||||
|
||||
let cfg = make_cfg(port, /* privacy_mode = */ true, "privacy");
|
||||
let builder = make_builder("inttest2");
|
||||
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let _handle = spawn(cfg, builder, rx);
|
||||
|
||||
// #898: per-node discovery is triggered by a snapshot for that node_id.
|
||||
let tx_bg = tx.clone();
|
||||
let drive = tokio::spawn(async move {
|
||||
for _ in 0..60 {
|
||||
let _ = tx_bg.send(VitalsSnapshot {
|
||||
node_id: "inttest2".into(),
|
||||
..Default::default()
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
|
||||
drive.abort();
|
||||
let _ = sub.disconnect().await;
|
||||
|
||||
let topics: Vec<&str> = msgs.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||
|
||||
@@ -276,6 +276,13 @@ pub struct FieldNormalMode {
|
||||
pub geometry_hash: u64,
|
||||
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
|
||||
pub baseline_eigenvalue_count: usize,
|
||||
/// Baseline noise variance estimate (median of bottom-half positive
|
||||
/// eigenvalues from the calibration covariance). Persisted so that
|
||||
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
|
||||
/// calibration noise floor instead of letting it drift with the
|
||||
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
|
||||
/// Issue #942.
|
||||
pub baseline_noise_var: f64,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
@@ -504,7 +511,11 @@ impl FieldModel {
|
||||
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
|
||||
|
||||
// --- True eigenvalue decomposition (with diagonal fallback) ---
|
||||
let (mode_energies, environmental_modes, baseline_eig_count) =
|
||||
// Returns: (energies, modes, baseline_count, baseline_noise_var).
|
||||
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
|
||||
// estimation hot path treats 0.0 as "no anchored noise floor" and
|
||||
// falls back to per-window noise_var, preserving pre-#942 behavior.
|
||||
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
|
||||
if let Some(ref cov_sum) = self.covariance_sum {
|
||||
if self.covariance_count > 1 {
|
||||
// Compute sample covariance from raw outer products:
|
||||
@@ -588,23 +599,28 @@ impl FieldModel {
|
||||
let baseline_count =
|
||||
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
|
||||
|
||||
(energies, modes, baseline_count)
|
||||
(energies, modes, baseline_count, noise_var)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to diagonal approximation on SVD failure
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) =
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
}
|
||||
// When eigenvalue feature is disabled, use diagonal fallback
|
||||
#[cfg(not(feature = "eigenvalue"))]
|
||||
{
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
};
|
||||
|
||||
// Compute variance explained using the same centered covariance as modes.
|
||||
@@ -648,6 +664,7 @@ impl FieldModel {
|
||||
calibrated_at_us: timestamp_us,
|
||||
geometry_hash,
|
||||
baseline_eigenvalue_count: baseline_eig_count,
|
||||
baseline_noise_var,
|
||||
};
|
||||
|
||||
self.modes = Some(field_mode);
|
||||
@@ -794,7 +811,7 @@ impl FieldModel {
|
||||
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
|
||||
// in the bottom half. Excludes zeros from rank-deficient matrices
|
||||
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
|
||||
let noise_var = {
|
||||
let local_noise_var = {
|
||||
let mut positive: Vec<f64> =
|
||||
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
|
||||
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
@@ -807,6 +824,22 @@ impl FieldModel {
|
||||
return Ok(0); // All zero eigenvalues — can't estimate
|
||||
}
|
||||
};
|
||||
|
||||
// Issue #942: anchor the noise floor to the calibration's noise_var
|
||||
// when it's available. Per-window noise_var drifts with sample size —
|
||||
// a short estimation window can produce a small local_noise_var that
|
||||
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
|
||||
// invariant. The max of (calibration noise, local noise) keeps the
|
||||
// threshold from collapsing on small windows while still letting the
|
||||
// per-window noise dominate when it's the larger estimate. Falls back
|
||||
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
|
||||
// calibration path, or pre-#942 stored modes).
|
||||
let noise_var = if modes.baseline_noise_var > 0.0 {
|
||||
local_noise_var.max(modes.baseline_noise_var)
|
||||
} else {
|
||||
local_noise_var
|
||||
};
|
||||
|
||||
let ratio = n as f64 / count as f64;
|
||||
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user