Compare commits
18 Commits
33ac00a552
...
7cd83d052c
| Author | SHA1 | Date |
|---|---|---|
|
|
7cd83d052c | |
|
|
f5d0e1e69e | |
|
|
b12662a54d | |
|
|
573b00fd98 | |
|
|
91b0e625bd | |
|
|
88b835dd89 | |
|
|
f8f08076eb | |
|
|
55f6a74e1e | |
|
|
b5a91c5635 | |
|
|
308d2fc89d | |
|
|
5038e3c8e1 | |
|
|
e239af3636 | |
|
|
4856afbd0c | |
|
|
4d205a05c4 | |
|
|
bc42ae7903 | |
|
|
b7b8c1109b | |
|
|
786e834dae | |
|
|
f02b431b59 |
|
|
@ -265,23 +265,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 +389,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 +408,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 +420,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
|
||||
|
|
|
|||
|
|
@ -115,11 +115,12 @@ jobs:
|
|||
# RUN guard catches missing ones at build time, this re-checks the
|
||||
# pushed artifact post-hoc as belt-and-braces).
|
||||
# 2. /health is up.
|
||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||
# 3. Secure-by-default (#864): with NO token env, the entrypoint
|
||||
# generates one, so /api/v1/info returns 401 without a bearer.
|
||||
# 4. Explicit LAN opt-out (RUVIEW_ALLOW_UNAUTHENTICATED=1) restores the
|
||||
# old unauthenticated 200 + serves the UI.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Smoke-test image assets + LAN-mode HTTP
|
||||
- name: Smoke-test image assets + secure-by-default HTTP
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
|
|
@ -128,17 +129,33 @@ jobs:
|
|||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
# Wait up to 30 s for /health.
|
||||
# (a) Secure-by-default: no token env ⇒ entrypoint auto-generates one
|
||||
# ⇒ /api/v1/info is gated (401) even though we passed no token.
|
||||
docker run -d --name secdef -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:3000/health
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "secure-by-default broken: expected 401, got $code (#864)"; exit 1; }
|
||||
# The auto-generated token is printed to the logs for operators.
|
||||
docker logs secdef 2>&1 | grep -q "generated one for you" || \
|
||||
{ echo "expected generated-token banner in logs (#864)"; exit 1; }
|
||||
docker stop secdef
|
||||
|
||||
# (b) Explicit opt-out for trusted LAN ⇒ unauthenticated 200 + UI.
|
||||
docker run -d --name lan -p 3000:3000 -e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 "$IMAGE"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
||||
docker stop sm
|
||||
docker stop lan
|
||||
|
||||
- name: Smoke-test the bearer-token auth path
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -430,7 +430,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 = {
|
||||
|
|
|
|||
|
|
@ -60,9 +60,12 @@ RUN set -e; \
|
|||
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
|
||||
echo "image assets OK"
|
||||
|
||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
||||
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
|
||||
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
|
||||
# Bearer-token auth on /api/v1/* and /ws/* (#443, #864). Secure-by-default:
|
||||
# when left unset the entrypoint GENERATES a random token at startup and prints
|
||||
# it to the logs, so the sensing API + WebSocket stream are never anonymous out
|
||||
# of the box. Pin a known token, or opt into the open LAN posture explicitly:
|
||||
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... # pin a token
|
||||
# docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # trusted LAN only
|
||||
ENV RUVIEW_API_TOKEN=
|
||||
|
||||
# HTTP API
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@ services:
|
|||
- "5005:5005/udp"
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# Bearer-token auth (#864). Secure-by-default: if RUVIEW_API_TOKEN is
|
||||
# unset the container generates a random token at startup — retrieve it
|
||||
# with `docker compose logs sensing-server`. Pin a known token by exporting
|
||||
# RUVIEW_API_TOKEN in your shell / .env, or run open on a trusted, isolated
|
||||
# LAN with RUVIEW_ALLOW_UNAUTHENTICATED=1.
|
||||
# REST: Authorization: Bearer <token>
|
||||
# WS: ws://<host>:3001/ws/sensing?token=<token>
|
||||
- RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN:-}
|
||||
- RUVIEW_ALLOW_UNAUTHENTICATED=${RUVIEW_ALLOW_UNAUTHENTICATED:-}
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||
|
|
|
|||
|
|
@ -38,6 +38,52 @@ case "${1:-}" in
|
|||
;;
|
||||
esac
|
||||
|
||||
# ── #864: secure-by-default API auth for the sensing server ──────────────────
|
||||
#
|
||||
# The sensing server publishes a live RF-sensing REST API and WebSocket stream.
|
||||
# Historically the Docker image shipped with RUVIEW_API_TOKEN empty, which makes
|
||||
# bearer auth a no-op and exposes `/api/v1/*` and `/ws/sensing` to anyone who can
|
||||
# reach the published ports. We now fail closed: if no token is supplied we
|
||||
# generate a strong random one and print it, so the stream is never anonymous by
|
||||
# default. Operators on a trusted, isolated LAN can opt back into the open
|
||||
# posture explicitly with RUVIEW_ALLOW_UNAUTHENTICATED=1.
|
||||
generate_token() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
elif [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
# Two UUIDs (dashes stripped) → 64 hex chars of kernel randomness.
|
||||
printf '%s%s' \
|
||||
"$(cat /proc/sys/kernel/random/uuid)" \
|
||||
"$(cat /proc/sys/kernel/random/uuid)" | tr -d '-'
|
||||
else
|
||||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ]; then
|
||||
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
|
||||
1|true|TRUE|yes|YES)
|
||||
echo "WARNING: RUVIEW_ALLOW_UNAUTHENTICATED is set — the sensing API and" >&2
|
||||
echo " /ws/sensing stream will run UNAUTHENTICATED. Only do this on a" >&2
|
||||
echo " trusted, isolated network (issue #864)." >&2
|
||||
;;
|
||||
*)
|
||||
RUVIEW_API_TOKEN="$(generate_token)"
|
||||
export RUVIEW_API_TOKEN
|
||||
echo "============================================================" >&2
|
||||
echo " RuView: no RUVIEW_API_TOKEN supplied — generated one for you:" >&2
|
||||
echo " RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN}" >&2
|
||||
echo "" >&2
|
||||
echo " REST: Authorization: Bearer <token>" >&2
|
||||
echo " WS: ws://<host>:3001/ws/sensing?token=<token>" >&2
|
||||
echo "" >&2
|
||||
echo " Pin your own with -e RUVIEW_API_TOKEN=..., or run open on a" >&2
|
||||
echo " trusted LAN with -e RUVIEW_ALLOW_UNAUTHENTICATED=1 (issue #864)." >&2
|
||||
echo "============================================================" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# If the first argument looks like a flag (starts with -), prepend the
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -104,6 +104,23 @@ static uint8_t s_hop_index = 0;
|
|||
/** Handle for the periodic hop timer. NULL when timer is not running. */
|
||||
static esp_timer_handle_t s_hop_timer = NULL;
|
||||
|
||||
/** Handle for the periodic probe-request injection timer (RuView#866).
|
||||
* NULL when not running. */
|
||||
static esp_timer_handle_t s_probe_timer = NULL;
|
||||
|
||||
/* Probe-request injection cadence (RuView#866). The MGMT-only promiscuous
|
||||
* filter (RuView#396) only surfaces management frames, so on a network with no
|
||||
* nearby beaconing APs — or one saturated with DATA traffic that the filter
|
||||
* drops — the CSI callback can starve (3 callbacks in 70 s was observed in
|
||||
* #866). Injecting a broadcast probe request elicits probe *responses* (which
|
||||
* ARE management frames) from every AP in range, giving a controlled, traffic-
|
||||
* independent CSI rate without re-enabling the DATA-frame interrupt storm that
|
||||
* MGMT-only exists to avoid. 100 ms ⇒ ~10 Hz, matching the 20 Hz edge sample
|
||||
* rate once ambient beacons are added. Override at build time via Kconfig. */
|
||||
#ifndef CONFIG_CSI_PROBE_INJECT_INTERVAL_MS
|
||||
#define CONFIG_CSI_PROBE_INJECT_INTERVAL_MS 100
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Serialize CSI data into ADR-018 binary frame format.
|
||||
*
|
||||
|
|
@ -464,19 +481,37 @@ void csi_collector_init(void)
|
|||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promiscuous_cb));
|
||||
|
||||
/* MGMT-only promiscuous filter + active probe injection (RuView#396).
|
||||
/* Promiscuous CSI filter (RuView#396 / RuView#866).
|
||||
*
|
||||
* DATA frames cause 100-500+ WiFi HW interrupts/sec which crashes Core 0
|
||||
* in wDev_ProcessFiq (SPI flash cache race in ESP-IDF WiFi blob).
|
||||
* MGMT-only gives ~10 Hz (beacons). Probe request injection at 10 Hz
|
||||
* adds ~10 Hz probe responses from APs → ~20 Hz total, matching the
|
||||
* edge processing designed sample rate of 20 Hz. */
|
||||
* History: DATA frames once crashed Core 0 in wDev_ProcessFiq (SPI-flash
|
||||
* cache race in the WiFi blob) under a 100-500+ interrupt/sec storm, so the
|
||||
* filter was pinned to MGMT-only. But MGMT-only starves on real networks:
|
||||
* the associated AP's beacons do not reliably generate CSI on the C6, and
|
||||
* broadcast probe injection (below) transmits fine yet elicits almost no
|
||||
* capturable responses — #866 measured ~3 CSI callbacks in 70 s with 0 pps
|
||||
* yield. Two mitigations for the original crash are now in place and active
|
||||
* (confirmed in the boot log): WiFi RX/TX IRAM optimisations keep the ISR
|
||||
* out of cacheable flash, and wifi_csi_callback() applies a 50 Hz early
|
||||
* rate gate (CSI_MIN_PROCESS_INTERVAL_US) that caps ISR work regardless of
|
||||
* arrival rate. With those guards we re-admit DATA frames so ambient/own
|
||||
* traffic produces a dense, traffic-driven CSI stream. Operators who hit
|
||||
* instability can fall back to MGMT-only via Kconfig.
|
||||
*
|
||||
* Probe injection (csi_collector_start_probe_timer) is retained: it keeps a
|
||||
* ~10 Hz floor of management-frame CSI when the link is otherwise idle. */
|
||||
#ifdef CONFIG_CSI_PROMISC_MGMT_ONLY
|
||||
uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT;
|
||||
const char *filter_desc = "MGMT-only (Kconfig override)";
|
||||
#else
|
||||
uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA;
|
||||
const char *filter_desc = "MGMT+DATA (50 Hz-gated, RuView#866)";
|
||||
#endif
|
||||
wifi_promiscuous_filter_t filt = {
|
||||
.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT,
|
||||
.filter_mask = filter_mask,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_filter(&filt));
|
||||
|
||||
ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)");
|
||||
ESP_LOGI(TAG, "Promiscuous mode enabled (%s)", filter_desc);
|
||||
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
|
||||
|
|
@ -526,6 +561,12 @@ void csi_collector_init(void)
|
|||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||
|
||||
/* RuView#866: start active probe injection so CSI keeps flowing even when
|
||||
* the MGMT-only filter would otherwise starve under heavy DATA traffic or
|
||||
* a beacon-sparse environment. Safe to call here — WiFi is started and the
|
||||
* CSI rx callback is registered above. */
|
||||
csi_collector_start_probe_timer();
|
||||
}
|
||||
|
||||
/* Accessor for other modules that need the authoritative runtime node_id. */
|
||||
|
|
@ -730,3 +771,103 @@ esp_err_t csi_inject_ndp_frame(void)
|
|||
|
||||
return err;
|
||||
}
|
||||
|
||||
/* ---- RuView#866: active probe-request injection for traffic-independent CSI ---- */
|
||||
|
||||
esp_err_t csi_inject_probe_request(void)
|
||||
{
|
||||
/*
|
||||
* Broadcast 802.11 probe request (wildcard SSID). Every AP in range answers
|
||||
* with a probe response — a *management* frame that passes the MGMT-only
|
||||
* promiscuous filter (RuView#396) and fires the CSI callback. This gives a
|
||||
* controlled CSI rate that does not depend on ambient beacon/data traffic.
|
||||
*
|
||||
* Layout: 24-byte MAC header + tagged params (wildcard SSID + basic rates).
|
||||
* FC(2) Dur(2) A1/DA(6) A2/SA(6) A3/BSSID(6) SeqCtl(2) | SSID tag | Rates tag
|
||||
*/
|
||||
uint8_t frame[] = {
|
||||
0x40, 0x00, /* FC: Type=Mgmt(0), Subtype=ProbeReq(4) */
|
||||
0x00, 0x00, /* Duration */
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A1 DA: broadcast */
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* A2 SA: own MAC (filled below) */
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A3 BSSID: broadcast */
|
||||
0x00, 0x00, /* Sequence control (HW overwrites) */
|
||||
0x00, 0x00, /* Tag: SSID, len 0 (wildcard) */
|
||||
0x01, 0x04, 0x02, 0x04, 0x0b, 0x16, /* Tag: Supported Rates 1/2/5.5/11 Mbps */
|
||||
};
|
||||
|
||||
/* The Wi-Fi driver requires A2 (source) to be this interface's own MAC for
|
||||
* a self-originated management frame, otherwise esp_wifi_80211_tx rejects
|
||||
* it with ESP_ERR_INVALID_ARG. */
|
||||
uint8_t mac[6];
|
||||
if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) {
|
||||
memcpy(&frame[10], mac, 6);
|
||||
}
|
||||
|
||||
/* en_sys_seq=true: let the MAC assign the sequence number. */
|
||||
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, sizeof(frame), true);
|
||||
|
||||
/* Observability (RuView#866): track TX outcome so the per-second yield can
|
||||
* be correlated with whether injection is actually reaching the air. The
|
||||
* first few results are logged verbatim; thereafter a periodic summary. */
|
||||
static uint32_t s_probe_ok = 0;
|
||||
static uint32_t s_probe_fail = 0;
|
||||
if (err == ESP_OK) {
|
||||
s_probe_ok++;
|
||||
if (s_probe_ok <= 3 || (s_probe_ok % 100) == 0) {
|
||||
ESP_LOGI(TAG, "probe inject ok #%lu (fail=%lu)",
|
||||
(unsigned long)s_probe_ok, (unsigned long)s_probe_fail);
|
||||
}
|
||||
} else {
|
||||
s_probe_fail++;
|
||||
if (s_probe_fail <= 3 || (s_probe_fail % 50) == 0) {
|
||||
ESP_LOGW(TAG, "probe inject failed: %s (fail #%lu, ok=%lu)",
|
||||
esp_err_to_name(err), (unsigned long)s_probe_fail,
|
||||
(unsigned long)s_probe_ok);
|
||||
}
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Timer callback: inject one probe request every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS. */
|
||||
static void probe_timer_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
csi_inject_probe_request();
|
||||
}
|
||||
|
||||
void csi_collector_start_probe_timer(void)
|
||||
{
|
||||
if (CONFIG_CSI_PROBE_INJECT_INTERVAL_MS == 0) {
|
||||
ESP_LOGI(TAG, "Probe injection disabled (interval=0)");
|
||||
return;
|
||||
}
|
||||
if (s_probe_timer != NULL) {
|
||||
ESP_LOGW(TAG, "Probe timer already running");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_timer_create_args_t timer_args = {
|
||||
.callback = probe_timer_cb,
|
||||
.arg = NULL,
|
||||
.name = "csi_probe",
|
||||
};
|
||||
esp_err_t err = esp_timer_create(&timer_args, &s_probe_timer);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to create probe timer: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t period_us = (uint64_t)CONFIG_CSI_PROBE_INJECT_INTERVAL_MS * 1000;
|
||||
err = esp_timer_start_periodic(s_probe_timer, period_us);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start probe timer: %s", esp_err_to_name(err));
|
||||
esp_timer_delete(s_probe_timer);
|
||||
s_probe_timer = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Probe injection started: %d ms (~%d Hz) to keep CSI alive under MGMT-only filter",
|
||||
CONFIG_CSI_PROBE_INJECT_INTERVAL_MS,
|
||||
1000 / CONFIG_CSI_PROBE_INJECT_INTERVAL_MS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,27 @@ void csi_collector_enable_data_capture(void);
|
|||
*/
|
||||
esp_err_t csi_inject_ndp_frame(void);
|
||||
|
||||
/**
|
||||
* Inject a broadcast 802.11 probe request (wildcard SSID) (RuView#866).
|
||||
*
|
||||
* Nearby APs answer with probe responses — management frames that pass the
|
||||
* MGMT-only promiscuous filter and fire the CSI rx callback. This produces a
|
||||
* controlled CSI rate independent of ambient beacon/data traffic, without
|
||||
* re-enabling the DATA-frame interrupt storm that the MGMT-only filter avoids.
|
||||
*
|
||||
* @return ESP_OK on success, or an error code from esp_wifi_80211_tx().
|
||||
*/
|
||||
esp_err_t csi_inject_probe_request(void);
|
||||
|
||||
/**
|
||||
* Start the periodic probe-request injection timer (RuView#866).
|
||||
*
|
||||
* Fires every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS (default 100 ms ⇒ ~10 Hz),
|
||||
* calling csi_inject_probe_request(). Called automatically from
|
||||
* csi_collector_init(); no-op if already running or if the interval is 0.
|
||||
*/
|
||||
void csi_collector_start_probe_timer(void);
|
||||
|
||||
/**
|
||||
* Get the recent CSI callback rate (per second).
|
||||
*
|
||||
|
|
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
|||
/// Path prefix the middleware protects when auth is enabled.
|
||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||
|
||||
/// Path prefix for the WebSocket sensing/introspection topics that
|
||||
/// [`require_ws_token`] protects when auth is enabled (#864).
|
||||
pub const WS_PREFIX: &str = "/ws/";
|
||||
|
||||
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
|
|
@ -115,6 +119,71 @@ pub async fn require_bearer(
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract a bearer token from a WebSocket-upgrade request. Browsers cannot set
|
||||
/// arbitrary headers on a WS handshake, so the token is accepted via the
|
||||
/// `?token=<t>` query parameter in addition to the `Authorization: Bearer`
|
||||
/// header that programmatic clients (wscat, curl) can send.
|
||||
///
|
||||
/// No percent-decoding is applied: generated tokens are URL-safe (hex from
|
||||
/// `openssl rand` / UUID concatenation). Operators who pin a custom token
|
||||
/// should keep it URL-safe.
|
||||
fn ws_supplied_token(request: &Request) -> Option<String> {
|
||||
// 1. Authorization: Bearer <token> — for programmatic clients.
|
||||
if let Some(t) = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
// 2. ?token=<token> query parameter — the only option browsers have on a
|
||||
// WebSocket handshake.
|
||||
request.uri().query().and_then(token_from_query)
|
||||
}
|
||||
|
||||
/// Find the `token` value in a `&`-separated `key=value` query string.
|
||||
fn token_from_query(query: &str) -> Option<String> {
|
||||
query.split('&').find_map(|pair| {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
match (it.next(), it.next()) {
|
||||
(Some("token"), Some(v)) => Some(v.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Axum middleware: enforces a valid token on `/ws/*` upgrade requests when
|
||||
/// [`AuthState::is_enabled`] returns `true` (#864). Mirrors [`require_bearer`]
|
||||
/// but reads the token from `?token=` (browser-friendly) or `Authorization`.
|
||||
/// When auth is disabled the middleware is a no-op, preserving the LAN-only
|
||||
/// default for non-Docker local runs.
|
||||
pub async fn require_ws_token(
|
||||
State(auth): State<AuthState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(expected) = auth.token.clone() else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
if !request.uri().path().starts_with(WS_PREFIX) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let ok = ws_supplied_token(&request)
|
||||
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
next.run(request).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid token (append ?token=<RUVIEW_API_TOKEN> to the ws URL, \
|
||||
or send Authorization: Bearer <token>)\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -256,5 +325,82 @@ mod tests {
|
|||
// These are documented in the issue body and the README; keep them locked.
|
||||
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
|
||||
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
|
||||
assert_eq!(WS_PREFIX, "/ws/");
|
||||
}
|
||||
|
||||
// ── #864: WebSocket token enforcement ────────────────────────────────────
|
||||
|
||||
fn ws_router(auth: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws/sensing", get(|| async { "stream" }))
|
||||
.route("/ws/introspection", get(|| async { "stream" }))
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(auth, require_ws_token))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_query_parses_first_match() {
|
||||
assert_eq!(token_from_query("token=abc").as_deref(), Some("abc"));
|
||||
assert_eq!(token_from_query("a=1&token=abc&b=2").as_deref(), Some("abc"));
|
||||
assert_eq!(token_from_query("a=1&b=2").as_deref(), None);
|
||||
assert_eq!(token_from_query("").as_deref(), None);
|
||||
// bare key with no value is not a token
|
||||
assert_eq!(token_from_query("token").as_deref(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_unprotected_when_token_unset() {
|
||||
let r = ws_router(AuthState::default());
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing", None).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_blocks_without_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/ws/sensing", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/introspection", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_allows_with_query_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing?token=s3cr3t", None).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_allows_with_bearer_header() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing", Some("s3cr3t")).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_blocks_with_wrong_query_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing?token=nope", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_middleware_never_gates_non_ws_paths() {
|
||||
// /health rides on the same router (dedicated WS port) and must stay open.
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(status(r, "GET", "/health", None).await, StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5476,6 +5476,149 @@ 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()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
|
||||
|
|
@ -6113,7 +6256,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,56 +6345,13 @@ 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 ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
|
||||
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
|
||||
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
|
||||
let breathing = vit["breathing_rate_bpm"].as_f64();
|
||||
let hr = vit["heart_rate_bpm"].as_f64();
|
||||
// #898: emit one snapshot per physical node so each
|
||||
// surfaces as its own Home-Assistant device (with
|
||||
// its own RSSI + availability). Falls back to a
|
||||
// single aggregate snapshot when there is no
|
||||
// per-node data (e.g. wifi / simulate sources).
|
||||
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
|
||||
node_id: nid,
|
||||
timestamp_ms: ts,
|
||||
presence,
|
||||
motion,
|
||||
presence_score,
|
||||
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() => {
|
||||
for node in arr {
|
||||
let n = node["node_id"].as_u64().unwrap_or(0);
|
||||
let nid = format!("{node_id}-node{n}");
|
||||
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = vtx.send(mk(
|
||||
node_id.clone(),
|
||||
v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||
));
|
||||
}
|
||||
// #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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -6384,7 +6486,10 @@ async fn main() {
|
|||
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
|
||||
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
|
||||
if bearer_auth_state.is_enabled() {
|
||||
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
|
||||
info!(
|
||||
"API auth: bearer-token enforcement ON for /api/v1/* and /ws/* (RUVIEW_API_TOKEN set). \
|
||||
WebSocket clients pass it as ?token=<token>."
|
||||
);
|
||||
if bind_ip.is_unspecified() {
|
||||
warn!(
|
||||
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
||||
|
|
@ -6392,8 +6497,9 @@ async fn main() {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
|
||||
warn!(
|
||||
"API auth: OFF — /api/v1/* and /ws/* sensing streams are UNAUTHENTICATED. \
|
||||
Set RUVIEW_API_TOKEN=<token> to enforce bearer auth (the Docker image does this by default)."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6426,6 +6532,13 @@ async fn main() {
|
|||
let ws_app = Router::new()
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
.route("/health", get(health))
|
||||
// #864: gate the live sensing stream with the API token when set. Reads
|
||||
// `?token=` (browser-friendly) or `Authorization: Bearer`. No-op when
|
||||
// RUVIEW_API_TOKEN is unset (LAN-mode default for local non-Docker runs).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
|
|
@ -6538,12 +6651,19 @@ async fn main() {
|
|||
))
|
||||
// Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN`
|
||||
// is unset/empty the middleware is a no-op — the default stays
|
||||
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
|
||||
// gated (orchestrator probes + local browsers).
|
||||
// LAN-mode-friendly. `/health*` and `/ui/*` are never gated
|
||||
// (orchestrator probes + local browsers loading the static UI).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// #864: gate the live `/ws/*` sensing + introspection streams with the
|
||||
// same token. Browsers pass it as `?token=`; programmatic clients use
|
||||
// `Authorization: Bearer`. No-op when RUVIEW_API_TOKEN is unset.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||
))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
|
|
@ -7068,3 +7188,143 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue