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: ruview on telep-mainframe, image ruvnet/wifi-densepose. Data volume /home/levander/ruview-data:/app/data. CSI ingest: UDP 5005 must be PUBLISHED (-p 5005:5005/udp), not merely EXPOSEd. This was the blocker. Firmware: firmware/esp32-csi-node in the repo. Do NOT flash release_bins/ on a display-less S3 — see the warning below. Build with the sdkconfig.defaults.devkitc overlay. 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.py writes NVS. Prove the ingest path with no hardware: scripts/synth-csi-udp.py emits the exact 0xC511_0001 wire format. Status: parked. Node 1 live; node 2 flash FAILED (wrong USB port), unprovisioned.

State change

Before (2026-08-05)Now (2026-08-11)
ContainerSTOPPED (exit 137 from a UPS shed)Running, recreated
CSI sourcesimulatedesp32 — real hardware
Ingest portEXPOSEd only → unreachable-p 5005:5005/udp published
Persistenceno volumes at all/home/levander/ruview-data:/app/data
Exposuretelep-mainframe...:8448/ui/index.htmlruview.taild4189d.ts.net (own node)
Hardwarenone (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 only EXPOSEs 5005, which does nothing at runtime. No ESP32 could ever have reached the ingest port, no matter how it was provisioned.
  • CSI_SOURCE=esp32 (was simulated). Valid values: auto, wifi, esp32, simulated. Prefer esp32 over auto — 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.rvf appended to the run command.

HTTP 421 on any new hostname — DNS-rebinding Host-header validation

The server validates the Host header against SENSING_ALLOWED_HOSTS and returns 421 Misdirected Request for anything not listed. This bit us twice: first on the :8448 shared-serve name, then again on the new ruview.taild4189d.ts.net node. Any time you change how RuView is reached, add the new hostname to SENSING_ALLOWED_HOSTS or 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.0

So appending --model /app/data/models/model.rvf adds 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> — otherwise serve --https fails “no TailscaleVarRoot”)
    • --socket=/run/tailscale-ruview/tailscaled.sock
    • --port=0
  • tailscale --socket=... up --hostname=ruview --advertise-tags=tag:telep
  • tailscale --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 home and pdf nodes show keyexpiry: None precisely because they carry tag: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-S3

On 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() returns true, main.c therefore 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.

PortChip / VID:PIDDeviceResult
UARTCH343, 1a86:55d3/dev/ttyUSB* or /dev/ttyACM*✅ flashes reliably — the bridge drives DTR/RTS
Native USB / OTGEspressif 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

chown the models dir first or the download silently writes nothing

The container creates /home/levander/ruview-data/models root-owned. A curl as levander then reports http=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.json LoRA 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_persons is 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: 3 simultaneously.
  • 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

ScriptUse
scripts/synth-csi-udp.pyEmits 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.pyRecord raw CSI (the overnight-test path)
scripts/csi-udp-relay.pyRelay UDP CSI
scripts/collect-training-data.pyTraining-data capture
scripts/train-count.pyPerson-count training
scripts/ruview_occ_dataset.pyOccupancy 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:

  1. Run --calibrate on an empty room. Prime suspect for the pinned confidence / always-presence behaviour.
  2. Overnight record-csi-udp.py capture, look for diurnal structure — the only uncontaminatable test available.
  3. Range-check and discard the out-of-range-confidence samples before trusting any downstream consumer.
  4. Flash + provision node 2 (UART port), then --node-positions for any actual localisation.