Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/fork-ghcr-sensing-server.yml
Original file line number Diff line number Diff line change
@@ -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/<owner>/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"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
40 changes: 40 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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:<tag> (Docker image)
deploy/<target>/ ← 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/<target>/` 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.)
21 changes: 21 additions & 0 deletions deploy/qnap/.env.example
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions deploy/qnap/README.md
Original file line number Diff line number Diff line change
@@ -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@<nas-ip>:
ssh -p 2222 admin@<nas-ip>
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 <nas-ip>:<port>,<nas-ip>,...
docker compose up -d # older Container Station: docker-compose up -d
```

Then open `http://<nas-ip>:3010`. Health check: `curl http://<nas-ip>: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 <nas-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".
23 changes: 23 additions & 0 deletions deploy/qnap/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Loading