// HLK-LD2410 driver — see ld2410.h for wiring/rationale and the honesty // note below for exactly how confident to be in this file. // // UNVERIFIED AGAINST REAL HARDWARE, and — unlike bme280.c, which transcribes // formulas from Bosch's official public datasheet — this frame parser is // reconstructed from public community reverse-engineering write-ups of the // LD2410 UART protocol (Hi-Link's official protocol document was not // available while writing this). The header/footer magic bytes (F4 F3 F2 F1 // / F8 F7 F6 F5) and the general envelope shape (4-byte header, 2-byte LE // length, payload, 4-byte footer) are consistently reported across sources // and are probably solid. The exact payload byte offsets for target // state / distances / energies are the single least-certain part of this // entire firmware — see the detailed note in ld2410_parse_payload() below. // Real bring-up should verify them against a logic analyzer capture or a // known-good reference implementation before trusting field values. #include #include #include "ld2410.h" #include "driver/uart.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" static const char *TAG = "ld2410"; #define LD2410_RX_BUF_SIZE 512 #define LD2410_SCRATCH_SIZE 256 static const uint8_t FRAME_HEADER[4] = { 0xF4, 0xF3, 0xF2, 0xF1 }; static const uint8_t FRAME_FOOTER[4] = { 0xF8, 0xF7, 0xF6, 0xF5 }; static bool s_ready = false; typedef struct { uint8_t target_state; // bit0 = moving target, bit1 = stationary target uint16_t moving_distance_cm; uint8_t moving_energy; // 0-100 uint16_t stationary_distance_cm; uint8_t stationary_energy; // 0-100 uint16_t detection_distance_cm; } ld2410_frame_t; esp_err_t ld2410_init(void) { uart_config_t cfg = { .baud_rate = LD2410_UART_BAUD, .data_bits = UART_DATA_8_BITS, .parity = UART_PARITY_DISABLE, .stop_bits = UART_STOP_BITS_1, .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, .source_clk = UART_SCLK_DEFAULT, }; esp_err_t err = uart_param_config(LD2410_UART_PORT, &cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); return err; } // uart_set_pin(port, tx_pin, rx_pin, rts_pin, cts_pin) -- our TX GPIO // wires to the module's RX, and our RX GPIO wires to the module's TX. err = uart_set_pin(LD2410_UART_PORT, LD2410_UART_TX_GPIO, LD2410_UART_RX_GPIO, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); if (err != ESP_OK) { ESP_LOGE(TAG, "uart_set_pin failed: %s", esp_err_to_name(err)); return err; } err = uart_driver_install(LD2410_UART_PORT, LD2410_RX_BUF_SIZE, 0, 0, NULL, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); return err; } s_ready = true; ESP_LOGI(TAG, "LD2410 UART init ok on port %d (%d baud)", LD2410_UART_PORT, LD2410_UART_BAUD); return ESP_OK; } static bool ld2410_parse_payload(const uint8_t *p, uint16_t len, ld2410_frame_t *out) { // Basic (engineering-mode-off) target report payload, as reported by // public LD2410 protocol write-ups: // p[0] data type byte (0x02 observed for normal reports; not // strictly checked here -- see honesty note above) // p[1] 0xAA head-of-intra-frame-data marker // p[2] target state: 0=none, 1=moving, 2=stationary, 3=both // p[3..4] moving target distance, cm, little-endian uint16 // p[5] moving target energy, 0-100 // p[6..7] stationary target distance, cm, little-endian uint16 // p[8] stationary target energy, 0-100 // p[9..10] detection distance, cm, little-endian uint16 // p[11] 0x55 end-of-intra-frame-data marker // p[12] trailing byte (ignored) // // We only trust a frame whose head/end markers (p[1], p[11]) match -- // a cheap sanity check against having latched onto the wrong byte // offsets or a corrupted frame. Anything that fails this is treated as // "no valid frame this cycle", not a hard error. if (len < 13) { return false; } if (p[1] != 0xAA || p[11] != 0x55) { return false; } out->target_state = p[2]; out->moving_distance_cm = (uint16_t)p[3] | ((uint16_t)p[4] << 8); out->moving_energy = p[5]; out->stationary_distance_cm = (uint16_t)p[6] | ((uint16_t)p[7] << 8); out->stationary_energy = p[8]; out->detection_distance_cm = (uint16_t)p[9] | ((uint16_t)p[10] << 8); return true; } esp_err_t ld2410_read(sensor_reading_t *out, size_t max_out, size_t *out_count) { *out_count = 0; if (!s_ready) { return ESP_ERR_INVALID_STATE; } if (max_out < 1) { return ESP_ERR_NO_MEM; } uint8_t buf[LD2410_SCRATCH_SIZE]; // The LD2410 free-runs, pushing a report frame roughly every 100ms, so // as long as the driver's RX ring buffer isn't empty a short read // should find at least one complete frame already queued. int len = uart_read_bytes(LD2410_UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(200)); if (len < 0) { ESP_LOGW(TAG, "uart_read_bytes error"); return ESP_FAIL; } if (len == 0) { ESP_LOGD(TAG, "no UART data from LD2410 this cycle"); return ESP_OK; // nothing new isn't a driver failure } bool parsed_any = false; ld2410_frame_t latest = {0}; // Scan for the newest complete, validated frame in whatever arrived // this cycle; keep overwriting `latest` so we report the freshest one. for (int i = 0; i + 4 <= len; i++) { if (memcmp(&buf[i], FRAME_HEADER, 4) != 0) { continue; } if (i + 6 > len) { break; // not enough bytes left even for the length field } uint16_t data_len = (uint16_t)buf[i + 4] | ((uint16_t)buf[i + 5] << 8); size_t frame_total = 4 + 2 + (size_t)data_len + 4; if (i + (int)frame_total > len) { continue; // incomplete frame in this read window, skip it } const uint8_t *payload = &buf[i + 6]; const uint8_t *footer = &buf[i + 6 + data_len]; if (memcmp(footer, FRAME_FOOTER, 4) != 0) { ESP_LOGD(TAG, "footer mismatch at offset %d, discarding candidate frame", i); continue; } if (ld2410_parse_payload(payload, data_len, &latest)) { parsed_any = true; i += (int)frame_total - 1; // loop's i++ moves past this frame } } if (!parsed_any) { ESP_LOGD(TAG, "no complete/valid LD2410 frame in this read window"); return ESP_OK; } memset(&out[0], 0, sizeof(out[0])); strncpy(out[0].sensor_type, "presence", SENSOR_READING_TYPE_MAXLEN - 1); out[0].value = (latest.target_state != 0) ? 1.0 : 0.0; strncpy(out[0].unit, "bool", SENSOR_READING_UNIT_MAXLEN - 1); // This is the whole reason to prefer the LD2410 over a plain PIR: pack // the richer distance/energy data into metadata instead of throwing it // away, so it's available to the backend/frontend even though the // top-level `value` stays a simple presence boolean per the contract. cJSON *meta = cJSON_CreateObject(); if (meta != NULL) { cJSON_AddNumberToObject(meta, "target_state", latest.target_state); cJSON_AddNumberToObject(meta, "moving_distance_cm", latest.moving_distance_cm); cJSON_AddNumberToObject(meta, "moving_energy", latest.moving_energy); cJSON_AddNumberToObject(meta, "stationary_distance_cm", latest.stationary_distance_cm); cJSON_AddNumberToObject(meta, "stationary_energy", latest.stationary_energy); cJSON_AddNumberToObject(meta, "detection_distance_cm", latest.detection_distance_cm); } out[0].metadata = meta; *out_count = 1; return ESP_OK; }