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>
195 lines
7.8 KiB
C
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;
|
|
}
|