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:
Indiana
2026-07-24 01:13:44 +00:00
parent cf817e5241
commit 348b5fc778
19 changed files with 1643 additions and 0 deletions

View 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);
}