Files
qtalker---/firmware/esp32p4-sensor-node
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
..

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:

  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 #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

  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:
    { .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).