Files
qtalker---/firmware/esp32p4-sensor-node/README.md
Indiana 348b5fc778 feat(firmware): ESP32-P4 sensor node — Workstream I core skeleton
New firmware/esp32p4-sensor-node/ ESP-IDF (C, FreeRTOS) project skeleton
per docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md's
Workstream I:

- Wi-Fi station-mode connect with exponential-backoff reconnect
  (wifi_manager.c), credentials from a gitignored main/device_config.h
  the seeker fills in (template: device_config.h.example).
- Telemetry HTTP client (telemetry_client.c) POSTing the spec's exact
  contract shape to /api/device/telemetry with a Bearer token, via
  esp_http_client + cJSON.
- BME280 I2C driver (bme280.c) with Bosch's public double-precision
  compensation formulas, using ESP-IDF's newer driver/i2c_master.h API.
- LD2410 mmWave presence driver (ld2410.c) over UART, chosen over a
  plain PIR for its distance/motion data richness — its frame-offset
  parsing is flagged as the least-certain code in the firmware.
- sensor_driver_t registry (sensor_driver.h, sensor_registry.c) so new
  sensors are a new driver file + one array line, no main-loop changes.
- README.md: build steps, manual-config walkthrough, wiring/pinouts,
  and an explicit "what's verified vs. not" section plus a real
  hardware caveat (ESP32-P4 has no integrated Wi-Fi radio).

UNVERIFIED AGAINST REAL HARDWARE per the spec's honesty-policy note —
no ESP-IDF toolchain or physical boards available in this environment.
Syntax-checked with gcc against hand-written ESP-IDF API stubs (not
committed) as a best-effort substitute for a real idf.py build.

Workstream J (RTL-SDR experimental module) is explicitly out of scope
here; firmware/esp32p4-sensor-node/components/ is left in place for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:13:44 +00:00

302 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quantumancy ESP32-P4 Sensor Node
Firmware for the paired hardware sensor node described in
[`docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md`](../../docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md)
("Workstream I — firmware, ESP-IDF C — core sensor node"). Real ESP-IDF C
(FreeRTOS-based), not Arduino, not pseudocode. Connects to the seeker's home
Wi-Fi, samples a small set of sensors, and POSTs readings to the Quantumancy
backend's `POST /api/device/telemetry` endpoint, which feeds them into the
séance's live anomaly-detection pipeline as a sixth signal source alongside
`wire`/`evp`/`radio`/`emf`.
## Honesty policy — READ THIS FIRST
> This app's whole ethos is "real signal processing on real data, and it
> says so when something is unverified."
**Nobody working on this had physical ESP32-P4 hardware, a BME280, or an
LD2410 module to flash and test against.** Everything in this directory is
real, structurally-correct ESP-IDF C, written against ESP-IDF's documented
APIs and each sensor's public datasheet/protocol documentation, and reasoned
about carefully — but it has **never been compiled with a real ESP-IDF
toolchain, never been flashed, and never talked to real hardware.** Treat
every claim below as "should work, per the docs" rather than "confirmed
working." See [What's verified vs. not](#whats-verified-vs-not) for the
specific, itemized breakdown — this mirrors the same convention
`frontend/src/lib/sdr.ts`'s `HARDWARE PASS REQUIRED` header comment uses
elsewhere in this repo.
## Directory layout
```
firmware/esp32p4-sensor-node/
├── CMakeLists.txt top-level ESP-IDF project file
├── sdkconfig.defaults seed config (idf.py generates the real sdkconfig)
├── README.md this file
├── components/ reserved for Workstream J (RTL-SDR), empty here
└── main/
├── CMakeLists.txt component registration
├── app_main.c entry point / boot sequence
├── device_config.h.example template you copy to device_config.h
├── wifi_manager.{h,c} Wi-Fi station mode connect/reconnect
├── telemetry_client.{h,c} HTTP POST task -> /api/device/telemetry
├── sensor_driver.h the sensor_driver_t registry interface
├── sensor_registry.{h,c} the concrete list of compiled-in drivers
├── bme280.{h,c} temperature/humidity/pressure over I2C
└── ld2410.{h,c} presence/distance over UART
```
## Build instructions
Requires an ESP-IDF install (v5.3 or newer — ESP32-P4 target support landed
around v5.2/5.3; this was written without a toolchain available to pin an
exact tested version, see honesty section). With `idf.py` on your `PATH`
(e.g. after sourcing ESP-IDF's `export.sh`):
```sh
cd firmware/esp32p4-sensor-node
# 1. Fill in your Wi-Fi + pairing details (see next section) — the build
# will fail on a missing #include until you do this.
cp main/device_config.h.example main/device_config.h
$EDITOR main/device_config.h
# 2. Target and build.
idf.py set-target esp32p4
idf.py build
# 3. Flash + monitor (adjust the port for your machine).
idf.py -p /dev/ttyUSB0 flash monitor
```
## Manual configuration (no provisioning UI — by design)
A full BLE/Wi-Fi-AP provisioning flow is explicitly out of scope for this
spec (see the spec's "Explicitly out of scope" section). Instead, you
hand-edit one header before building:
1. In the Quantumancy web app, sign in and create a device from your
account (name + optional sensor-type hint). The backend shows you a
**raw pairing token exactly once** — copy it immediately, it cannot be
retrieved again (same one-time-secret convention as the site's session
tokens).
2. `cp main/device_config.h.example main/device_config.h`
3. Edit `main/device_config.h` and fill in:
- `DEVICE_WIFI_SSID` / `DEVICE_WIFI_PASSWORD` — your home Wi-Fi.
- `DEVICE_BACKEND_BASE_URL` — the backend's base URL, no trailing slash.
- `DEVICE_PAIRING_TOKEN` — the raw token from step 1.
- `DEVICE_REPORT_INTERVAL_SEC` — optional, defaults to 15s.
4. `main/device_config.h` is listed in `.gitignore` — it will never be
committed. Never put real credentials in `device_config.h.example`
itself; that file is the template everyone else copies.
There is deliberately no other config path (no NVS-based captive portal, no
BLE provisioning) in this build — see the spec's scope boundary.
## What's verified vs. not
**Structurally verified** (reasoned through carefully against ESP-IDF's
documented API surface and each sensor's public protocol docs; internally
consistent; no known syntax errors or obviously-wrong API usage):
- Project skeleton (`CMakeLists.txt` × 2, `sdkconfig.defaults`,
`idf_component_register` call) follows ESP-IDF's standard project layout.
- Wi-Fi station-mode connect/reconnect (`wifi_manager.c`) follows ESP-IDF's
documented event-driven pattern (`WIFI_EVENT`/`IP_EVENT` handlers +
`EventGroupHandle_t`), extended with an exponential-backoff reconnect
timer instead of giving up after N tries.
- HTTP client (`telemetry_client.c`) builds the exact JSON shape the spec's
contract defines and POSTs it via `esp_http_client` with
`Authorization: Bearer <token>` and `Content-Type: application/json`.
- BME280 driver (`bme280.c`): register map and the double-precision
compensation formulas are transcribed from Bosch's public BME280
datasheet (rev 1.23, §4.2.2–4.2.3) — this is well-trodden, publicly
documented territory, and the formulas are checkable line-by-line against
the datasheet. Uses ESP-IDF's newer `driver/i2c_master.h` API (the
current idiomatic choice; the older `driver/i2c.h` is being phased out).
- LD2410 driver (`ld2410.c`): UART frame envelope (header/footer magic
bytes, length-prefixed payload) follows the shape consistently reported
across public LD2410 protocol write-ups. **The exact payload byte offsets
for target state / distances / energies are the single least-certain
piece of code in this entire firmware** — see the detailed note in
`ld2410_parse_payload()`. The driver defends itself with a head/tail
marker sanity check (`0xAA`/`0x55`) and silently skips anything that
doesn't match rather than reporting garbage, but that check catches
gross corruption, not subtle off-by-one offset errors.
- Sensor driver registry (`sensor_driver.h`, `sensor_registry.c`): a
`sensor_driver_t { name, init, read }` struct, a compile-time array of
them, and generic init/collect functions that `app_main.c` and
`telemetry_client.c` call without knowing which concrete sensors exist.
**NOT verified — requires real hardware bring-up:**
- `idf.py build` has never actually been run in this environment (no
ESP-IDF toolchain installed here) — there could be a typo, a missing
include, or an API signature mismatch against whatever exact ESP-IDF
version you build with that only a real compile will surface.
- I2C timing/electricals: pull-up resistor values, bus speed headroom,
cable length — none of this has been bench-tested.
- BME280 compensation formula correctness in practice: the math is
transcribed carefully, but "matches the datasheet" and "produces a
plausible number when this exact C runs on this exact silicon" are
different claims until someone compares a real reading to a reference
thermometer/barometer.
- LD2410 frame parsing, as above — verify against a logic analyzer capture
or a known-good reference implementation (e.g. the `ncmreynolds/ld2410`
or `iavorvel/MyLD2410` Arduino libraries, cross-checked) before trusting
field values.
- Wi-Fi reconnect behavior under real-world conditions (router reboot,
weak signal, captive portals) — the backoff logic is reasoned about, not
soak-tested.
- HTTP client behavior against the real backend: TLS handshake against
its actual certificate, real latency, real error responses. The
`sdkconfig.defaults` enables mbedTLS's full certificate bundle for this,
but that's untested against the live deploy.
- Timing/power: task stack sizes (`telemetry_task`'s 8192 words, etc.) are
reasonable guesses, not measured high-water-marks from a real run.
- **The ESP32-P4-has-no-integrated-Wi-Fi caveat below** — this is a real
hardware architecture question, not just an untested detail.
## Important hardware caveat: ESP32-P4 has no integrated Wi-Fi radio
The ESP32-P4 SoC (per Espressif's own published specs) has **no built-in
2.4GHz radio**. A real deployment needs one of:
- **A companion Wi-Fi chip** (e.g. ESP32-C6) wired to the P4 via SDIO or
SPI, running Espressif's "esp-hosted" firmware/driver stack. Critically,
esp-hosted presents the *same* `esp_wifi`/`esp_netif` API this firmware
already uses — so `wifi_manager.c` should not need to change, only board
wiring and `sdkconfig` (host-side esp-hosted config) would.
- **Building this same code against a Wi-Fi-native target instead**, e.g.
`idf.py set-target esp32s3` or `esp32c6`. The application code
(`wifi_manager.c`, `telemetry_client.c`, the sensor drivers) is written
against the standard API surface and doesn't reference P4-specific
peripherals for anything except I2C/UART GPIO numbers, so it should be
largely target-portable.
This wasn't in the original spec's framing but matters enough for a real
build that it's called out here explicitly, in the honesty-policy spirit —
better to flag a real hardware-architecture gap than let someone discover
it after ordering a bare P4 dev board expecting it to just join Wi-Fi.
## Wiring / pinout
### BME280 (I2C) — temperature, humidity, pressure
Chosen as the concrete default sensor per the spec ("a common,
well-documented sensor... pick this as the concrete default since no
specific part number was given").
| BME280 pin | Connects to |
|------------|---------------------------------------|
| VCC | 3V3 |
| GND | GND |
| SDA | GPIO8 (`BME280_I2C_SDA_GPIO`) |
| SCL | GPIO9 (`BME280_I2C_SCL_GPIO`) |
| CSB | VCC (selects I2C mode, not SPI) |
| SDO | GND → I2C address `0x76` (default assumed; tie to VCC + change `BME280_I2C_ADDR` for `0x77`) |
GPIO numbers are `#define`s at the top of `bme280.h` — override them there
(or via a future `idf.py menuconfig` entry) to match your actual wiring.
100kHz I2C clock by default (`BME280_I2C_CLK_HZ`); the part supports faster
modes if your wiring/pull-ups support it.
### LD2410 (UART) — presence, distance, motion
**Chosen over a plain PIR** — see the rationale in `ld2410.h`'s header
comment: the LD2410 reports moving-target and stationary-target distance
and energy separately, not just a boolean, which is richer signal for the
anomaly pipeline and better matches this app's "believable" ethos (it can
distinguish "someone crossed the room" from "the sitter shifted in their
chair" in a way a boolean PIR cannot). The tradeoff is a materially more
complex protocol than a PIR's single GPIO pin — see the honesty note in
[What's verified vs. not](#whats-verified-vs-not) about the LD2410 frame
parser being the least-certain code in this firmware. If you'd rather start
with a boolean PIR for a faster, more certain first bring-up, it fits the
same `sensor_driver_t` interface — see
[Adding a new sensor](#adding-a-new-sensor) below.
| LD2410 pin | Connects to |
|------------|----------------------------------------|
| VCC | 5V (sensor front-end runs at 5V; confirm your board revision's UART logic level before wiring directly to a 3.3V-only UART pin) |
| GND | GND |
| TX | GPIO17 (`LD2410_UART_RX_GPIO`, ESP32 RX) |
| RX | GPIO18 (`LD2410_UART_TX_GPIO`, ESP32 TX) |
Default UART settings: 256000 baud, 8N1 (module factory default), reporting
in "basic" (non-engineering) mode. GPIO numbers and baud rate are
`#define`s at the top of `ld2410.h`.
## Sensor driver registry — the extensibility pattern
`sensor_driver.h` defines:
```c
typedef struct {
char sensor_type[SENSOR_READING_TYPE_MAXLEN];
double value;
char unit[SENSOR_READING_UNIT_MAXLEN];
cJSON *metadata; // nullable; NULL serializes as {}
} sensor_reading_t;
typedef struct sensor_driver {
const char *name;
esp_err_t (*init)(void);
esp_err_t (*read)(sensor_reading_t *out, size_t max_out, size_t *out_count);
} sensor_driver_t;
```
`sensor_registry.c` holds a compile-time array of these (currently BME280
and LD2410) and two generic functions, `sensor_registry_init_all()` and
`sensor_registry_collect()`, that `app_main.c` and `telemetry_client.c`
call without ever referencing `bme280.c`/`ld2410.c` directly. One driver
failing `init()` or `read()` is logged and skipped — it doesn't take the
whole node offline.
### Adding a new sensor
1. Write `main/my_sensor.h` / `main/my_sensor.c` implementing `init()` and
`read()` matching `sensor_driver_t`'s function pointer signatures.
2. Add `"my_sensor.c"` to the `SRCS` list in `main/CMakeLists.txt`.
3. `#include "my_sensor.h"` in `sensor_registry.c` and add one line to the
`s_drivers[]` array:
```c
{ .name = "my_sensor", .init = my_sensor_init, .read = my_sensor_read },
```
Nothing in `app_main.c`, `telemetry_client.c`, or the main reporting loop's
control flow needs to change — that's the whole point of this structure per
the spec.
## Backend contract this firmware targets
From the spec (binding, see the spec file for the authoritative version):
```json
POST /api/device/telemetry
Authorization: Bearer <raw pairing token>
Content-Type: application/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": {}}
]
}
```
This firmware's BME280 driver emits `temperature`/`humidity`/`pressure`
exactly as shown; its LD2410 driver emits `presence` as a `0`/`1` boolean
in `value` with the richer distance/energy data folded into `metadata`
(`moving_distance_cm`, `moving_energy`, `stationary_distance_cm`,
`stationary_energy`, `detection_distance_cm`, `target_state`).
## Out of scope here
Per the spec: thermal camera support, a full BLE/Wi-Fi-AP provisioning UX,
on-device spectrum analysis/FFT, the RTL-SDR module (Workstream J — see
`components/README.md`), and anything on the backend/frontend side
(Workstreams G, H, K).