Files
qtalker---/firmware/esp32p4-sensor-node/main/ld2410.c
Indiana 348b5fc778 feat(firmware): ESP32-P4 sensor node — Workstream I core skeleton
New firmware/esp32p4-sensor-node/ ESP-IDF (C, FreeRTOS) project skeleton
per docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md's
Workstream I:

- Wi-Fi station-mode connect with exponential-backoff reconnect
  (wifi_manager.c), credentials from a gitignored main/device_config.h
  the seeker fills in (template: device_config.h.example).
- Telemetry HTTP client (telemetry_client.c) POSTing the spec's exact
  contract shape to /api/device/telemetry with a Bearer token, via
  esp_http_client + cJSON.
- BME280 I2C driver (bme280.c) with Bosch's public double-precision
  compensation formulas, using ESP-IDF's newer driver/i2c_master.h API.
- LD2410 mmWave presence driver (ld2410.c) over UART, chosen over a
  plain PIR for its distance/motion data richness — its frame-offset
  parsing is flagged as the least-certain code in the firmware.
- sensor_driver_t registry (sensor_driver.h, sensor_registry.c) so new
  sensors are a new driver file + one array line, no main-loop changes.
- README.md: build steps, manual-config walkthrough, wiring/pinouts,
  and an explicit "what's verified vs. not" section plus a real
  hardware caveat (ESP32-P4 has no integrated Wi-Fi radio).

UNVERIFIED AGAINST REAL HARDWARE per the spec's honesty-policy note —
no ESP-IDF toolchain or physical boards available in this environment.
Syntax-checked with gcc against hand-written ESP-IDF API stubs (not
committed) as a best-effort substitute for a real idf.py build.

Workstream J (RTL-SDR experimental module) is explicitly out of scope
here; firmware/esp32p4-sensor-node/components/ is left in place for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:13:44 +00:00

195 lines
7.8 KiB
C

// 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;
}