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