RuView (WiFi-CSI presence sensing, github ruvnet/ruview) is no longer blocked on hardware — an ESP32-S3 DevKitC-1 is flashed, provisioned and deployed, and real CSI is flowing end-to-end into the ruview container on telep-mainframe, now on its own tailnet node https://ruview.taild4189d.ts.net. This supersedes the “STOPPED, blocked on hardware” state recorded in RuView (WiFi-CSI through-wall sensing) — STOPPED, blocked on hardware. Presence / motion / RSSI / variance are real and responsive; person-counting is not trustworthy, --calibrate has never been run, and no valid controlled test has been performed. Read the honesty section before believing any number this system prints.
For Agents — Quick Reference
URL:
https://ruview.taild4189d.ts.net(no port — dedicated node, per tailnet-service-exposure-convention). Container:ruviewon telep-mainframe, imageruvnet/wifi-densepose. Data volume/home/levander/ruview-data:/app/data. CSI ingest: UDP 5005 must be PUBLISHED (-p 5005:5005/udp), not merelyEXPOSEd. This was the blocker. Firmware:firmware/esp32-csi-nodein the repo. Do NOT flashrelease_bins/on a display-less S3 — see the warning below. Build with thesdkconfig.defaults.devkitcoverlay. Flash port: the UART USB-C port (CH343,1a86:55d3). The native USB/OTG port (303a:*) fails — this and the other reusable ESP32 rules are consolidated in esp32. Re-provision WiFi without reflashing:provision.pywrites NVS. Prove the ingest path with no hardware:scripts/synth-csi-udp.pyemits the exact0xC511_0001wire format. Status: parked. Node 1 live; node 2 flash FAILED (wrong USB port), unprovisioned.
State change
| Before (2026-08-05) | Now (2026-08-11) | |
|---|---|---|
| Container | STOPPED (exit 137 from a UPS shed) | Running, recreated |
| CSI source | simulated | esp32 — real hardware |
| Ingest port | EXPOSEd only → unreachable | -p 5005:5005/udp published |
| Persistence | no volumes at all | /home/levander/ruview-data:/app/data |
| Exposure | telep-mainframe...:8448/ui/index.html | ruview.taild4189d.ts.net (own node) |
| Hardware | none (nRF has no WiFi CSI) | ESP32-S3 DevKitC-1, yield=33–37 pps |
The recreate was safe because the old container had no volumes — the only state was /app/data/session-secret (16 K, regenerable).
Container — the recreate and why each flag is there
Ports cannot be added to a running container, so publishing UDP 5005 required a full recreate. That was the occasion to fix everything else at once.
-p 5005:5005/udp— the key fix. The Dockerfile onlyEXPOSEs 5005, which does nothing at runtime. No ESP32 could ever have reached the ingest port, no matter how it was provisioned.CSI_SOURCE=esp32(wassimulated). Valid values:auto,wifi,esp32,simulated. Preferesp32overauto— it fails loud instead of silently faking. Upstream issue #937 removed the silent synthetic fallback, and you want that behaviour.SENSING_ALLOWED_HOSTS=...— the fix for HTTP 421. See the warning below.-v /home/levander/ruview-data:/app/data— new, so models and recordings survive future recreates.--model /app/data/models/model.rvfappended to the run command.
HTTP 421 on any new hostname — DNS-rebinding Host-header validation
The server validates the
Hostheader againstSENSING_ALLOWED_HOSTSand returns 421 Misdirected Request for anything not listed. This bit us twice: first on the:8448shared-serve name, then again on the newruview.taild4189d.ts.netnode. Any time you change how RuView is reached, add the new hostname toSENSING_ALLOWED_HOSTSor the UI is dead on arrival with a status code that looks like a proxy fault.
Appending flags is safe — the entrypoint PREPENDS its defaults
Whenever the first argument starts with
-, the entrypoint prepends the binary plus its defaults:--source $CSI_SOURCE --tick-ms 100 --ui-path /app/ui --http-port 3000 --ws-port 3001 --bind-addr 0.0.0.0So appending
--model /app/data/models/model.rvfadds the model without losing the ports or the bind address. You do not need to restate them.
Tailnet node
Moved off the shared telep-mainframe:8448 serve port onto a dedicated node, matching the knowledgebase pattern in tailnet-service-exposure-convention — a second tailscaled in userspace networking:
- Unit
/etc/systemd/system/tailscaled-ruview.service--statedir=/var/lib/tailscale-ruview(statedir, not--state=<file>— otherwiseserve --httpsfails “no TailscaleVarRoot”)--socket=/run/tailscale-ruview/tailscaled.sock--port=0
tailscale --socket=... up --hostname=ruview --advertise-tags=tag:teleptailscale --socket=... serve --bg 3000
The tag is what stops the node silently vanishing in ~6 months
Untagged, user-owned tailnet nodes get key expiry. A service node that expires drops off the tailnet with no warning and no obvious cause. The existing
homeandkeyexpiry: Noneprecisely because they carrytag:telep.
Verified — the tag took. Node status:
host: ruview
tags: ['tag:telep']
keyexpiry: None
expired: None
user: ruview.taild4189d.ts.net
The node is owned by the tag, not by ledererandras2004@, and has no key expiry.
Note that this used --advertise-tags=tag:telep, whereas tailnet-service-exposure-convention prescribes a reusable auth key minted with tag:telep and warns against --advertise-tags. That warning is that it can be rejected — it validates against the authenticating user’s tagOwners — and here it wasn’t, because the user owns tag:telep. Prefer the auth-key route for future nodes anyway: it doesn’t depend on who is authenticating, so it can’t fail that way.
Firmware — the trap that silently kills CSI yield
Do NOT flash the prebuilt
release_bins/on a display-less ESP32-S3On a WROOM-1 / DevKitC-1 (no display, no TCA9554, floating QSPI pins) the ADR-045 runtime panel probe false-positives: the SH8601 init reports success,
display_is_active()returnstrue,main.ctherefore skips the RuView#893 MGMT+DATA promiscuous upgrade, and CSI yield collapses to 0 pps with no error and no obvious cause. The prebuilts are also 2 versions stale (0.6.7 vs repo 0.8.4).
Fix: build with the sdkconfig.defaults.devkitc overlay. It is a single line —
# CONFIG_DISPLAY_ENABLE is not set
— which makes has_display a compile-time constant false, so the runtime probe never runs.
Build (Docker is the only reliable method):
docker run --rm -v "$PWD/firmware/esp32-csi-node:/project" -w /project espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && \
idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && \
idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"Verified on real hardware: yield=33–37 pps.
Flashing gotchas
The board has two USB-C ports and only one of them works.
| Port | Chip / VID:PID | Device | Result |
|---|---|---|---|
| UART | CH343, 1a86:55d3 | /dev/ttyUSB* or /dev/ttyACM* | ✅ flashes reliably — the bridge drives DTR/RTS |
| Native USB / OTG | Espressif 303a:* | — | ❌ No serial data received |
The native-USB failure happened on a board already running firmware, and with both --before default_reset and --before usb_reset. Either use the UART port, or manually enter download mode: hold BOOT, tap RESET, release BOOT.
Flash from Docker with --device /dev/ttyACM0. This also sidesteps a permissions problem: levander is in plugdev but not dialout.
Provisioning
provision.py writes NVS, so changing WiFi credentials or the target needs no reflash:
provision.py --port <dev> --ssid <ssid> --password <pw> \
--target-ip 192.168.1.123 --target-port 5005 --node-id N
Also supports --tdm-slot / --tdm-total for multi-node time-division. The server logs a Multistatic fusion guard: 60000 µs hard / 20000 µs soft. The firmware already runs ESP-NOW leader election for time sync — visible in the serial log as c6_espnow: tx#851 leader=1.
Placement — measurable effect, counter-intuitive direction
Moving the node further from the AP improved results: RSSI −26 → −43/−44 dBm took confidence off a pinned 0.50 up to 0.58–0.61.
Too close to the AP means the direct path dominates and a human body is only a small fractional perturbation of the channel. Guidance:
- Target −40 to −60 dBm node↔AP.
- Torso height, rigidly mounted (a node that moves is indistinguishable from a room that moves).
- Put the area of interest ON the AP↔node line — that link is the entire sensor.
Model
ruvnet/wifi-densepose-pretrained on HuggingFace.
# download into /home/levander/ruview-data/models
--convert-model model.safetensors --convert-out model.rvf
# then load with --model /app/data/models/model.rvf
chownthe models dir first or the download silently writes nothingThe container creates
/home/levander/ruview-data/modelsroot-owned. Acurlaslevanderthen reportshttp=200 size=0— success status, zero bytes. Fix ownership before downloading.
Be honest about the scale of this model
48 KB of weights, 2048 LoRA parameters, 12 minutes of training. The headline “82.3%” is temporal-triplet accuracy — a self-supervised representation metric — not presence accuracy. The authors themselves retracted an earlier “100% presence” figure after finding it had been measured on a single-class recording. The shipped
node-1.json/node-2.jsonLoRA adapters encode the author’s environment; per-node adapters are environment-specific and should be treated as suspect here.
What actually works, and what does not
Real and responsive: presence, motion, RSSI, variance. The plumbing is genuinely working end-to-end on real hardware.
Not trustworthy:
estimated_personsis wrong. It read 1 when there were 2 people, then 2 and 3 later, at ~0.5 confidence throughout.- Outputs can be internally contradictory. One sample read
motion_level: absent+presence: true+estimated_persons: 3simultaneously. - With ONE node there is NO localisation. The 3D blob in the UI is a rendering of feature magnitudes, not a position measurement — the UI’s own text says a single node gives presence/breathing only. Real position needs multiple nodes plus
--node-positions.
Malformed samples with out-of-range confidence — would false-trigger any alarm built on this
Every ~20–40 readings a malformed sample appears with confidence ABOVE 1.0 (observed 4.79 and 12.96) and an RSSI different from the steady value. Suspicion: the priority / vital-signs channel carrying a different schema through the same parser. Do not build an alarm on this feed without range-checking and discarding out-of-range samples — see 2026-07-17-intruder-alarm for the alarm this would otherwise be a tempting input to.
--calibrate has never been run — this is the prime suspect
Variance never drops below ~200 in any condition, which is the signature of a system with no empty-room baseline: everything sits above threshold, so everything reads as presence. --calibrate requires an empty room and has not been run once. This is the recommended next step and plausibly explains the pinned confidence and the over-eager presence.
No valid test has been performed
Three walk-tests were run and all three were contaminated — hardware being plugged in during the test, and a second person moving during the supposed “still” control periods.
Recommended alternative: an overnight recording via scripts/record-csi-udp.py, then look for diurnal structure (sleep vs. movement). It needs nobody’s cooperation, so it cannot be contaminated by the tester.
Repo tooling worth knowing
| Script | Use |
|---|---|
scripts/synth-csi-udp.py | Emits the exact 0xC511_0001 ESP32 wire format — proves the whole ingest path with no hardware. Used successfully before the board arrived. |
scripts/record-csi-udp.py | Record raw CSI (the overnight-test path) |
scripts/csi-udp-relay.py | Relay UDP CSI |
scripts/collect-training-data.py | Training-data capture |
scripts/train-count.py | Person-count training |
scripts/ruview_occ_dataset.py | Occupancy dataset handling |
Server flags: --calibrate, --node-positions, --train, --embed, --build-index (env|activity|temporal|person), --convert-model, --model.
Status and next steps
Parked — next project is servo control, not this.
- Node 1: flashed, provisioned, deployed, feeding real CSI.
- Node 2: flash FAILED (wrong USB port used), unprovisioned. Reflash via the UART port.
Ordered next steps when resumed:
- Run
--calibrateon an empty room. Prime suspect for the pinned confidence / always-presence behaviour. - Overnight
record-csi-udp.pycapture, look for diurnal structure — the only uncontaminatable test available. - Range-check and discard the out-of-range-confidence samples before trusting any downstream consumer.
- Flash + provision node 2 (UART port), then
--node-positionsfor any actual localisation.
Related
- esp32 — cross-project ESP32 reference; carries the USB-port / Docker-flash /
dialoutlessons from this note as general rules, and cross-references the display-probe trap above - RuView (WiFi-CSI through-wall sensing) — STOPPED, blocked on hardware — the parked state this note supersedes
- tailnet-service-exposure-convention — the dedicated-node pattern followed here, and the
--advertise-tagscaveat - telep-mainframe — the host
- 2026-07-17-intruder-alarm — the existing presence/alarm system RuView would eventually feed; note the malformed-sample warning before wiring anything
- 2026-08-08-ups-shed-restore-hardening — the UPS shed that exit-137’d the original
ruviewcontainer