diff --git a/.gitignore b/.gitignore index bca5c0a..70649fd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,11 @@ frontend/vite.config.js frontend/vite.config.d.ts backend/voices/ backend/data/ + +# ESP-IDF firmware (firmware/esp32p4-sensor-node/) +firmware/*/build/ +firmware/*/sdkconfig +firmware/*/sdkconfig.old +firmware/*/managed_components/ +firmware/*/dependencies.lock +firmware/*/main/device_config.h diff --git a/firmware/esp32p4-sensor-node/CMakeLists.txt b/firmware/esp32p4-sensor-node/CMakeLists.txt new file mode 100644 index 0000000..90cee4a --- /dev/null +++ b/firmware/esp32p4-sensor-node/CMakeLists.txt @@ -0,0 +1,16 @@ +# Quantumancy ESP32-P4 Sensor Node — top-level ESP-IDF project file. +# +# Standard ESP-IDF project layout: this file just pulls in the IDF build +# system and declares the project; all real component logic lives under +# main/ (and, for the experimental RTL-SDR stretch goal, components/). +# +# Build with (ESP-IDF v5.3+ toolchain required for esp32p4 target support): +# idf.py set-target esp32p4 +# idf.py build +# +# UNVERIFIED AGAINST REAL HARDWARE — see README.md's honesty-policy section. +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(esp32p4_sensor_node) diff --git a/firmware/esp32p4-sensor-node/README.md b/firmware/esp32p4-sensor-node/README.md new file mode 100644 index 0000000..c18a67c --- /dev/null +++ b/firmware/esp32p4-sensor-node/README.md @@ -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 ` 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 +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). diff --git a/firmware/esp32p4-sensor-node/components/README.md b/firmware/esp32p4-sensor-node/components/README.md new file mode 100644 index 0000000..a573c7a --- /dev/null +++ b/firmware/esp32p4-sensor-node/components/README.md @@ -0,0 +1,11 @@ +# components/ + +Reserved for the RTL-SDR experimental module (Workstream J in +`docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md`) — USB-host +communication with an RTL2832U-based dongle via the ESP32-P4's USB-OTG host +capability, as either a dedicated ESP-IDF component here or a single +`main/rtlsdr_experimental.c`. + +Deliberately empty as of Workstream I (this directory's sibling `main/` +component covers the core sensor node only — WiFi, telemetry HTTP client, +BME280, LD2410). Not implemented here; out of scope for Workstream I. diff --git a/firmware/esp32p4-sensor-node/main/CMakeLists.txt b/firmware/esp32p4-sensor-node/main/CMakeLists.txt new file mode 100644 index 0000000..d8722ed --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/CMakeLists.txt @@ -0,0 +1,37 @@ +# Quantumancy sensor node — main component. +# +# device_config.h is intentionally NOT listed as a source: it's a header the +# seeker generates locally (see device_config.h.example + README.md) and is +# gitignored. If it's missing, the build will fail on the #include in +# app_main.c with a clear "file not found" — that's deliberate, it's the +# signal to go copy the example file and fill it in. + +idf_component_register( + SRCS + "app_main.c" + "wifi_manager.c" + "telemetry_client.c" + "sensor_registry.c" + "bme280.c" + "ld2410.c" + INCLUDE_DIRS + "." + REQUIRES + esp_wifi + esp_netif + esp_event + nvs_flash + esp_http_client + driver + json + esp_timer + log +) + +# Note (unverified): recent ESP-IDF versions (v5.3+) split the old +# monolithic "driver" component into per-peripheral components +# (esp_driver_i2c, esp_driver_uart, ...); "driver" is kept as a +# backward-compatible umbrella that still pulls those in, which is why a +# plain REQUIRES driver is used above. If a real build against your exact +# IDF version complains it can't find driver/i2c_master.h or driver/uart.h, +# add esp_driver_i2c / esp_driver_uart explicitly to REQUIRES. diff --git a/firmware/esp32p4-sensor-node/main/app_main.c b/firmware/esp32p4-sensor-node/main/app_main.c new file mode 100644 index 0000000..8cf3b5f --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/app_main.c @@ -0,0 +1,51 @@ +// Quantumancy ESP32-P4 Sensor Node — entry point. +// +// UNVERIFIED AGAINST REAL HARDWARE. Nobody working on this workstream has a +// physical ESP32-P4 (or the BME280 / LD2410 modules) to flash and test +// against. This firmware is real, structurally-sound ESP-IDF C, reasoned +// about carefully against ESP-IDF's documented APIs and the public +// datasheets/protocol docs for each sensor -- but "compiles and reads +// correctly" is where the verification stops. See README.md's "What's +// verified vs. not" section before treating any of this as field-tested. +// +// Boot sequence: bring up Wi-Fi station mode (device_config.h credentials), +// wait (briefly, non-fatally) for an initial connection, initialize every +// registered sensor driver, then hand off to the telemetry task, which +// periodically samples the sensor registry and POSTs the results to the +// backend. Wi-Fi reconnection and per-cycle "are we online" checks happen +// independently after this, so a boot-time Wi-Fi hiccup doesn't wedge the +// device -- it just starts reporting once the connection comes up. + +#include "wifi_manager.h" +#include "sensor_registry.h" +#include "telemetry_client.h" +#include "esp_err.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "app_main"; + +// How long to wait at boot for the first Wi-Fi connection before giving up +// on blocking and handing off to the telemetry task anyway (which will +// simply skip cycles until wifi_manager's own retry logic connects). +#define BOOT_WIFI_WAIT_MS 20000 + +void app_main(void) { + ESP_LOGI(TAG, "Quantumancy sensor node starting"); + + ESP_ERROR_CHECK(wifi_manager_start()); + + esp_err_t err = wifi_manager_wait_connected(pdMS_TO_TICKS(BOOT_WIFI_WAIT_MS)); + if (err == ESP_OK) { + ESP_LOGI(TAG, "Wi-Fi connected at boot"); + } else { + ESP_LOGW(TAG, "Wi-Fi not connected within %d ms at boot -- continuing anyway, " + "wifi_manager will keep retrying in the background", BOOT_WIFI_WAIT_MS); + } + + sensor_registry_init_all(); + telemetry_client_start_task(); + + ESP_LOGI(TAG, "startup complete, telemetry task running"); +} diff --git a/firmware/esp32p4-sensor-node/main/bme280.c b/firmware/esp32p4-sensor-node/main/bme280.c new file mode 100644 index 0000000..d8dc8fa --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/bme280.c @@ -0,0 +1,301 @@ +// Bosch BME280 driver — see bme280.h for wiring and honesty notes. +// +// UNVERIFIED AGAINST REAL HARDWARE: this has been written against the public +// BME280 datasheet (Bosch Sensortec, document rev 1.23) and ESP-IDF's +// documented `driver/i2c_master.h` API surface, and reasoned about carefully, +// but never compiled with a real ESP-IDF toolchain nor run against a real +// sensor. Register addresses, the calibration-word packing, and the +// compensation formulas below are transcribed as directly as possible from +// the datasheet's section 4.2.2 (register map) and 4.2.3 (double-precision +// compensation formula reference implementation) to minimize transcription +// risk, but a real bring-up should sanity-check first readings against a +// known-good reference (e.g. compare to a household thermometer/barometer). + +#include +#include +#include +#include "bme280.h" +#include "driver/i2c_master.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "bme280"; + +// --- Register map (BME280 datasheet section 4.2.2) ------------------------- +#define REG_CHIP_ID 0xD0 +#define REG_RESET 0xE0 +#define REG_CTRL_HUM 0xF2 +#define REG_STATUS 0xF3 +#define REG_CTRL_MEAS 0xF4 +#define REG_CONFIG 0xF5 +#define REG_PRESS_MSB 0xF7 // press(3) + temp(3) + hum(2) = 8 bytes, burst-read from here +#define REG_CALIB00 0x88 // dig_T1..dig_P9, 26 bytes: 0x88-0xA1 +#define REG_CALIB_H1 0xA1 // dig_H1, 1 byte +#define REG_CALIB26 0xE1 // dig_H2..dig_H6, 7 bytes: 0xE1-0xE7 + +#define CHIP_ID_EXPECTED 0x60 +#define RESET_MAGIC 0xB6 + +#define STATUS_MEASURING_BIT 0x08 + +typedef struct { + uint16_t dig_T1; + int16_t dig_T2; + int16_t dig_T3; + uint16_t dig_P1; + int16_t dig_P2; + int16_t dig_P3; + int16_t dig_P4; + int16_t dig_P5; + int16_t dig_P6; + int16_t dig_P7; + int16_t dig_P8; + int16_t dig_P9; + uint8_t dig_H1; + int16_t dig_H2; + uint8_t dig_H3; + int16_t dig_H4; + int16_t dig_H5; + int8_t dig_H6; +} bme280_calib_t; + +static i2c_master_bus_handle_t s_bus = NULL; +static i2c_master_dev_handle_t s_dev = NULL; +static bme280_calib_t s_calib; +static bool s_ready = false; + +static esp_err_t write_reg(uint8_t reg, uint8_t val) { + uint8_t buf[2] = { reg, val }; + return i2c_master_transmit(s_dev, buf, sizeof(buf), 1000 /* ms */); +} + +static esp_err_t read_regs(uint8_t reg, uint8_t *out, size_t len) { + return i2c_master_transmit_receive(s_dev, ®, 1, out, len, 1000 /* ms */); +} + +static int16_t s16(uint8_t lsb, uint8_t msb) { + return (int16_t)((uint16_t)msb << 8 | lsb); +} +static uint16_t u16(uint8_t lsb, uint8_t msb) { + return (uint16_t)((uint16_t)msb << 8 | lsb); +} + +static esp_err_t read_calibration(void) { + uint8_t buf1[26]; // 0x88..0xA1 + uint8_t h1; + uint8_t buf2[7]; // 0xE1..0xE7 + + esp_err_t err = read_regs(REG_CALIB00, buf1, sizeof(buf1)); + if (err != ESP_OK) return err; + err = read_regs(REG_CALIB_H1, &h1, 1); + if (err != ESP_OK) return err; + err = read_regs(REG_CALIB26, buf2, sizeof(buf2)); + if (err != ESP_OK) return err; + + s_calib.dig_T1 = u16(buf1[0], buf1[1]); + s_calib.dig_T2 = s16(buf1[2], buf1[3]); + s_calib.dig_T3 = s16(buf1[4], buf1[5]); + s_calib.dig_P1 = u16(buf1[6], buf1[7]); + s_calib.dig_P2 = s16(buf1[8], buf1[9]); + s_calib.dig_P3 = s16(buf1[10], buf1[11]); + s_calib.dig_P4 = s16(buf1[12], buf1[13]); + s_calib.dig_P5 = s16(buf1[14], buf1[15]); + s_calib.dig_P6 = s16(buf1[16], buf1[17]); + s_calib.dig_P7 = s16(buf1[18], buf1[19]); + s_calib.dig_P8 = s16(buf1[20], buf1[21]); + s_calib.dig_P9 = s16(buf1[22], buf1[23]); + // buf1[24] is reserved (0xA0), buf1[25] would be dig_H1 duplicate on some + // parts — we read dig_H1 explicitly from 0xA1 above instead of relying + // on that, to match the datasheet's documented address exactly. + s_calib.dig_H1 = h1; + + // dig_H4/dig_H5 have an odd 12-bit packing across 3 bytes (datasheet + // 4.2.2, table 16): + // dig_H4 = (E4[11:4] << 4) | E5[3:0] + // dig_H5 = (E6[11:4] << 4) | (E5[7:4]) + uint8_t e1 = buf2[0]; // 0xE1 -> dig_H2 lsb + uint8_t e2 = buf2[1]; // 0xE2 -> dig_H2 msb + uint8_t e3 = buf2[2]; // 0xE3 -> dig_H3 + uint8_t e4 = buf2[3]; // 0xE4 + uint8_t e5 = buf2[4]; // 0xE5 + uint8_t e6 = buf2[5]; // 0xE6 + uint8_t e7 = buf2[6]; // 0xE7 -> dig_H6 + + s_calib.dig_H2 = s16(e1, e2); + s_calib.dig_H3 = e3; + s_calib.dig_H4 = (int16_t)(((int8_t)e4 << 4) | (e5 & 0x0F)); + s_calib.dig_H5 = (int16_t)(((int8_t)e6 << 4) | (e5 >> 4)); + s_calib.dig_H6 = (int8_t)e7; + + return ESP_OK; +} + +esp_err_t bme280_init(void) { + i2c_master_bus_config_t bus_cfg = { + .i2c_port = BME280_I2C_PORT, + .sda_io_num = BME280_I2C_SDA_GPIO, + .scl_io_num = BME280_I2C_SCL_GPIO, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + esp_err_t err = i2c_new_master_bus(&bus_cfg, &s_bus); + if (err != ESP_OK) { + ESP_LOGE(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err)); + return err; + } + + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = BME280_I2C_ADDR, + .scl_speed_hz = BME280_I2C_CLK_HZ, + }; + err = i2c_master_bus_add_device(s_bus, &dev_cfg, &s_dev); + if (err != ESP_OK) { + ESP_LOGE(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + return err; + } + + uint8_t chip_id = 0; + err = read_regs(REG_CHIP_ID, &chip_id, 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "chip id read failed: %s", esp_err_to_name(err)); + return err; + } + if (chip_id != CHIP_ID_EXPECTED) { + ESP_LOGW(TAG, "unexpected chip id 0x%02x (want 0x%02x) -- wrong wiring/address, or a BMP280 (no humidity)?", chip_id, CHIP_ID_EXPECTED); + // Don't hard-fail: a BMP280 (temp+pressure only, same register map + // minus humidity) would also land here and can still usefully report + // two of the three readings. We proceed and let real data speak. + } + + err = write_reg(REG_RESET, RESET_MAGIC); + if (err != ESP_OK) return err; + vTaskDelay(pdMS_TO_TICKS(10)); // datasheet: allow >= 2ms after reset + + err = read_calibration(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "calibration read failed: %s", esp_err_to_name(err)); + return err; + } + + // Humidity oversampling x1. Must be written before ctrl_meas for the + // change to take effect (datasheet 5.4.3). + err = write_reg(REG_CTRL_HUM, 0x01); + if (err != ESP_OK) return err; + + s_ready = true; + ESP_LOGI(TAG, "BME280 init ok (chip id 0x%02x)", chip_id); + return ESP_OK; +} + +// Bosch datasheet 4.2.3 double-precision reference compensation formulas, +// transcribed near-verbatim (variable names kept close to the original so +// it's checkable against the datasheet PDF side-by-side). + +static double compensate_temperature(int32_t adc_T, double *out_t_fine) { + double var1 = (((double)adc_T) / 16384.0 - ((double)s_calib.dig_T1) / 1024.0) * ((double)s_calib.dig_T2); + double var2 = ((((double)adc_T) / 131072.0 - ((double)s_calib.dig_T1) / 8192.0) * + (((double)adc_T) / 131072.0 - ((double)s_calib.dig_T1) / 8192.0)) * ((double)s_calib.dig_T3); + *out_t_fine = var1 + var2; + return (var1 + var2) / 5120.0; // degrees C +} + +static double compensate_pressure(int32_t adc_P, double t_fine) { + double var1 = (t_fine / 2.0) - 64000.0; + double var2 = var1 * var1 * ((double)s_calib.dig_P6) / 32768.0; + var2 = var2 + var1 * ((double)s_calib.dig_P5) * 2.0; + var2 = (var2 / 4.0) + (((double)s_calib.dig_P4) * 65536.0); + var1 = (((double)s_calib.dig_P3) * var1 * var1 / 524288.0 + ((double)s_calib.dig_P2) * var1) / 524288.0; + var1 = (1.0 + var1 / 32768.0) * ((double)s_calib.dig_P1); + if (var1 == 0.0) { + return 0.0; // avoid divide-by-zero per datasheet's own guard + } + double p = 1048576.0 - (double)adc_P; + p = (p - (var2 / 4096.0)) * 6250.0 / var1; + var1 = ((double)s_calib.dig_P9) * p * p / 2147483648.0; + var2 = p * ((double)s_calib.dig_P8) / 32768.0; + p = p + (var1 + var2 + ((double)s_calib.dig_P7)) / 16.0; + return p; // Pa +} + +static double compensate_humidity(int32_t adc_H, double t_fine) { + double var_h = (t_fine - 76800.0); + var_h = (adc_H - (((double)s_calib.dig_H4) * 64.0 + ((double)s_calib.dig_H5) / 16384.0 * var_h)) * + (((double)s_calib.dig_H2) / 65536.0 * (1.0 + ((double)s_calib.dig_H6) / 67108864.0 * var_h * + (1.0 + ((double)s_calib.dig_H3) / 67108864.0 * var_h))); + var_h = var_h * (1.0 - ((double)s_calib.dig_H1) * var_h / 524288.0); + if (var_h > 100.0) var_h = 100.0; + if (var_h < 0.0) var_h = 0.0; + return var_h; // %RH +} + +esp_err_t bme280_read(sensor_reading_t *out, size_t max_out, size_t *out_count) { + *out_count = 0; + if (!s_ready) { + return ESP_ERR_INVALID_STATE; + } + if (max_out < 3) { + return ESP_ERR_NO_MEM; + } + + // Forced mode: osrs_t=1 (001), osrs_p=1 (001), mode=forced (01). + // ctrl_meas = 0b001_001_01 = 0x25 + esp_err_t err = write_reg(REG_CTRL_MEAS, 0x25); + if (err != ESP_OK) return err; + + // Poll status until the "measuring" bit clears, with a hard cap so a + // wedged bus/sensor can't hang the telemetry task forever. + for (int attempt = 0; attempt < 20; attempt++) { + uint8_t status = 0; + err = read_regs(REG_STATUS, &status, 1); + if (err != ESP_OK) return err; + if ((status & STATUS_MEASURING_BIT) == 0) { + break; + } + vTaskDelay(pdMS_TO_TICKS(5)); + if (attempt == 19) { + ESP_LOGW(TAG, "measurement did not complete in time"); + return ESP_ERR_TIMEOUT; + } + } + + uint8_t raw[8]; + err = read_regs(REG_PRESS_MSB, raw, sizeof(raw)); + if (err != ESP_OK) return err; + + int32_t adc_P = ((int32_t)raw[0] << 12) | ((int32_t)raw[1] << 4) | (raw[2] >> 4); + int32_t adc_T = ((int32_t)raw[3] << 12) | ((int32_t)raw[4] << 4) | (raw[5] >> 4); + int32_t adc_H = ((int32_t)raw[6] << 8) | raw[7]; + + double t_fine = 0.0; + double temp_c = compensate_temperature(adc_T, &t_fine); + double press_pa = compensate_pressure(adc_P, t_fine); + double hum_pct = compensate_humidity(adc_H, t_fine); + + size_t n = 0; + memset(&out[n], 0, sizeof(out[n])); + strncpy(out[n].sensor_type, "temperature", SENSOR_READING_TYPE_MAXLEN - 1); + out[n].value = temp_c; + strncpy(out[n].unit, "c", SENSOR_READING_UNIT_MAXLEN - 1); + out[n].metadata = NULL; + n++; + + memset(&out[n], 0, sizeof(out[n])); + strncpy(out[n].sensor_type, "humidity", SENSOR_READING_TYPE_MAXLEN - 1); + out[n].value = hum_pct; + strncpy(out[n].unit, "pct", SENSOR_READING_UNIT_MAXLEN - 1); + out[n].metadata = NULL; + n++; + + memset(&out[n], 0, sizeof(out[n])); + strncpy(out[n].sensor_type, "pressure", SENSOR_READING_TYPE_MAXLEN - 1); + out[n].value = press_pa / 100.0; // Pa -> hPa + strncpy(out[n].unit, "hpa", SENSOR_READING_UNIT_MAXLEN - 1); + out[n].metadata = NULL; + n++; + + *out_count = n; + return ESP_OK; +} diff --git a/firmware/esp32p4-sensor-node/main/bme280.h b/firmware/esp32p4-sensor-node/main/bme280.h new file mode 100644 index 0000000..83ff6f1 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/bme280.h @@ -0,0 +1,57 @@ +// Bosch BME280 driver (temperature / humidity / pressure) over I2C. +// +// Implements the sensor_driver_t interface (see sensor_driver.h) so the +// registry can init/read it generically. Register map and compensation +// formulas are transcribed from Bosch's public BME280 datasheet (Rev 1.23, +// section 4.2.3 "Compensation formulas", double-precision reference +// implementation) — publicly documented, well-trodden territory, not +// guesswork. What IS unverified: this has never talked to a real BME280 — +// see README.md's honesty-policy section. +// +// Default wiring (override via `idf.py menuconfig` or by editing the pin +// macros below before building) — see README.md's wiring section for the +// full pinout table: +// BME280 SDA -> GPIO8 (BME280_I2C_SDA_GPIO) +// BME280 SCL -> GPIO9 (BME280_I2C_SCL_GPIO) +// BME280 VCC -> 3V3, GND -> GND +// BME280 CSB -> VCC (selects I2C mode, not SPI) +// BME280 SDO -> GND selects address 0x76 (default assumed here); tie SDO +// to VCC instead for address 0x77 and change +// BME280_I2C_ADDR accordingly. + +#pragma once + +#include "esp_err.h" +#include "sensor_driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef BME280_I2C_PORT +#define BME280_I2C_PORT 0 +#endif + +#ifndef BME280_I2C_SDA_GPIO +#define BME280_I2C_SDA_GPIO 8 +#endif + +#ifndef BME280_I2C_SCL_GPIO +#define BME280_I2C_SCL_GPIO 9 +#endif + +#ifndef BME280_I2C_ADDR +#define BME280_I2C_ADDR 0x76 // 0x77 if SDO is tied to VCC instead of GND +#endif + +#ifndef BME280_I2C_CLK_HZ +#define BME280_I2C_CLK_HZ 100000 // 100kHz standard mode; BME280 supports up to 3.4MHz +#endif + +// sensor_driver_t-compatible entry points. +esp_err_t bme280_init(void); +esp_err_t bme280_read(sensor_reading_t *out, size_t max_out, size_t *out_count); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/device_config.h.example b/firmware/esp32p4-sensor-node/main/device_config.h.example new file mode 100644 index 0000000..87a0fa0 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/device_config.h.example @@ -0,0 +1,42 @@ +// Quantumancy sensor node — per-device configuration TEMPLATE. +// +// HOW TO USE THIS FILE (there is no provisioning UI in this build — see +// README.md's "Manual configuration" section for the full walkthrough): +// +// 1. Copy this file to `device_config.h` in this same directory: +// cp device_config.h.example device_config.h +// 2. Edit device_config.h and fill in YOUR OWN values below. +// 3. `device_config.h` is listed in .gitignore — it will not be committed. +// Never paste real credentials or tokens into this .example file. +// 4. Build and flash as normal (idf.py build flash). +// +// The pairing token comes from the Quantumancy web app: sign in, create a +// device from your account (name + optional sensor-type hint), and the +// backend shows you a raw pairing token EXACTLY ONCE. Copy it straight into +// DEVICE_PAIRING_TOKEN below — the backend only ever stores a hash of it, so +// if you lose it your only recourse is deleting the device and pairing a new +// one. + +#pragma once + +// --- Wi-Fi station credentials ------------------------------------------- +// The device joins this network in station mode (it does not host an AP). +#define DEVICE_WIFI_SSID "your-wifi-ssid-here" +#define DEVICE_WIFI_PASSWORD "your-wifi-password-here" + +// --- Quantumancy backend --------------------------------------------------- +// Base URL of the backend, NO trailing slash. The firmware appends +// "/api/device/telemetry" itself. Use https:// in any real deployment. +#define DEVICE_BACKEND_BASE_URL "https://your-quantumancy-host.example.com" + +// One-time raw pairing token shown by the web app when you create this +// device (POST /api/device). Sent as `Authorization: Bearer ` on +// every telemetry POST. Treat it like a password. +#define DEVICE_PAIRING_TOKEN "paste-your-one-time-pairing-token-here" + +// --- Reporting cadence ----------------------------------------------------- +// How often (seconds) the telemetry task samples every registered sensor +// driver and POSTs a fresh readings batch. The contract caps 1 req/s +// sustained per device on the backend side — anything >= 2s here is safely +// inside that budget with room for retries. +#define DEVICE_REPORT_INTERVAL_SEC 15 diff --git a/firmware/esp32p4-sensor-node/main/ld2410.c b/firmware/esp32p4-sensor-node/main/ld2410.c new file mode 100644 index 0000000..e70a249 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/ld2410.c @@ -0,0 +1,194 @@ +// HLK-LD2410 driver — see ld2410.h for wiring/rationale and the honesty +// note below for exactly how confident to be in this file. +// +// UNVERIFIED AGAINST REAL HARDWARE, and — unlike bme280.c, which transcribes +// formulas from Bosch's official public datasheet — this frame parser is +// reconstructed from public community reverse-engineering write-ups of the +// LD2410 UART protocol (Hi-Link's official protocol document was not +// available while writing this). The header/footer magic bytes (F4 F3 F2 F1 +// / F8 F7 F6 F5) and the general envelope shape (4-byte header, 2-byte LE +// length, payload, 4-byte footer) are consistently reported across sources +// and are probably solid. The exact payload byte offsets for target +// state / distances / energies are the single least-certain part of this +// entire firmware — see the detailed note in ld2410_parse_payload() below. +// Real bring-up should verify them against a logic analyzer capture or a +// known-good reference implementation before trusting field values. + +#include +#include +#include "ld2410.h" +#include "driver/uart.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "ld2410"; + +#define LD2410_RX_BUF_SIZE 512 +#define LD2410_SCRATCH_SIZE 256 + +static const uint8_t FRAME_HEADER[4] = { 0xF4, 0xF3, 0xF2, 0xF1 }; +static const uint8_t FRAME_FOOTER[4] = { 0xF8, 0xF7, 0xF6, 0xF5 }; + +static bool s_ready = false; + +typedef struct { + uint8_t target_state; // bit0 = moving target, bit1 = stationary target + uint16_t moving_distance_cm; + uint8_t moving_energy; // 0-100 + uint16_t stationary_distance_cm; + uint8_t stationary_energy; // 0-100 + uint16_t detection_distance_cm; +} ld2410_frame_t; + +esp_err_t ld2410_init(void) { + uart_config_t cfg = { + .baud_rate = LD2410_UART_BAUD, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + + esp_err_t err = uart_param_config(LD2410_UART_PORT, &cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + return err; + } + + // uart_set_pin(port, tx_pin, rx_pin, rts_pin, cts_pin) -- our TX GPIO + // wires to the module's RX, and our RX GPIO wires to the module's TX. + err = uart_set_pin(LD2410_UART_PORT, LD2410_UART_TX_GPIO, LD2410_UART_RX_GPIO, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uart_set_pin failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_driver_install(LD2410_UART_PORT, LD2410_RX_BUF_SIZE, 0, 0, NULL, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); + return err; + } + + s_ready = true; + ESP_LOGI(TAG, "LD2410 UART init ok on port %d (%d baud)", LD2410_UART_PORT, LD2410_UART_BAUD); + return ESP_OK; +} + +static bool ld2410_parse_payload(const uint8_t *p, uint16_t len, ld2410_frame_t *out) { + // Basic (engineering-mode-off) target report payload, as reported by + // public LD2410 protocol write-ups: + // p[0] data type byte (0x02 observed for normal reports; not + // strictly checked here -- see honesty note above) + // p[1] 0xAA head-of-intra-frame-data marker + // p[2] target state: 0=none, 1=moving, 2=stationary, 3=both + // p[3..4] moving target distance, cm, little-endian uint16 + // p[5] moving target energy, 0-100 + // p[6..7] stationary target distance, cm, little-endian uint16 + // p[8] stationary target energy, 0-100 + // p[9..10] detection distance, cm, little-endian uint16 + // p[11] 0x55 end-of-intra-frame-data marker + // p[12] trailing byte (ignored) + // + // We only trust a frame whose head/end markers (p[1], p[11]) match -- + // a cheap sanity check against having latched onto the wrong byte + // offsets or a corrupted frame. Anything that fails this is treated as + // "no valid frame this cycle", not a hard error. + if (len < 13) { + return false; + } + if (p[1] != 0xAA || p[11] != 0x55) { + return false; + } + + out->target_state = p[2]; + out->moving_distance_cm = (uint16_t)p[3] | ((uint16_t)p[4] << 8); + out->moving_energy = p[5]; + out->stationary_distance_cm = (uint16_t)p[6] | ((uint16_t)p[7] << 8); + out->stationary_energy = p[8]; + out->detection_distance_cm = (uint16_t)p[9] | ((uint16_t)p[10] << 8); + return true; +} + +esp_err_t ld2410_read(sensor_reading_t *out, size_t max_out, size_t *out_count) { + *out_count = 0; + if (!s_ready) { + return ESP_ERR_INVALID_STATE; + } + if (max_out < 1) { + return ESP_ERR_NO_MEM; + } + + uint8_t buf[LD2410_SCRATCH_SIZE]; + // The LD2410 free-runs, pushing a report frame roughly every 100ms, so + // as long as the driver's RX ring buffer isn't empty a short read + // should find at least one complete frame already queued. + int len = uart_read_bytes(LD2410_UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(200)); + if (len < 0) { + ESP_LOGW(TAG, "uart_read_bytes error"); + return ESP_FAIL; + } + if (len == 0) { + ESP_LOGD(TAG, "no UART data from LD2410 this cycle"); + return ESP_OK; // nothing new isn't a driver failure + } + + bool parsed_any = false; + ld2410_frame_t latest = {0}; + + // Scan for the newest complete, validated frame in whatever arrived + // this cycle; keep overwriting `latest` so we report the freshest one. + for (int i = 0; i + 4 <= len; i++) { + if (memcmp(&buf[i], FRAME_HEADER, 4) != 0) { + continue; + } + if (i + 6 > len) { + break; // not enough bytes left even for the length field + } + uint16_t data_len = (uint16_t)buf[i + 4] | ((uint16_t)buf[i + 5] << 8); + size_t frame_total = 4 + 2 + (size_t)data_len + 4; + if (i + (int)frame_total > len) { + continue; // incomplete frame in this read window, skip it + } + const uint8_t *payload = &buf[i + 6]; + const uint8_t *footer = &buf[i + 6 + data_len]; + if (memcmp(footer, FRAME_FOOTER, 4) != 0) { + ESP_LOGD(TAG, "footer mismatch at offset %d, discarding candidate frame", i); + continue; + } + if (ld2410_parse_payload(payload, data_len, &latest)) { + parsed_any = true; + i += (int)frame_total - 1; // loop's i++ moves past this frame + } + } + + if (!parsed_any) { + ESP_LOGD(TAG, "no complete/valid LD2410 frame in this read window"); + return ESP_OK; + } + + memset(&out[0], 0, sizeof(out[0])); + strncpy(out[0].sensor_type, "presence", SENSOR_READING_TYPE_MAXLEN - 1); + out[0].value = (latest.target_state != 0) ? 1.0 : 0.0; + strncpy(out[0].unit, "bool", SENSOR_READING_UNIT_MAXLEN - 1); + + // This is the whole reason to prefer the LD2410 over a plain PIR: pack + // the richer distance/energy data into metadata instead of throwing it + // away, so it's available to the backend/frontend even though the + // top-level `value` stays a simple presence boolean per the contract. + cJSON *meta = cJSON_CreateObject(); + if (meta != NULL) { + cJSON_AddNumberToObject(meta, "target_state", latest.target_state); + cJSON_AddNumberToObject(meta, "moving_distance_cm", latest.moving_distance_cm); + cJSON_AddNumberToObject(meta, "moving_energy", latest.moving_energy); + cJSON_AddNumberToObject(meta, "stationary_distance_cm", latest.stationary_distance_cm); + cJSON_AddNumberToObject(meta, "stationary_energy", latest.stationary_energy); + cJSON_AddNumberToObject(meta, "detection_distance_cm", latest.detection_distance_cm); + } + out[0].metadata = meta; + + *out_count = 1; + return ESP_OK; +} diff --git a/firmware/esp32p4-sensor-node/main/ld2410.h b/firmware/esp32p4-sensor-node/main/ld2410.h new file mode 100644 index 0000000..3009f7c --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/ld2410.h @@ -0,0 +1,53 @@ +// HLK-LD2410 mmWave presence/distance sensor driver, over UART. +// +// Chosen over a plain PIR for this app's "believable" ethos (per the spec): +// the LD2410 reports moving-target distance/energy AND stationary-target +// distance/energy separately, not just a boolean "something moved" — richer +// signal for the anomaly pipeline (Workstream K) to work with, and it can +// tell a séance's "someone opened a door" from "the sitter shifted in their +// chair" in a way a PIR fundamentally cannot. Tradeoff: it's a more complex +// protocol than a PIR's single GPIO pin, and this driver's frame parser is +// UNVERIFIED against a real module — see README.md's honesty section and +// the parsing notes in ld2410.c. +// +// Default wiring (3.3V logic — do NOT wire directly to a 5V-logic UART): +// LD2410 TX -> ESP32 RX (LD2410_UART_RX_GPIO) +// LD2410 RX -> ESP32 TX (LD2410_UART_TX_GPIO) +// LD2410 VCC -> 5V (module runs its sensor front-end at 5V; UART logic is +// 3.3V-tolerant per module datasheet -- double check your +// specific board revision before wiring) +// LD2410 GND -> GND +// Default UART settings: 256000 baud, 8N1 (module factory default). + +#pragma once + +#include "esp_err.h" +#include "sensor_driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef LD2410_UART_PORT +#define LD2410_UART_PORT 1 +#endif + +#ifndef LD2410_UART_RX_GPIO +#define LD2410_UART_RX_GPIO 17 +#endif + +#ifndef LD2410_UART_TX_GPIO +#define LD2410_UART_TX_GPIO 18 +#endif + +#ifndef LD2410_UART_BAUD +#define LD2410_UART_BAUD 256000 +#endif + +// sensor_driver_t-compatible entry points. +esp_err_t ld2410_init(void); +esp_err_t ld2410_read(sensor_reading_t *out, size_t max_out, size_t *out_count); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/sensor_driver.h b/firmware/esp32p4-sensor-node/main/sensor_driver.h new file mode 100644 index 0000000..93def18 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/sensor_driver.h @@ -0,0 +1,66 @@ +// Small internal sensor-driver interface. +// +// The whole point of this file: adding a new sensor type later should mean +// "write a new .c/.h pair implementing this interface, add one line to the +// registry array in sensor_registry.c" — never editing app_main.c's control +// flow or the telemetry POST loop. See sensor_registry.c for the array and +// README.md's "Adding a new sensor" section for a walkthrough. +// +// UNVERIFIED AGAINST REAL HARDWARE — see README.md. + +#pragma once + +#include +#include "esp_err.h" +#include "cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Max length (including NUL) for a reading's sensor_type / unit strings. +// Generous for the sensor_type strings this firmware emits ("temperature", +// "humidity", "pressure", "presence", ...) with room for future ones. +#define SENSOR_READING_TYPE_MAXLEN 24 +#define SENSOR_READING_UNIT_MAXLEN 16 + +// One entry of the backend contract's `readings` array: +// {"sensor_type": str, "value": number, "unit": str, "metadata": {}} +// +// `metadata` is optional. If non-NULL, ownership transfers to the caller +// that serializes the reading (telemetry_client.c frees it after building +// the request body) — a driver's read() must hand back a freshly-created +// cJSON object it does not touch again, never a shared/static one. +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; + +// One registered sensor driver. +// +// name - short human-readable identifier, used only in log lines. +// init - one-time hardware bring-up (bus/peripheral init, sensor reset, +// presence/ID check). May be NULL if a driver needs no init step. +// Called once at boot, in registry array order, before Wi-Fi +// connects so a slow/hanging sensor bus can't block network +// bring-up indefinitely (each init should still apply its own +// reasonable internal timeout). +// read - sample the sensor and append up to `max_out` readings to `out`, +// writing the number actually written to `*out_count`. Called +// once per reporting cycle from the telemetry task. Must return +// ESP_OK even if it decides there is nothing new to report (set +// *out_count = 0) — returning an error is reserved for actual +// I/O failure (bus NACK, UART timeout with no valid frame, etc.), +// which the registry logs and treats as "this driver contributed +// nothing this cycle" without aborting the whole POST. +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; + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/sensor_registry.c b/firmware/esp32p4-sensor-node/main/sensor_registry.c new file mode 100644 index 0000000..4f3eedd --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/sensor_registry.c @@ -0,0 +1,73 @@ +// Sensor driver registry -- see sensor_registry.h. +// +// ADDING A NEW SENSOR: write its driver as a new .c/.h pair implementing +// sensor_driver_t's init()/read() (see sensor_driver.h), add the .c file to +// main/CMakeLists.txt's SRCS list, #include its header below, and add one +// line to the s_drivers[] array. Nothing else in this firmware needs to +// change -- app_main.c's task loop and telemetry_client.c's POST logic both +// iterate the registry generically and have no per-sensor-type branches. + +#include +#include "sensor_registry.h" +#include "bme280.h" +#include "ld2410.h" +#include "esp_log.h" + +static const char *TAG = "sensor_registry"; + +// The concrete list of sensor drivers compiled into this firmware. Order +// matters only for init() sequencing (e.g. bus setup before device use); +// read() order just determines readings array ordering in the POST body. +static const sensor_driver_t s_drivers[] = { + { .name = "bme280", .init = bme280_init, .read = bme280_read }, + { .name = "ld2410", .init = ld2410_init, .read = ld2410_read }, + // Add new drivers here, e.g.: + // { .name = "my_sensor", .init = my_sensor_init, .read = my_sensor_read }, +}; + +static const size_t s_driver_count = sizeof(s_drivers) / sizeof(s_drivers[0]); + +// Tracks which drivers initialized successfully so a failed driver's read() +// isn't called every cycle (and doesn't spam logs) -- but the array stays +// a fixed compile-time list either way, no dynamic registration needed at +// this project's scale. +static bool s_driver_ready[sizeof(s_drivers) / sizeof(s_drivers[0])]; + +void sensor_registry_init_all(void) { + for (size_t i = 0; i < s_driver_count; i++) { + const sensor_driver_t *drv = &s_drivers[i]; + if (drv->init == NULL) { + s_driver_ready[i] = true; + continue; + } + esp_err_t err = drv->init(); + if (err == ESP_OK) { + s_driver_ready[i] = true; + ESP_LOGI(TAG, "driver '%s' initialized", drv->name); + } else { + s_driver_ready[i] = false; + ESP_LOGW(TAG, "driver '%s' failed to initialize (%s) -- it will be skipped", + drv->name, esp_err_to_name(err)); + } + } +} + +esp_err_t sensor_registry_collect(sensor_reading_t *out, size_t max_out, size_t *out_count) { + size_t total = 0; + for (size_t i = 0; i < s_driver_count && total < max_out; i++) { + if (!s_driver_ready[i]) { + continue; + } + const sensor_driver_t *drv = &s_drivers[i]; + size_t produced = 0; + esp_err_t err = drv->read(&out[total], max_out - total, &produced); + if (err != ESP_OK) { + ESP_LOGW(TAG, "driver '%s' read failed (%s) -- skipping this cycle", + drv->name, esp_err_to_name(err)); + continue; + } + total += produced; + } + *out_count = total; + return ESP_OK; +} diff --git a/firmware/esp32p4-sensor-node/main/sensor_registry.h b/firmware/esp32p4-sensor-node/main/sensor_registry.h new file mode 100644 index 0000000..fde5a53 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/sensor_registry.h @@ -0,0 +1,31 @@ +// Sensor driver registry: the one place that knows the concrete list of +// sensor drivers compiled into this firmware. app_main.c and +// telemetry_client.c never reference bme280.c / ld2410.c directly -- they +// only talk to this registry, so adding a new sensor never touches them. +// +// See README.md's "Adding a new sensor" section for the step-by-step. + +#pragma once + +#include "sensor_driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Calls every registered driver's init() (in array order), logging and +// continuing past individual failures rather than aborting -- one dead +// sensor shouldn't take the whole node offline. Call once at boot, before +// the telemetry task starts. +void sensor_registry_init_all(void); + +// Calls every registered driver's read() and appends its readings into +// `out` (capacity `max_out`), writing the total count written to +// `*out_count`. A driver that errors is logged and simply contributes zero +// readings this cycle. Returns ESP_OK; this function does not fail as a +// whole just because one driver did. +esp_err_t sensor_registry_collect(sensor_reading_t *out, size_t max_out, size_t *out_count); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/telemetry_client.c b/firmware/esp32p4-sensor-node/main/telemetry_client.c new file mode 100644 index 0000000..b37819a --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/telemetry_client.c @@ -0,0 +1,166 @@ +// Telemetry HTTP client — see telemetry_client.h. +// +// UNVERIFIED AGAINST REAL HARDWARE: written against ESP-IDF's documented +// esp_http_client API and the design spec's contract (see header comment), +// but never run against a live backend from real firmware. The request +// shape (JSON body, header names) is copied verbatim from the spec, which +// is binding for this workstream — see +// docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md. + +#include +#include +#include +#include "telemetry_client.h" +#include "device_config.h" +#include "sensor_registry.h" +#include "wifi_manager.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "cJSON.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "telemetry_client"; + +// Matches the backend contract's own cap ("cap readings array length, e.g. +// 64 per request") with headroom to spare for this firmware's small, +// fixed set of drivers. +#define TELEMETRY_MAX_READINGS 32 + +static esp_err_t build_request_body(const sensor_reading_t *readings, size_t count, + char **out_json, size_t *out_len) { + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + for (size_t i = 0; i < count; i++) { + cJSON_Delete(readings[i].metadata); + } + return ESP_ERR_NO_MEM; + } + + cJSON *arr = cJSON_AddArrayToObject(root, "readings"); + if (arr == NULL) { + cJSON_Delete(root); + for (size_t i = 0; i < count; i++) { + cJSON_Delete(readings[i].metadata); + } + return ESP_ERR_NO_MEM; + } + + for (size_t i = 0; i < count; i++) { + cJSON *item = cJSON_CreateObject(); + if (item == NULL) { + cJSON_Delete(root); + for (size_t j = i; j < count; j++) { + cJSON_Delete(readings[j].metadata); + } + return ESP_ERR_NO_MEM; + } + cJSON_AddStringToObject(item, "sensor_type", readings[i].sensor_type); + cJSON_AddNumberToObject(item, "value", readings[i].value); + cJSON_AddStringToObject(item, "unit", readings[i].unit); + // metadata is required by the contract even when a driver has + // nothing extra to say -- an empty object, never a missing key. + cJSON *meta = readings[i].metadata ? readings[i].metadata : cJSON_CreateObject(); + cJSON_AddItemToObject(item, "metadata", meta); + cJSON_AddItemToArray(arr, item); + } + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); // recursively frees every item + its metadata too + if (json == NULL) { + return ESP_ERR_NO_MEM; + } + *out_json = json; + *out_len = strlen(json); + return ESP_OK; +} + +esp_err_t telemetry_client_post_readings(const sensor_reading_t *readings, size_t count) { + if (count == 0) { + return ESP_OK; + } + + char *json = NULL; + size_t json_len = 0; + esp_err_t err = build_request_body(readings, count, &json, &json_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed to build request body: %s", esp_err_to_name(err)); + return err; + } + + char url[256]; + int url_n = snprintf(url, sizeof(url), "%s/api/device/telemetry", DEVICE_BACKEND_BASE_URL); + if (url_n < 0 || (size_t)url_n >= sizeof(url)) { + ESP_LOGE(TAG, "DEVICE_BACKEND_BASE_URL too long for url buffer"); + free(json); + return ESP_ERR_INVALID_SIZE; + } + + char auth_header[512]; + int auth_n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", DEVICE_PAIRING_TOKEN); + if (auth_n < 0 || (size_t)auth_n >= sizeof(auth_header)) { + ESP_LOGE(TAG, "DEVICE_PAIRING_TOKEN too long for header buffer"); + free(json); + return ESP_ERR_INVALID_SIZE; + } + + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = 10000, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (client == NULL) { + ESP_LOGE(TAG, "esp_http_client_init failed"); + free(json); + return ESP_FAIL; + } + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_header(client, "Authorization", auth_header); + esp_http_client_set_post_field(client, json, (int)json_len); + + err = esp_http_client_perform(client); + if (err == ESP_OK) { + int status = esp_http_client_get_status_code(client); + ESP_LOGI(TAG, "telemetry POST -> HTTP %d (%d readings, %d bytes)", + status, (int)count, (int)json_len); + if (status < 200 || status >= 300) { + err = ESP_FAIL; + } + } else { + ESP_LOGW(TAG, "telemetry POST transport error: %s", esp_err_to_name(err)); + } + + esp_http_client_cleanup(client); + free(json); // cJSON_PrintUnformatted allocates via cJSON's hooks (malloc by default) + return err; +} + +static void telemetry_task(void *arg) { + static sensor_reading_t s_readings[TELEMETRY_MAX_READINGS]; + + while (1) { + if (!wifi_manager_is_connected()) { + ESP_LOGI(TAG, "Wi-Fi not connected, skipping this reporting cycle"); + } else { + size_t count = 0; + sensor_registry_collect(s_readings, TELEMETRY_MAX_READINGS, &count); + if (count == 0) { + ESP_LOGD(TAG, "no readings produced this cycle"); + } else { + esp_err_t err = telemetry_client_post_readings(s_readings, count); + if (err != ESP_OK) { + ESP_LOGW(TAG, "telemetry post did not succeed this cycle: %s", esp_err_to_name(err)); + } + } + } + vTaskDelay(pdMS_TO_TICKS(DEVICE_REPORT_INTERVAL_SEC * 1000)); + } +} + +void telemetry_client_start_task(void) { + // Stack sized generously: cJSON body building + TLS handshake state for + // esp_http_client both live on this task's stack. + xTaskCreate(telemetry_task, "telemetry_task", 8192, NULL, 5, NULL); +} diff --git a/firmware/esp32p4-sensor-node/main/telemetry_client.h b/firmware/esp32p4-sensor-node/main/telemetry_client.h new file mode 100644 index 0000000..5ab28af --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/telemetry_client.h @@ -0,0 +1,36 @@ +// HTTP client for POSTing sensor readings to the Quantumancy backend's +// `POST /api/device/telemetry` endpoint (see the design spec's "Contract" +// section: docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md). +// +// UNVERIFIED AGAINST REAL HARDWARE — see README.md. + +#pragma once + +#include +#include "esp_err.h" +#include "sensor_driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Builds the request body: +// {"readings": [{"sensor_type": str, "value": number, "unit": str, +// "metadata": {}}, ...]} +// and POSTs it to "/api/device/telemetry" with +// `Authorization: Bearer ` and +// `Content-Type: application/json`. Takes ownership of any non-NULL +// `metadata` cJSON pointers in `readings` — they are freed as part of +// building/serializing the body regardless of whether the POST succeeds. +// No-ops (returns ESP_OK) if `count` is 0. +esp_err_t telemetry_client_post_readings(const sensor_reading_t *readings, size_t count); + +// Starts the FreeRTOS task that, on a DEVICE_REPORT_INTERVAL_SEC cadence, +// collects a fresh batch of readings from the sensor registry and POSTs +// them via telemetry_client_post_readings(). Call once from app_main() +// after wifi_manager_start() and sensor_registry_init_all(). +void telemetry_client_start_task(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/wifi_manager.c b/firmware/esp32p4-sensor-node/main/wifi_manager.c new file mode 100644 index 0000000..fe16b18 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/wifi_manager.c @@ -0,0 +1,131 @@ +// Wi-Fi station-mode connection manager — see wifi_manager.h for the +// hardware caveat about ESP32-P4 not having an integrated radio. +// +// Structure follows ESP-IDF's own documented Wi-Fi station example pattern +// (event-driven connect via WIFI_EVENT/IP_EVENT + an event group the rest +// of the app can block on), extended with an exponential-backoff reconnect +// instead of giving up after N attempts — a sensor node left running for +// weeks needs to ride out a router reboot or a home Wi-Fi outage +// indefinitely, not park itself in a permanent failure state. +// +// UNVERIFIED AGAINST REAL HARDWARE — see README.md. + +#include +#include "wifi_manager.h" +#include "device_config.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "nvs_flash.h" +#include "freertos/event_groups.h" + +static const char *TAG = "wifi_manager"; + +#define WIFI_CONNECTED_BIT BIT0 + +#define WIFI_RECONNECT_BASE_DELAY_MS 1000 +#define WIFI_RECONNECT_MAX_DELAY_MS 30000 +#define WIFI_RECONNECT_BACKOFF_CAP_SHIFT 5 // 1000ms << 5 = 32000ms, then clamped to MAX + +static EventGroupHandle_t s_wifi_event_group = NULL; +static esp_timer_handle_t s_reconnect_timer = NULL; +static uint32_t s_retry_count = 0; + +static void reconnect_timer_cb(void *arg) { + ESP_LOGI(TAG, "attempting Wi-Fi reconnect"); + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_connect() failed: %s", esp_err_to_name(err)); + } +} + +static void schedule_reconnect(void) { + uint32_t shift = s_retry_count < WIFI_RECONNECT_BACKOFF_CAP_SHIFT ? s_retry_count : WIFI_RECONNECT_BACKOFF_CAP_SHIFT; + uint32_t delay_ms = WIFI_RECONNECT_BASE_DELAY_MS << shift; + if (delay_ms > WIFI_RECONNECT_MAX_DELAY_MS) { + delay_ms = WIFI_RECONNECT_MAX_DELAY_MS; + } + s_retry_count++; + ESP_LOGW(TAG, "reconnecting in %" PRIu32 " ms (attempt %" PRIu32 ")", delay_ms, s_retry_count); + esp_timer_stop(s_reconnect_timer); // no-op if not running; keeps this idempotent + esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000ULL); +} + +static void wifi_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) { + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data; + ESP_LOGW(TAG, "Wi-Fi disconnected (reason %d)", disc ? disc->reason : -1); + schedule_reconnect(); + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *evt = (ip_event_got_ip_t *)event_data; + ESP_LOGI(TAG, "got IP: " IPSTR, IP2STR(&evt->ip_info.ip)); + s_retry_count = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_manager_start(void) { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + + s_wifi_event_group = xEventGroupCreate(); + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init_cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); + + const esp_timer_create_args_t timer_args = { + .callback = &reconnect_timer_cb, + .name = "wifi_reconnect", + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_reconnect_timer)); + + wifi_config_t wifi_config = { + .sta = { + .ssid = DEVICE_WIFI_SSID, + .password = DEVICE_WIFI_PASSWORD, + .threshold.authmode = WIFI_AUTH_WPA2_PSK, + }, + }; + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Wi-Fi station starting, SSID \"%s\"", DEVICE_WIFI_SSID); + return ESP_OK; +} + +esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks) { + if (s_wifi_event_group == NULL) { + return ESP_ERR_INVALID_STATE; + } + EventBits_t bits = xEventGroupWaitBits( + s_wifi_event_group, WIFI_CONNECTED_BIT, + pdFALSE /* don't clear on exit */, pdFALSE /* any bit */, timeout_ticks); + return (bits & WIFI_CONNECTED_BIT) ? ESP_OK : ESP_ERR_TIMEOUT; +} + +bool wifi_manager_is_connected(void) { + if (s_wifi_event_group == NULL) { + return false; + } + return (xEventGroupGetBits(s_wifi_event_group) & WIFI_CONNECTED_BIT) != 0; +} diff --git a/firmware/esp32p4-sensor-node/main/wifi_manager.h b/firmware/esp32p4-sensor-node/main/wifi_manager.h new file mode 100644 index 0000000..fc24a4f --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/wifi_manager.h @@ -0,0 +1,44 @@ +// Wi-Fi station-mode connection management, using the credentials in +// device_config.h. +// +// IMPORTANT hardware caveat (honesty note, not code): the ESP32-P4 SoC has +// no integrated 2.4GHz radio -- real Wi-Fi on P4 hardware requires a +// companion chip (e.g. ESP32-C6) wired via SDIO/SPI running "esp-hosted", +// which presents this exact same esp_wifi/esp_netif API to application code. +// This file is written against that standard API surface, so it should be +// portable unchanged to a hosted-mode P4 board or to a Wi-Fi-native target +// (e.g. `idf.py set-target esp32s3`) -- only sdkconfig / board wiring +// differs, not this code. See README.md for the full explanation. This is +// exactly the kind of thing nobody can verify without the real board, so +// it's called out explicitly rather than glossed over. + +#pragma once + +#include +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Initializes NVS (required by esp_wifi), the default netif, the event +// loop, and esp_wifi in station mode, then starts connecting using +// DEVICE_WIFI_SSID / DEVICE_WIFI_PASSWORD from device_config.h. Registers +// an internal event handler that retries on disconnect with a capped +// backoff. Call once from app_main() before starting the telemetry task. +esp_err_t wifi_manager_start(void); + +// Blocks (efficiently, via an internal FreeRTOS event group) until the +// station has an IP address, or until `timeout_ticks` elapses. +// Returns ESP_OK once connected, ESP_ERR_TIMEOUT otherwise. +esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks); + +// True if the station currently holds an IP (i.e. the last known state is +// "connected"), for callers that want a non-blocking check (e.g. the +// telemetry task deciding whether to bother building a request this cycle). +bool wifi_manager_is_connected(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/sdkconfig.defaults b/firmware/esp32p4-sensor-node/sdkconfig.defaults new file mode 100644 index 0000000..bd536fa --- /dev/null +++ b/firmware/esp32p4-sensor-node/sdkconfig.defaults @@ -0,0 +1,25 @@ +# Default sdkconfig overrides for the Quantumancy sensor node. +# +# These are seed values applied when `idf.py set-target esp32p4` generates the +# real `sdkconfig` (which is machine/toolchain-specific and NOT committed — +# see .gitignore). UNVERIFIED: nobody has run `idf.py build` against a real +# ESP-IDF install for this project, so treat these as reasonable, standard +# starting points rather than confirmed-working values. + +CONFIG_IDF_TARGET="esp32p4" + +# The backend is served over HTTPS in production (see deploy/), so the HTTP +# client needs mbedTLS's bundled CA store to verify the TLS cert. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y + +# Headroom for the main task: JSON body construction (cJSON) plus +# esp_http_client happen on it via the telemetry task. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=4096 + +# A handful of sockets is plenty for one outbound HTTP client + Wi-Fi/LWIP +# housekeeping; keeps RAM use predictable on a sensor node. +CONFIG_LWIP_MAX_SOCKETS=16 + +# Reasonably quiet serial log output by default (bump to DEBUG for bring-up). +CONFIG_LOG_DEFAULT_LEVEL_INFO=y