diff --git a/.github/workflows/fork-ghcr-sensing-server.yml b/.github/workflows/fork-ghcr-sensing-server.yml new file mode 100644 index 0000000000..408ea0cc08 --- /dev/null +++ b/.github/workflows/fork-ghcr-sensing-server.yml @@ -0,0 +1,124 @@ +name: (fork) sensing-server → ghcr.io/rjperry36 + +# Fork-local image build for the QNAP redeploy path (Option A). +# +# The upstream `sensing-server-docker.yml` pushes `ruvnet/wifi-densepose:latest` +# to ruvnet's Docker Hub + ghcr and needs upstream secrets — a fork can't run it. +# This workflow instead builds the sensing-server image on GitHub's runners and +# pushes it to THIS fork's own ghcr namespace (`ghcr.io//wifi-densepose`) +# using only the built-in GITHUB_TOKEN. The QNAP (Intel/amd64, Container Station) +# then pulls it directly: +# +# IMAGE=ghcr.io/rjperry36/wifi-densepose:calfix ./scripts/deploy-qnap.sh +# +# amd64-only on purpose: the QNAP TS-853A is x86_64, so there's no reason to pay +# for the emulated arm64 layer (and no QEMU setup needed). + +on: + push: + branches: + - fix/calibration-feed-deadlock + - main + paths: + - 'docker/Dockerfile.rust' + - 'docker/docker-entrypoint.sh' + - 'v2/crates/**' + - 'v2/Cargo.toml' + - 'v2/Cargo.lock' + - 'ui/**' + - '.github/workflows/fork-ghcr-sensing-server.yml' + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +concurrency: + group: fork-ghcr-sensing-server-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-publish: + name: build · push · smoke-test (amd64 → ghcr) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/wifi-densepose + # `calfix` is a stable, easy-to-pull tag for the QNAP redeploy; + # the sha tag pins the exact commit for reproducibility. + tags: | + type=raw,value=calfix + type=sha,format=short + type=ref,event=branch + + - name: Build + push (linux/amd64) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile.rust + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke-test the pushed image (boots + serves in simulated mode) + run: | + set -euo pipefail + IMAGE="ghcr.io/${{ github.repository_owner }}/wifi-densepose:sha-${GITHUB_SHA::7}" + docker pull "$IMAGE" + # UI assets present. Bypass the entrypoint (--entrypoint sh): the + # docker-entrypoint.sh #864 guard exits 64 on any invocation without + # a token / RUVIEW_ALLOW_UNAUTHENTICATED, and this file-listing check + # has no reason to boot the server or carry auth config. + docker run --rm --entrypoint sh "$IMAGE" -c \ + 'ls /app/ui/index.html /app/ui/observatory.html /app/ui/pose-fusion.html >/dev/null' + # Boots and answers /health + /api/v1/info in LAN (no-auth) mode. + # RUVIEW_ALLOW_UNAUTHENTICATED=1 is required or the entrypoint refuses + # to start on 0.0.0.0 without a token (#864) — same posture the QNAP + # deploy uses on the trusted LAN. + docker run -d --name sm -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 + curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null + docker stop sm + + - name: Summary + if: always() + run: | + { + echo "## sensing-server image published to your fork's ghcr" + echo + echo "Pull + redeploy on the QNAP:" + echo '```' + echo "IMAGE=ghcr.io/${{ github.repository_owner }}/wifi-densepose:calfix CSI_SOURCE=esp32 ./scripts/deploy-qnap.sh" + echo '```' + echo + echo "Tags built:" + echo '```' + echo "${{ steps.meta.outputs.tags }}" + echo '```' + echo + echo "> If the QNAP can't pull, make the ghcr package public (GitHub → your profile → Packages → wifi-densepose → Package settings → Change visibility), or 'docker login ghcr.io' on the NAS with a PAT (read:packages)." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 770a0cd71f..d175a2f20d 100644 --- a/.gitignore +++ b/.gitignore @@ -291,3 +291,7 @@ harness/**/ruvector.db # ruvector runtime/hook DB — never tracked (any depth) ruvector.db **/ruvector.db + +# macOS Finder cruft +.DS_Store +**/.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173107f14..70e2cbf6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Empty rooms reported permanent false-positive presence in high-multipath (strong-RSSI) environments.** The `motion_score` (`csi.rs::extract_features_from_frame`) mixes a true frame-to-frame `temporal_motion_score` (~0 in an empty room) with **absolute** static terms — `variance/10` and `motion_band_power/25` — which saturate to 1.0 whenever the room's ambient CSI variance/MBP exceed those divisors (common at strong RSSI, e.g. −39 dBm). That pushed raw `motion_score` to ~0.47 with no one present. `smooth_and_classify[_node]` learns the ambient as `baseline_motion` but only subtracted **70%** of it (`raw − baseline*0.7`), leaving ~0.14 — above both the `present_moving` (>0.12) and `presence` (>0.03) thresholds — so an empty room read `present_moving` forever. Changed the subtraction to the **full** learned baseline via a new `BASELINE_SUBTRACT_FACTOR = 1.0`, so presence reflects motion *above* the room's static ambient (empty → `absent`; a person still registers as motion above baseline). Pinned by `csi::…::sustained_ambient_settles_to_absent_after_warmup` (stable 0.47 ambient → `absent`; stayed `present_moving` on old code). Person-present sensitivity to be validated on live hardware. +- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment. +- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model. - **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed. - **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`. - **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4). diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000000..4aff62e118 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,40 @@ +# deploy/ — RuView deployment templates + +This folder holds **reusable deployment templates**, one subfolder per target +(`qnap/`, …). They are *templates*, not live instances. + +## The model + +``` +this fork (ruview-fork) ← the software. Tracks upstream ruvnet/RuView. + │ builds & publishes → ruvnet/wifi-densepose: (Docker image) + ▼ +deploy// ← reusable template (compose + .env.example, committed) + │ copy out + fill .env → + ▼ +your real deployment ← a project instance with real tokens/hosts (NOT in git) +``` + +## Rules that keep it clean + +- **Templates live here; real instances live outside.** A `.env` with real + tokens/hosts is per-project and must never be committed — only `.env.example` is. +- **Pin the image tag** in each template for reproducibility (avoid `:latest`, + which mutates under you). Bump the tag deliberately when you adopt a release. +- **Fork the code only when you must change RuView's source.** Different + container, ports, data source, or host = a deployment difference → new + `deploy//` template or a separate deploy repo, *not* a source fork. + +## When to graduate to a separate repo + +Start with templates here. Extract a target into its own deployment repo once a +second project exists, or when an instance needs secrets/infra that shouldn't sit +beside the core. The folder copies out cleanly as that repo's seed. + +## Pull upstream updates + +```bash +git fetch upstream +git merge upstream/main # or: git rebase upstream/main +``` +(`upstream` = https://github.com/ruvnet/RuView.git; pushing to it is disabled.) diff --git a/deploy/qnap/.env.example b/deploy/qnap/.env.example new file mode 100644 index 0000000000..8c90b07da5 --- /dev/null +++ b/deploy/qnap/.env.example @@ -0,0 +1,21 @@ +# RuView QNAP deployment — copy to `.env` and fill in. DO NOT commit `.env`. + +# Image version (pinned for reproducibility). v0.8.3-esp32 == the verified stable build. +RUVIEW_IMAGE_TAG=v0.8.3-esp32 + +CONTAINER_NAME=ruview +PORT=3010 # host port -> container 3000 (UI/REST) +WS_PORT=3011 # host port -> container 3001 (WebSocket) +CSI_SOURCE=simulated # simulated | esp32 | wifi + +# Bearer token for /ws/sensing & /api/v1/*. The image REFUSES to start (exit 64) +# with no token while bound to 0.0.0.0. Generate one: openssl rand -hex 32 +RUVIEW_API_TOKEN= + +# DNS-rebinding allowlist. MUST include this host's LAN IP + port or browsers get +# HTTP 421. Example for a NAS at 192.168.1.132 on port 3010: +SENSING_ALLOWED_HOSTS=192.168.1.132:3010,192.168.1.132,localhost:3010,localhost + +# Alternative to a token: run OPEN on a trusted LAN (leave RUVIEW_API_TOKEN blank +# and uncomment). Weaker — anyone on the LAN can read live sensing frames. +# RUVIEW_ALLOW_UNAUTHENTICATED=1 diff --git a/deploy/qnap/README.md b/deploy/qnap/README.md new file mode 100644 index 0000000000..092be51c93 --- /dev/null +++ b/deploy/qnap/README.md @@ -0,0 +1,34 @@ +# RuView on QNAP (Container Station) + +Two ways to deploy. Both run the same pinned image (`v0.8.3-esp32`). + +## Option 1 — script (auto token + auto allowlist) — recommended + +`../../scripts/deploy-qnap.sh` generates & persists an API token and auto-derives +`SENSING_ALLOWED_HOSTS` from the NAS LAN IP. Run it **on the NAS**: + +```bash +scp -P 2222 ../../scripts/deploy-qnap.sh admin@: +ssh -p 2222 admin@ +PORT=3010 CSI_SOURCE=simulated ./deploy-qnap.sh +``` + +## Option 2 — docker compose (declarative) + +```bash +cp .env.example .env +# - set RUVIEW_API_TOKEN (openssl rand -hex 32) +# - set SENSING_ALLOWED_HOSTS to :,,... +docker compose up -d # older Container Station: docker-compose up -d +``` + +Then open `http://:3010`. Health check: `curl http://:3010/health`. + +## Notes + +- **Secrets stay out of git** — only `.env.example` is committed; your real `.env` + (and the token) are not. +- **Going live with an ESP32:** provision it with `--target-ip `, then set + `CSI_SOURCE=esp32` and redeploy. +- **Prereqs on the NAS:** Container Station installed, and a working default + gateway/DNS (so Docker can pull). See `../../SETUP-COWORK.md` "Path D". diff --git a/deploy/qnap/docker-compose.yml b/deploy/qnap/docker-compose.yml new file mode 100644 index 0000000000..5df622eec5 --- /dev/null +++ b/deploy/qnap/docker-compose.yml @@ -0,0 +1,23 @@ +# RuView (WiFi-DensePose) — QNAP / Container Station deployment template. +# +# Usage: +# cp .env.example .env # then fill in RUVIEW_API_TOKEN + SENSING_ALLOWED_HOSTS +# docker compose up -d # (older Container Station: docker-compose up -d) +# +# The image is pinned for reproducibility (RUVIEW_IMAGE_TAG). Bump it in .env when +# you adopt a newer RuView release. For the auto-everything imperative path (token +# generation + LAN-IP allowlist detection), use ../../scripts/deploy-qnap.sh instead. +services: + ruview: + image: ruvnet/wifi-densepose:${RUVIEW_IMAGE_TAG:-v0.8.3-esp32} + container_name: ${CONTAINER_NAME:-ruview} + restart: unless-stopped + ports: + - "${PORT:-3010}:3000" # UI / REST + - "${WS_PORT:-3011}:3001" # WebSocket (/ws/sensing) + - "5005:5005/udp" # ESP32 CSI ingest + environment: + CSI_SOURCE: ${CSI_SOURCE:-simulated} # simulated | esp32 | wifi + RUVIEW_API_TOKEN: ${RUVIEW_API_TOKEN:-} # bearer token; required unless allow-unauth + SENSING_ALLOWED_HOSTS: ${SENSING_ALLOWED_HOSTS:-} # else browsers get HTTP 421 + RUVIEW_ALLOW_UNAUTHENTICATED: ${RUVIEW_ALLOW_UNAUTHENTICATED:-} # set 1 for open trusted-LAN diff --git a/scripts/deploy-qnap.sh b/scripts/deploy-qnap.sh new file mode 100644 index 0000000000..021224d888 --- /dev/null +++ b/scripts/deploy-qnap.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Deploy RuView (WiFi-DensePose) on a QNAP NAS via Docker / Container Station. +# Safe to re-run: it removes any prior "ruview" container and recreates it with the +# same persisted API token + an auto-derived host allowlist, so re-runs "just work". +# +# Usage (run ON the NAS over SSH, or let Claude Code run it): +# ./deploy-qnap.sh # port 3010, simulated, token auth +# PORT=3010 CSI_SOURCE=simulated ./deploy-qnap.sh +# CSI_SOURCE=esp32 ./deploy-qnap.sh # once your ESP32 streams to the NAS +# RUVIEW_ALLOW_UNAUTHENTICATED=1 ./deploy-qnap.sh # open on a trusted LAN (no token) +# +# Security: by default the server requires a bearer token because /ws/sensing streams +# live sensing frames (the image refuses to start otherwise — exit 64). This script +# generates a token on first run and persists it to $TOKEN_FILE so every re-run reuses +# it (clients keep working). It also sets SENSING_ALLOWED_HOSTS (DNS-rebinding defense, +# else browsers get HTTP 421) to this NAS's LAN IP + port automatically. +set -euo pipefail + +PORT="${PORT:-3010}" # host port -> container 3000 (REST/UI) +WS_PORT="${WS_PORT:-3011}" # host port -> container 3001 (WebSocket) +CSI_SOURCE="${CSI_SOURCE:-simulated}" # simulated | esp32 | wifi | auto +IMAGE="${IMAGE:-ruvnet/wifi-densepose:latest}" +NAME="${NAME:-ruview}" +TOKEN_FILE="${TOKEN_FILE:-$HOME/.ruview/api_token}" # persisted API token + +# --- locate docker (QNAP often hides it under Container Station) ------------- +DOCKER="" +if command -v docker >/dev/null 2>&1; then + DOCKER="$(command -v docker)" +else + for p in \ + /share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker \ + /share/ZFS530_DATA/.qpkg/container-station/bin/docker \ + /share/*/.qpkg/container-station/bin/docker; do + [ -x "$p" ] && DOCKER="$p" && break + done +fi +if [ -z "$DOCKER" ]; then + echo "ERROR: docker not found. Enable Container Station, or set DOCKER=/path/to/docker." >&2 + exit 1 +fi +echo ">> Using docker at: $DOCKER" + +# --- report architecture (image is multi-arch: amd64 + arm64) --------------- +ARCH="$(uname -m)" +echo ">> NAS architecture: $ARCH" +case "$ARCH" in + x86_64|amd64|aarch64|arm64) ;; + *) echo "WARN: $ARCH may not have a matching image tag; continuing anyway." >&2 ;; +esac + +# --- detect this NAS's primary LAN IP (used for the host allowlist) ---------- +# Tolerant: BusyBox `hostname` (QNAP) lacks -I, so try `ip` first. Never let a +# failed probe abort the script (set -e + pipefail) — hence the trailing `|| true`. +IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' || true)" +[ -z "$IP" ] && IP="$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2}' | grep -v '^127\.' | cut -d/ -f1 | head -1 || true)" +[ -z "$IP" ] && IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +[ -z "$IP" ] && IP="$(hostname -i 2>/dev/null | awk '{print $1}' || true)" + +# --- assemble container env ------------------------------------------------- +RUN_ENV=( -e "CSI_SOURCE=${CSI_SOURCE}" ) + +# Auth posture: bearer token by default; opt out only on a trusted LAN. +if [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-0}" = "1" ]; then + echo ">> AUTH: UNAUTHENTICATED (RUVIEW_ALLOW_UNAUTHENTICATED=1) — open to anyone on the LAN" + RUN_ENV+=( -e "RUVIEW_ALLOW_UNAUTHENTICATED=1" ) + TOKEN="" +else + TOKEN="${RUVIEW_API_TOKEN:-}" + if [ -z "$TOKEN" ] && [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + echo ">> AUTH: reusing API token from $TOKEN_FILE" + fi + if [ -z "$TOKEN" ]; then + if command -v openssl >/dev/null 2>&1; then + TOKEN="$(openssl rand -hex 32)" + else + TOKEN="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + fi + mkdir -p "$(dirname "$TOKEN_FILE")" + ( umask 077; printf '%s\n' "$TOKEN" > "$TOKEN_FILE" ) + chmod 600 "$TOKEN_FILE" 2>/dev/null || true + echo ">> AUTH: generated a new API token, saved to $TOKEN_FILE" + fi + RUN_ENV+=( -e "RUVIEW_API_TOKEN=${TOKEN}" ) +fi + +# Host allowlist (DNS-rebinding defense). Without the NAS IP here, browsers get HTTP 421. +DEFAULT_HOSTS="localhost:${PORT},localhost,127.0.0.1:${PORT},127.0.0.1" +[ -n "$IP" ] && DEFAULT_HOSTS="${IP}:${PORT},${IP},${DEFAULT_HOSTS}" +ALLOWED_HOSTS="${SENSING_ALLOWED_HOSTS:-$DEFAULT_HOSTS}" +RUN_ENV+=( -e "SENSING_ALLOWED_HOSTS=${ALLOWED_HOSTS}" ) +echo ">> Allowed hosts: ${ALLOWED_HOSTS}" + +# Optional: real RuField signing seed (else the server uses a dev key + warns). +[ -n "${WDP_RUFIELD_SIGNING_SEED:-}" ] && RUN_ENV+=( -e "WDP_RUFIELD_SIGNING_SEED=${WDP_RUFIELD_SIGNING_SEED}" ) + +# --- pull, replace, run ------------------------------------------------------ +echo ">> Pulling $IMAGE ..." +"$DOCKER" pull "$IMAGE" + +echo ">> Removing any existing '$NAME' container ..." +"$DOCKER" rm -f "$NAME" >/dev/null 2>&1 || true + +echo ">> Starting '$NAME' (CSI_SOURCE=$CSI_SOURCE) ..." +"$DOCKER" run -d \ + --name "$NAME" \ + --restart unless-stopped \ + -p "${PORT}:3000" \ + -p "${WS_PORT}:3001" \ + -p "5005:5005/udp" \ + "${RUN_ENV[@]}" \ + "$IMAGE" + +sleep 4 +echo ">> Status:" +"$DOCKER" ps --filter "name=${NAME}" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + +# --- health probe (loopback host is always allowed) ------------------------- +if command -v curl >/dev/null 2>&1; then + CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://localhost:${PORT}/health" 2>/dev/null || true)" + echo ">> Health: http://localhost:${PORT}/health -> ${CODE:-no-response}" + [ "$CODE" = "200" ] || echo " (not 200 yet — the server may still be starting; check: $DOCKER logs $NAME)" +fi + +echo "" +echo ">> Open: http://${IP:-}:${PORT}" +if [ -n "$TOKEN" ]; then + echo ">> API token (for /ws/sensing & /api/v1/*): $TOKEN" + echo ">> persisted at $TOKEN_FILE — re-runs reuse it automatically" +fi +echo ">> Logs: $DOCKER logs -f $NAME" +echo ">> Stop: $DOCKER rm -f $NAME" +if [ "$CSI_SOURCE" = "auto" ]; then + echo "NOTE: CSI_SOURCE=auto exits (code 78) with no live CSI. Use simulated or esp32." +fi + +exit 0 diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index a4853c8b0e..6da372eb6a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -523,7 +523,7 @@ pub fn smooth_and_classify( state.baseline_motion = state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } - let adjusted = (raw_motion - state.baseline_motion * 0.7).max(0.0); + let adjusted = (raw_motion - state.baseline_motion * BASELINE_SUBTRACT_FACTOR).max(0.0); state.smoothed_motion = state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = state.smoothed_motion; @@ -554,7 +554,7 @@ pub fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo ns.baseline_motion = ns.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } - let adjusted = (raw_motion - ns.baseline_motion * 0.7).max(0.0); + let adjusted = (raw_motion - ns.baseline_motion * BASELINE_SUBTRACT_FACTOR).max(0.0); ns.smoothed_motion = ns.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = ns.smoothed_motion; @@ -1070,4 +1070,30 @@ mod adr110_tests { // Steady-state HE frames keep flowing. assert!(ns.accept_grid(he.grid())); } + + /// A stable, high-ambient raw motion score (a strong-RSSI empty room whose + /// static variance/MBP saturate the raw score) must settle to `absent` once + /// the baseline learns the ambient and full subtraction (factor 1.0) applies. + /// With the previous 0.7 factor this stayed `present_moving` forever. + #[test] + fn sustained_ambient_settles_to_absent_after_warmup() { + let mut ns = NodeState::new(); + // ~0.47 is the measured empty-room raw motion score on the live node. + let ambient = 0.47_f64; + let mut raw = ClassificationInfo { + motion_level: String::new(), + presence: true, + confidence: 1.0, + }; + // Run well past BASELINE_WARMUP so the baseline converges and smoothing + // decays; DEBOUNCE_FRAMES then lets the level transition to absent. + for _ in 0..600 { + smooth_and_classify_node(&mut ns, &mut raw, ambient); + } + assert!( + !raw.presence, + "stable ambient must read absent after the baseline learns it (smoothed still above 0.03)" + ); + assert_eq!(raw.motion_level, "absent"); + } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs index 8c009e237c..5b7f588fdd 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -7,12 +7,22 @@ //! score-based heuristic in `score_to_person_count`. use std::collections::VecDeque; +use std::sync::LazyLock; +use wifi_densepose_signal::hardware_norm::HardwareNormalizer; use wifi_densepose_signal::ruvsense::field_model::{ CalibrationStatus, FieldModel, FieldModelConfig, }; use super::score_to_person_count; +/// Length-only canonicalizer for calibration frames (issue #1170 pattern, +/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the +/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is +/// configured for the canonical 56-tone grid, and `feed_calibration` rejects +/// any other width with `DimensionMismatch`. Resampling here (default 56) +/// lets real HT40 nodes actually calibrate instead of silently feeding nothing. +static CALIB_NORMALIZER: LazyLock = LazyLock::new(HardwareNormalizer::new); + /// Number of recent frames to feed into perturbation extraction. const OCCUPANCY_WINDOW: usize = 50; @@ -99,15 +109,27 @@ pub fn occupancy_or_fallback( /// Feed the latest frame to the FieldModel during calibration collection. /// -/// Only acts when the model status is `Collecting`. Wraps the latest frame -/// as a single-link observation (n_links=1) and feeds it. +/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame +/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside +/// `feed_calibration`; without accepting the `Uncalibrated` state here the two +/// gates deadlock and the frame count never leaves 0 (calibration/start yields +/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest +/// frame as a single-link observation (n_links=1) and feeds it. pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque>) { - if field.status() != CalibrationStatus::Collecting { + if !matches!( + field.status(), + CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting + ) { return; } if let Some(latest) = frame_history.back() { - // Single-link observation: [1][n_subcarriers] - let observations = vec![latest.clone()]; + // Resample the raw amplitude vector onto the FieldModel's canonical + // 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames; + // feeding those raw made every `feed_calibration` fail DimensionMismatch + // (swallowed at debug level), pinning frame_count at 0 even after the + // status-gate deadlock was fixed. Single-link observation: [1][56]. + let canonical = CALIB_NORMALIZER.resample_to_canonical(latest); + let observations = vec![canonical]; if let Err(e) = field.feed_calibration(&observations) { tracing::debug!("FieldModel calibration feed: {e}"); } @@ -180,4 +202,65 @@ mod tests { assert_eq!(positions.len(), 1); assert_eq!(positions[0], [3.0, 4.0, 5.0]); } + + /// Regression: a freshly-started (`Uncalibrated`) field model must begin + /// collecting once frames arrive. Before the fix, `maybe_feed_calibration` + /// only fed while already `Collecting`, but only `feed_calibration` sets + /// `Collecting` — so the first frame was never fed and the count stayed 0. + #[test] + fn maybe_feed_calibration_advances_uncalibrated_to_collecting() { + let mut field = FieldModel::new(single_link_config()).expect("field model"); + assert_eq!(field.status(), CalibrationStatus::Uncalibrated); + assert_eq!(field.calibration_frame_count(), 0); + + // n_subcarriers defaults to 56; one single-link frame of that width. + let frame = vec![0.5_f64; 56]; + let mut history: VecDeque> = VecDeque::new(); + history.push_back(frame); + + maybe_feed_calibration(&mut field, &history); + + assert_eq!( + field.status(), + CalibrationStatus::Collecting, + "first frame must flip Uncalibrated -> Collecting" + ); + assert_eq!( + field.calibration_frame_count(), + 1, + "frame count must advance past 0" + ); + + // Subsequent frames keep accumulating while Collecting. + maybe_feed_calibration(&mut field, &history); + assert_eq!(field.calibration_frame_count(), 2); + } + + /// Regression (#1170 pattern): a real HT40 node streams 128-wide amplitude + /// frames, but the FieldModel is a 56-tone grid. Before canonicalization, + /// `feed_calibration` rejected every frame with DimensionMismatch (swallowed + /// at debug), so frame_count stayed 0 even with the deadlock fixed. The feed + /// must resample 128 → 56 and actually accumulate. + #[test] + fn maybe_feed_calibration_resamples_wide_frames_and_accumulates() { + let mut field = FieldModel::new(single_link_config()).expect("field model"); + + // 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw. + let wide = vec![0.5_f64; 128]; + let mut history: VecDeque> = VecDeque::new(); + history.push_back(wide); + + maybe_feed_calibration(&mut field, &history); + + assert_eq!( + field.status(), + CalibrationStatus::Collecting, + "128-wide frame must resample to 56 and be accepted" + ); + assert_eq!( + field.calibration_frame_count(), + 1, + "wide frame must accumulate, not be silently dropped" + ); + } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 38a543d325..fbcc42f866 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4991,6 +4991,20 @@ async fn calibration_start(State(state): State) -> Json) -> Json { let mut s = state.write().await; if let Some(ref mut fm) = s.field_model { + // Guard: finalizing before enough empty-room frames have accumulated + // is a client-side sequencing error, not a server fault. Return a + // clear, structured message (with progress) instead of a 500 so the + // caller knows to keep the room empty and poll /calibration/status. + let have = fm.calibration_frame_count(); + let need = fm.min_calibration_frames() as u64; + if have < need { + return Json(serde_json::json!({ + "success": false, + "error": "Not enough calibration frames yet — keep the room empty and poll /calibration/status until frame_count reaches the target.", + "frame_count": have, + "frames_needed": need, + })); + } let ts = chrono::Utc::now().timestamp_micros() as u64; match fm.finalize_calibration(ts, 0) { Ok(modes) => { diff --git a/v2/crates/wifi-densepose-sensing-server/src/types.rs b/v2/crates/wifi-densepose-sensing-server/src/types.rs index f4af047c57..8ac0ec7e72 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/types.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/types.rs @@ -64,6 +64,12 @@ pub const MOTION_EMA_ALPHA: f64 = 0.15; pub const BASELINE_EMA_ALPHA: f64 = 0.003; /// Number of warm-up frames before baseline subtraction kicks in. pub const BASELINE_WARMUP: u64 = 50; +/// Fraction of the learned empty-room motion baseline subtracted from the raw +/// motion score. 1.0 = full subtraction, so presence reflects motion *above* +/// the room's static ambient. Was 0.7, which left 30% of the ambient baseline +/// unsubtracted and produced permanent false-positive presence in high-multipath +/// rooms (strong RSSI → large static variance/MBP that saturate the raw score). +pub const BASELINE_SUBTRACT_FACTOR: f64 = 1.0; /// Size of the median filter window for vital signs outlier rejection. pub const VITAL_MEDIAN_WINDOW: usize = 21; diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 2653fef142..210e6d0a0d 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -449,6 +449,11 @@ impl FieldModel { .map_or(0, |ls| ls.observation_count()) } + /// Minimum frames required before `finalize_calibration` will succeed. + pub fn min_calibration_frames(&self) -> usize { + self.config.min_calibration_frames + } + /// Feed a calibration frame (one CSI observation per link during empty room). /// /// `observations` is `[n_links][n_subcarriers]` amplitude data.