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>
This commit is contained in:
301
firmware/esp32p4-sensor-node/README.md
Normal file
301
firmware/esp32p4-sensor-node/README.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user