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>
14 KiB
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
("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 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):
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:
- 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).
cp main/device_config.h.example main/device_config.h- Edit
main/device_config.hand 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.
main/device_config.his listed in.gitignore— it will never be committed. Never put real credentials indevice_config.h.exampleitself; 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_registercall) 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_EVENThandlers +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 viaesp_http_clientwithAuthorization: Bearer <token>andContent-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 newerdriver/i2c_master.hAPI (the current idiomatic choice; the olderdriver/i2c.his 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 inld2410_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): asensor_driver_t { name, init, read }struct, a compile-time array of them, and generic init/collect functions thatapp_main.candtelemetry_client.ccall without knowing which concrete sensors exist.
NOT verified — requires real hardware bring-up:
idf.py buildhas 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/ld2410oriavorvel/MyLD2410Arduino 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.defaultsenables 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_netifAPI this firmware already uses — sowifi_manager.cshould not need to change, only board wiring andsdkconfig(host-side esp-hosted config) would. - Building this same code against a Wi-Fi-native target instead, e.g.
idf.py set-target esp32s3oresp32c6. 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 #defines 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 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 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
#defines at the top of ld2410.h.
Sensor driver registry — the extensibility pattern
sensor_driver.h defines:
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
- Write
main/my_sensor.h/main/my_sensor.cimplementinginit()andread()matchingsensor_driver_t's function pointer signatures. - Add
"my_sensor.c"to theSRCSlist inmain/CMakeLists.txt. #include "my_sensor.h"insensor_registry.cand add one line to thes_drivers[]array:{ .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):
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).