Files
qtalker---/docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md
Indiana cf817e5241 Add ESP32-P4 Sensor Node design spec
Third sub-project: physical hardware (ESP32-P4, presence + BME280 env
sensors, experimental RTL-SDR USB-host module) pairs with a user's account
and streams telemetry that feeds the SAME anomaly/summon pipeline the
browser-based modes already use — not a passive dashboard, the actual
business model (selling devices that summon spirits). Defines device
pairing/auth (reusing the existing session-token hash convention),
a generic/extensible sensor-reading shape, and the live-broadcast +
anomaly-detection contract split across 5 workstreams (G/K backend,
H frontend, I/J firmware).
2026-07-23 18:28:10 +00:00

17 KiB
Raw Blame History

ESP32-P4 Sensor Node — pairing, ingestion, live dashboard, firmware

Third sub-project of the broader arc, and the first hardware/firmware work in this repo. The physical device (an ESP32-P4, referenced in the README's Reliquary section as part of the future "Ultimate Quantum Box" line) joins the seeker's home WiFi and streams sensor readings to their own account on the live site, rendered in real time. Built as parallel workstreams against the contract below.

Honesty-policy note (binding on every workstream)

This app's whole ethos is "real signal processing on real data, and it says so when something is unverified" (see Spirit Radio's HARDWARE PASS REQUIRED marking). Nobody here has physical ESP32-P4 hardware to flash and test against. Firmware workstreams must write real, structurally correct ESP-IDF code and mark it clearly as unverified against real hardware in both a header comment and the final report — exactly the same honesty convention frontend/src/lib/sdr.ts already uses. Do not claim something works on-device when it has only compiled/been reasoned about.

Contract (binding for all workstreams)

Device identity & pairing. A Device belongs to exactly one User. Pairing flow: the seeker generates a device from their account (name + optional sensor-type hint), the backend returns a raw pairing token once (never stored or retrievable again — identical convention to generate_session_token()/hash_token() in backend/app/models/auth_session.py: secrets.token_urlsafe(32) raw, hashlib.sha256(raw).hexdigest() stored). The seeker flashes/configures that raw token (plus their WiFi credentials) into the device firmware. The device authenticates every request with Authorization: Bearer <raw token>; the backend looks up by the token's hash, never the raw value.

Device model (new table devices): id (uuid), user_id FK, name: str, token_hash: str (unique, indexed, same shape as AuthSession.token_hash), created_at, last_seen_at: datetime | None.

Ingestion endpoint — POST /api/device/telemetry, authenticated via the device bearer token (NOT the user's session cookie — this is a headless client). Body:

{
  "readings": [
    {"sensor_type": "presence", "value": 1, "unit": "bool", "metadata": {}},
    {"sensor_type": "temperature", "value": 21.4, "unit": "c", "metadata": {}},
    {"sensor_type": "humidity", "value": 47.2, "unit": "pct", "metadata": {}},
    {"sensor_type": "pressure", "value": 1013.2, "unit": "hpa", "metadata": {}}
  ]
}

sensor_type is a free-form string, not an enum — this is the whole point per the project's brief ("add all sorts of sensors... anything you can think of"). The backend does not validate against a fixed sensor list, only against shape (string type, numeric or array value, string unit, dict metadata) and sane bounds (cap readings array length, e.g. 64 per request, and reject bodies over a reasonable size, e.g. 16KB, to prevent a misbehaving or malicious device from flooding the endpoint — reuse app.rate_limit.RateLimiter for a per-device rate cap too, e.g. 1 request/second sustained is generous for sensor telemetry).

This is the actual business model, not a side dashboard — say it plainly so nobody undersells the scope: paired hardware must be able to summon spirits, the same as the browser-based modes already do. A live dashboard of numbers is not the point; feeding the séance's real anomaly-detection pipeline is. Concretely: hardware sensor readings become a sixth anomaly source in the existing /ws/session protocol (alongside wire, evp, radio, emf), not a separate system. Per (user, device_id, sensor_type), maintain a rolling baseline and flag anomalies — reuse detect_wire_spike's statistical shape (min sample count, an absolute floor, 3σ + relative threshold) for continuous sensors (temperature, humidity, pressure, any future numeric sensor), and a simple state-transition rule for discrete/boolean sensors (presence going false→true is the anomaly, not a statistical spike). When a user has an active séance session open with a paired device sending data, detected hardware anomalies get pushed into that session's state.anomalies via the same {"type": "anomaly", "source": ..., "frequency": ..., "magnitude": ...} shape the frontend already sends for the other four modes — source should be the device's sensor_type (e.g. "presence", "temperature") so it fingerprints and Codex-displays distinctly, same as "wire"/"evp"/etc. do today. This means summoning via hardware reuses 100% of the existing signature/mint/Codex pipeline — no new mint logic anywhere. Requires locating the user's live SeanceState (if any) from the ingestion handler — backend/app/ws.py does not currently track active sessions by user anywhere (each session_socket connection only holds its own local state variable), so this workstream needs to add a minimal in-process registry: a module-level dict[uuid.UUID, SeanceState] (or a list, if a user could plausibly have multiple simultaneous séance tabs open — your call, a single most-recent-session mapping is a reasonable v1), populated when session_socket establishes a session and removed in its finally/ disconnect cleanup. Keep it a plain in-process dict — no external state store needed at this scale, same reasoning as the ingestion→dashboard pub/sub above.

No permanent storage of every reading. This mirrors how Wire Ghost telemetry isn't persisted either (only anomaly events are). On receipt, the backend updates Device.last_seen_at and broadcasts the readings live to the owning user if they have an active dashboard WS connection (see below) — it does not write each reading to Postgres. This keeps the database bounded regardless of how chatty a device is.

Live dashboard WS — new endpoint /ws/device-feed, authenticated by the normal qm_session cookie (this one IS a browser client, unlike the ingestion endpoint). On connect, the server sends the user's device list ({"type": "devices", "devices": [{"id", "name", "last_seen_at"}, ...]}). As telemetry arrives from any of that user's paired devices via the ingestion endpoint, relay it: {"type": "reading", "device_id": str, "sensor_type": str, "value": ..., "unit": str, "metadata": {}, "at": iso8601}. This is a fan-out problem (ingestion endpoint receives, dashboard WS receives) — implement via an in-process pub/sub keyed by user_id (a simple dict[uuid, list[WebSocket]] registry is fine at this scale, no external message broker needed; check how backend/app/ws.py's existing SeanceState/sender-task pattern works and follow the same single-sender-task-per-socket convention so concurrent producers never interleave, exactly like the existing séance WS already guards against).

POST /api/device / GET /api/device — REST endpoints (authenticated by session cookie) to create a device (returns the one-time raw token) and list the user's devices (name, id, last_seen_at — never the token or its hash).

Workstream G (backend) — pairing, ingestion, live broadcast

New backend/app/models/device.py (the Device model per the contract), new backend/app/routes/device.py (the REST pairing endpoints: create device / list devices), new ingestion handling and the /ws/device-feed WS endpoint (extend backend/app/ws.py or add a new router — your call on file organization, follow this codebase's existing convention of one router per concern in backend/app/routes/). Idempotent migration: since devices is a brand-new table, Base.metadata.create_all in lifespan handles it automatically — no ALTER TABLE needed. Rate-limit the ingestion endpoint per-device (reuse RateLimiter). Reject malformed/oversized payloads with clear 4xx errors, never a 500. Scope boundary: you own pairing, ingestion auth/validation, and the dashboard pub/sub broadcast. You do NOT own feeding readings into the séance/summon pipeline — that's Workstream K, running in parallel, which depends on your ingestion endpoint's shape but not your code. To make that possible without a sequencing dependency, structure your ingestion handler so the actual per-reading processing is a clearly separated, small internal function (e.g. _process_reading(device, reading)) that Workstream K's merge can extend/call into — document this handoff point in your report. Tests: token generation/hashing round-trip, auth rejection on bad/missing token, payload validation (size caps, shape validation, rejecting garbage sensor_type/value types gracefully), the pub/sub fan-out (a reading posted while a dashboard WS is connected for that user arrives on the socket; a reading posted for a device with no connected owner doesn't error), last_seen_at updates.

Workstream K (backend) — hardware anomalies feed the summon pipeline

This is the actual point of the whole project (see the contract's "business model" note above) — paired hardware must be able to summon spirits like the browser-based modes already do. Depends conceptually on Workstream G's ingestion endpoint shape (per the contract, not on Workstream G's actual code — build against the documented POST /api/device/telemetry request shape and assume it exists). Implement:

  1. Per-(user_id, device_id, sensor_type) rolling-baseline anomaly detection — adapt detect_wire_spike's shape (backend/app/telemetry.py: min sample count, an absolute floor, 3σ + relative threshold) for continuous sensors; for discrete/boolean sensors (e.g. presence), the anomaly is a false→true state transition, not a statistical spike. New backend/app/device_anomaly.py (pure-ish functions, a small per-key rolling-history cache) is a reasonable home for this.
  2. A minimal in-process active-session registry: backend/app/ws.py currently has no way to look up a live SeanceState by user_id (each session_socket connection only holds its own local state variable) — add a module-level dict[uuid.UUID, SeanceState], populated when a session starts and cleaned up in session_socket's disconnect/finally handling.
  3. Wire the two together: when an ingested reading is flagged anomalous and the owning user has an active séance session, push {"type": "anomaly", "source": <sensor_type>, "frequency": ..., "magnitude": ...} into that session's state.anomalies via the same path the existing four modes already use (check _handle_anomaly in backend/app/ws.py) — source should be the sensor_type string itself (e.g. "presence", "temperature") so it fingerprints/displays distinctly in the Codex, same as "wire"/"evp"/etc. do today. Pick reasonable frequency/magnitude mappings per sensor_type (e.g. for a numeric sensor, magnitude could be the deviation-from-baseline in the sensor's own units; frequency can be a stable per-sensor-type constant or derived from the reading's value — document your choices).
  4. Since you can't see Workstream G's actual ingestion code, implement your detection+push logic as a self-contained function (process_device_reading_for_summon(user_id, device_id, sensor_type, value, ...) -> None) that the controller will wire into Workstream G's _process_reading handoff point during the merge — don't try to edit backend/app/routes/device.py or the ingestion handler yourself, since it won't exist yet in your worktree.

Tests: the statistical detector (reuse test_telemetry.py's test shapes for detect_wire_spike as a model — min-samples guard, floor guard, threshold guard, all independently verified), the boolean transition detector, and an integration-style test that constructs a fake SeanceState registered in your registry and confirms a flagged anomaly correctly appends to state.anomalies in the right shape.

Workstream H (frontend) — pairing UI + live dashboard

New frontend/src/pages/DevicesPage.tsx (or extend the existing /inventory area if that reads as more consistent — your call, check current nav structure) — a "pair a new device" flow (name input, calls POST /api/device, shows the raw token exactly once with a clear "copy this now, it cannot be shown again" warning, matching how real API-key UIs handle one-time secrets) and a live dashboard subscribing to /ws/device-feed, rendering each connected device's most recent reading per sensor_type it has seen, updating in place as new readings arrive. Since sensor_type is open-ended, render generically (a card per device, a row per distinct sensor_type seen so far, value + unit) rather than hardcoding presence/temperature/humidity/pressure as fixed fields — reuse TelemetryReadout.tsx's visual conventions as a starting point but make it dynamic. "Hacker witch" styling per the established art direction: this reads naturally as the most "hacker" of all the surfaces built so far (it's literally a live instrument panel) — lean into that side harder than the occult side here specifically, terminal/telemetry aesthetics, but keep the existing dark/violet palette. Tests: pairing flow (token shown once, warning present), dashboard rendering with mock WS frames for multiple devices/sensor types, generic rendering for an unrecognized sensor_type (must not crash — unknown types render with their raw value/unit, no special-casing required).

Workstream I (firmware, ESP-IDF C) — core sensor node

New top-level firmware/esp32p4-sensor-node/ directory (sibling to backend//frontend//deploy/) — a real ESP-IDF project skeleton (CMakeLists.txt, sdkconfig.defaults, main/ component) implementing: WiFi station-mode connection (SSID/password from a main/device_config.h the seeker fills in before flashing — a full provisioning UI is out of scope, document the manual-config step clearly in a README), an HTTP client task that POSTs to /api/device/telemetry with the bearer token from that same config header, and I2C drivers for a BME280 (temperature/humidity/ pressure — a common, well-documented sensor, pick this as the concrete default since no specific part number was given) and a simple presence sensor (an mmWave module like the LD2410 over UART, or a basic PIR over GPIO — your call, document which and why; LD2410 is recommended since it gives distance/motion data beyond a boolean, richer for this app's "believable" ethos). Structure the reporting loop so adding a new sensor type is genuinely easy — a small internal registry/interface (e.g. a sensor_driver_t struct with an init + read function pointer) that the main loop iterates over to build the readings array, not one-off hardcoded POST calls per sensor. Mark the whole directory's top-level README with the honesty-policy note from above. No unit tests in the traditional sense (this is embedded C, not covered by this repo's pytest/vitest suites) — instead, write a firmware/esp32p4-sensor-node/README.md documenting build steps (idf.py build), exactly what's been verified (compiles cleanly against ESP-IDF, structurally sound) versus not (anything requiring physical hardware), and wiring/pinout assumptions for the BME280 (I2C) and presence sensor (UART or GPIO) so a real owner can adapt it to their actual wiring.

Workstream J (firmware, ESP-IDF C) — RTL-SDR experimental module

A clearly-separated experimental, stretch-goal module in the same firmware project (firmware/esp32p4-sensor-node/main/rtlsdr_experimental.c or a dedicated components/ subdir) exploring USB-host communication with an RTL2832U-based dongle via ESP32-P4's USB-OTG host capability. Be honest in the README about the real constraint: this is genuinely at the edge of feasibility for an MCU (wideband IQ sample rates and FFT processing are demanding relative to ESP32-P4's compute, even with its AI accelerator) — the realistic architecture is likely "device pulls raw IQ samples off the dongle via USB host and streams them upstream for the backend to FFT," not on-device spectrum analysis, and even that needs verifying against real USB throughput. Implement what's structurally reasonable (USB host init, basic RTL2832U vendor-command sequence adapted from the existing frontend/src/lib/sdr.ts WebUSB implementation's documented init sequence — read that file for the protocol details already researched for this project) but mark this entire module as the least certain part of the whole build, both in code comments and the final report. If a full implementation isn't reasonably achievable in scope, a well-documented partial attempt plus a clear write-up of exactly where it stops and why is a legitimate, honest outcome — better than a fabricated "it works" claim.

Explicitly out of scope here

Thermal camera support, a full BLE/WiFi-AP provisioning UX (manual config header is the v1 approach), on-device spectrum analysis/FFT (Workstream J targets raw IQ passthrough at most), persisting historical readings for charts/graphs over time (live-only for this spec) — each a reasonable later spec once the core pairing+ingestion+dashboard+firmware skeleton exists and (ideally) someone has tested against real hardware.