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).
This commit is contained in:
281
docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md
Normal file
281
docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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.
|
||||||
Reference in New Issue
Block a user