commit ac7cfbab9c4448aacda883af49b8d286dec75bff
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-13T02:51:53Z |
| subject | Added calibration tool for ld2410s |
commit ac7cfbab9c4448aacda883af49b8d286dec75bff
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-13T02:51:53Z
Added calibration tool for ld2410s
---
README.md | 18 +-
esp32/tools/ld2410s-c3-calib/.gitignore | 7 +
esp32/tools/ld2410s-c3-calib/CMakeLists.txt | 4 +
esp32/tools/ld2410s-c3-calib/README.md | 143 ++++++++++
esp32/tools/ld2410s-c3-calib/flash.py | 34 +++
esp32/tools/ld2410s-c3-calib/main/CMakeLists.txt | 11 +
.../tools/ld2410s-c3-calib/main/Kconfig.projbuild | 22 ++
.../tools/ld2410s-c3-calib/main/idf_component.yml | 6 +
esp32/tools/ld2410s-c3-calib/main/main.c | 236 ++++++++++++++++
esp32/tools/ld2410s-c3-calib/main/protocol.c | 74 +++++
esp32/tools/ld2410s-c3-calib/main/protocol.h | 33 +++
esp32/tools/ld2410s-c3-calib/main/radar.c | 301 +++++++++++++++++++++
esp32/tools/ld2410s-c3-calib/main/radar.h | 32 +++
esp32/tools/ld2410s-c3-calib/main/web.html | 56 ++++
esp32/tools/ld2410s-c3-calib/sdkconfig.defaults | 7 +
esp32/tools/ld2410s-c3-calib/tests/test_protocol.c | 55 ++++
esp32/tools/ld2410s-c3-calib/tests/test_ui.js | 27 ++
17 files changed, 1060 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 22550e2..05c1402 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
# HomeKey Reader Relay
-`PN532 -> ESP32-C3 (this part) -> Home Assistant`
+
+```
+PN532 -> | ESP32-C3 (this part) | -> Home Assistant
+ | ^ |
+ | HLK-LD2410S sensor |
+```
+
In development. NOT FOR USE!
@@ -8,6 +14,11 @@ NFC APDUs. The HACS component and service-data examples below describe the
earlier advertisement-based firmware; current firmware does not emit those
UID advertisements. See [the current relay protocol](esp32/PROTOCOL.md).
+
+## Workflow
+Presence -> HLK-LD2410S -> ESP32-C3 -> PN532
+
+
## Legacy Home Assistant UI setup
HACS
@@ -41,11 +52,6 @@ idf.py set-target esp32c3
idf.py -p /dev/cu.usbmodem101 flash monitor
```
-## Current flow
-
-```text
-```
-
Service UUID:
```text
diff --git a/esp32/tools/ld2410s-c3-calib/.gitignore b/esp32/tools/ld2410s-c3-calib/.gitignore
new file mode 100644
index 0000000..bbfc156
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/.gitignore
@@ -0,0 +1,7 @@
+build/
+sdkconfig
+sdkconfig.old
+managed_components/
+dependencies.lock
+
+firmware/
diff --git a/esp32/tools/ld2410s-c3-calib/CMakeLists.txt b/esp32/tools/ld2410s-c3-calib/CMakeLists.txt
new file mode 100644
index 0000000..d0cd59e
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/CMakeLists.txt
@@ -0,0 +1,4 @@
+cmake_minimum_required(VERSION 3.16)
+set(COMPONENTS main)
+include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+project(ld2410s_bench)
diff --git a/esp32/tools/ld2410s-c3-calib/README.md b/esp32/tools/ld2410s-c3-calib/README.md
new file mode 100644
index 0000000..ccf37a3
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/README.md
@@ -0,0 +1,143 @@
+# LD2410S calibration tool for ESP32-C3
+
+ESP-IDF firmware that connects an LD2410S directly to an ESP32-C3 and serves a local Wi-Fi calibration dashboard. Mount the breadboard on the wall, power it from USB, and test from your Mac or phone.
+
+**Calibration fix:** device progress is an integer percentage (0–100), not hundredths. Earlier source displayed completion as 1% and eventually timed out. Rebuild from this source to apply the correction. The included prebuilt binaries were compiled before this fix and still have that progress bug.
+
+This is a standalone calibration firmware. Flashing it replaces the running Home Key application on the ESP32; keep that project's build/source so you can flash it back afterwards. Disconnect the PN532 from GPIO3/GPIO4 while using these defaults.
+
+## Wiring
+
+| LD2410S | ESP32-C3 |
+| --- | --- |
+| 3V3 | Regulated 3V3 output |
+| GND | GND |
+| OT1 / TX / TX0 | **GPIO3** (ESP RX) |
+| RX / RX0 | **GPIO4** (ESP TX) |
+| OT2 | Leave disconnected |
+
+UART1 runs at 115200 baud, 8N1. The ESP's USB console remains available. Sensor power and logic must be 3.3 V; do not connect 5 V or an unregulated 21700. After flashing, a USB wall supply or power bank can power the ESP board. Check that your power bank stays on at this load.
+
+Fix the sensor firmly in position, with the antenna side facing the area to test. Avoid moving it during measurements or calibration.
+
+## Connect wirelessly
+
+The default firmware creates:
+
+- **Wi-Fi SSID:** `LD2410S-Bench`
+- **Password:** `calibrate2410`
+- **Dashboard:** http://192.168.4.1/
+
+Join that Wi-Fi on your Mac or phone. It has no internet; remain connected if your operating system asks. Open the dashboard manually in Firefox/Safari/Chrome. There is no captive portal or cloud service.
+
+At boot, the ESP automatically attempts to read the sensor settings. Click **Read / retry sensor** if the sensor was wired or powered later. Enable **Show gate energy** to receive all 16 gate energy values. Distance is shown only for a detected person; the dashboard marks old readings as stale.
+
+## Optional: use your home Wi-Fi
+
+To access it from your existing home network, build from source and run `idf.py menuconfig`. Under **LD2410S bench**, set:
+
+- Optional home Wi-Fi SSID
+- Optional home Wi-Fi password
+
+The bench AP remains available. The home network IP appears in the USB log and on the dashboard after connection. Use that numeric IP to open it from your normal Wi-Fi; mDNS and custom hostnames are not enabled. A failed home network connection does not disable the bench AP.
+
+The same menu changes UART pins and the AP password. The supplied binary uses the defaults above. Wi-Fi credentials set in menuconfig are compiled into that customized firmware; don't share it or its generated sdkconfig.
+
+## Flash the included default binaries
+
+These are legacy binaries: **they do not include the calibration progress fix**. For calibration, use the source build instructions below.
+
+The `firmware/` folder contains ESP32-C3 binaries compiled with ESP-IDF 5.4.1, using the default AP and UART pins above. From this directory, in your usual ESP-IDF environment:
+
+```sh
+python3 flash.py --port /dev/cu.usbmodem101
+```
+
+If you aren't in an ESP-IDF environment, install esptool in a virtual environment first:
+
+```sh
+python3 -m venv .flash-venv
+source .flash-venv/bin/activate
+python3 -m pip install 'esptool>=4.8,<5'
+python3 flash.py --port /dev/cu.usbmodem101
+```
+
+The script verifies SHA-256 checksums and flashes only the bootloader, partition table and application at their individual offsets. It does not write a merged image across the NVS region or run erase-flash. Use `--baud 115200` if the serial adapter is unreliable at 460800. If auto-reset fails, hold BOOT, tap RESET, release BOOT, and retry; reset the board after flashing if it remains in download mode.
+
+After flashing, join `LD2410S-Bench` and open http://192.168.4.1/. For different pins or home Wi-Fi credentials, rebuild and flash from source instead of using these default binaries.
+
+## Build and flash from source
+
+Use ESP-IDF **5.4.1 or newer**, including 6.1, with ESP32-C3 tools installed. IDF 5.x uses its built-in JSON component; IDF 6.x downloads `espressif/cjson` using `main/idf_component.yml`. Leave the IDF Component Manager enabled (the default) and allow internet access for that first download. From this project directory, in your normal ESP-IDF shell:
+
+```sh
+idf.py set-target esp32c3
+idf.py menuconfig # optional: Wi-Fi credentials / UART pins
+idf.py build
+idf.py -p /dev/cu.usbmodem101 flash monitor
+```
+
+Exit the serial monitor with Ctrl-]. If that port isn't present, list `/dev/cu.*` and use the actual board device. The project defaults to the C3 native USB Serial/JTAG console and a 2 MB flash layout. If your board uses a separate USB–UART bridge, choose UART console in menuconfig instead.
+
+If an older copy reports `Failed to resolve component 'json'` on IDF 6.x, replace `main/CMakeLists.txt` and add `main/idf_component.yml` from this package, then run `idf.py reconfigure` before `idf.py menuconfig`. Keep your existing `sdkconfig` to retain your Wi-Fi and pin choices.
+
+No erase-flash step is needed. Bootloader, partition table, and application are flashed; the normal commands leave NVS intact. Startup deliberately does not automatically erase NVS if initialization fails. If you hit an NVS initialization error, inspect your board's existing data/layout before deciding whether to erase it.
+
+## Calibration workflow
+
+1. Mount the sensor in the final orientation and leave normal stationary surroundings in place.
+2. Read settings and download the current settings JSON.
+3. Enable gate energy. Walk towards it, stand still, walk away, and check for responses outside the intended range.
+4. Set distance gates, response speed and report frequency, then click **Apply settings & verify**.
+5. Start automatic calibration. The default **15-second start delay** gives you time to leave the room; the default scan lasts **120 seconds**, with trigger/hold factors **2/1**.
+6. Keep the detection area empty throughout the scan. Watch progress remotely. Only a device progress report of **100%** means complete; elapsed time alone is not treated as success.
+7. Click **Read from sensor** after completion to load the generated thresholds.
+8. Test again, then switch to **Minimal output** before returning to the Home Key firmware.
+
+Calibration and serial commands run in a worker task, so the web dashboard keeps responding during the countdown and scan. Keep the board powered and do not move it during a scan. There is no undocumented stop/cancel command; wait for completion rather than interrupting power.
+
+## Backups and exports
+
+Before every settings write or automatic calibration, the ESP reads the current settings and saves them to its own NVS flash. If reading or saving fails, it refuses the mutation. **One pre-change backup** is retained across reboots; the next mutation replaces it.
+
+- **Download current settings** exports the last successfully read settings. Read first if you need a fresh snapshot.
+- **Download pre-change backup** exports that retained snapshot.
+- **Restore pre-change backup** writes it back and verifies readback. The pre-restore settings become the new pre-change backup, allowing an undo.
+- **Load backup into editor** accepts the Python tool's JSON format too. It doesn't write anything until Apply is clicked.
+- CSV exports the last **120 readings**, including gate energy when standard output is enabled. Times are ESP uptime in milliseconds, not wall-clock timestamps. The ring buffer resets on reboot.
+
+Writes are multiple sensor commands, not an atomic transaction. A failed write can leave some values changed; use Read and then restore the backup. The tool verifies all settings by reading them back after a successful write. UART commands/replies appear in the web diagnostics and USB console.
+
+## Limits
+
+This is a USB-powered bench tool with active Wi-Fi, not the final battery-optimized reader firmware. Restoring minimal sensor output does not disable ESP Wi-Fi.
+
+The sensor's published absence delay is 10–120 s. It is coarse human-presence radar, not an NFC-range hand detector. Range gates are approximately 0.70 m steps. The dashboard preserves raw energy values without guessing undocumented scale conversions.
+
+Targets the newer LD2410S UART protocol and your module's **1.1.1** firmware. Firmware is decoded from the trailing three 16-bit words for the observed reply lengths, with the full raw reply retained. Legacy firmware may require different threshold semantics.
+
+The web UI is served over local HTTP. Keep the board on your local network; no internet exposure is needed. The AP uses WPA2, and API requests use a per-boot token. Set a different AP password in menuconfig if necessary.
+
+## Developer checks
+
+Portable protocol tests:
+
+```sh
+cc -std=c11 -Wall -Wextra -Werror -I main main/protocol.c tests/test_protocol.c -o /tmp/ld2410s-test
+/tmp/ld2410s-test
+```
+
+For the optional dashboard logic checks (Node.js 18+):
+
+```sh
+node tests/test_ui.js
+```
+
+The supplied ESP32-C3 firmware was compiled successfully with ESP-IDF 5.4.1 (no compiler warnings). The app occupies 0xca490 bytes, leaving 21% of its 1 MiB app partition free. The source dependency configuration now handles IDF 6.x using Espressif's documented cJSON migration; a full IDF 6.1 firmware build has not been verified here. Portable decoder tests passed with address/undefined-behavior sanitizers, and dashboard JavaScript was checked for syntax and its settings/job/calibration flows. A physical ESP32-C3 and LD2410S were not attached during development; your first flash is the hardware test.
+
+The UART decoder handles minimal frames, standard frames, calibration progress and interleaved replies, including fragmented/noisy data. A truncated partial frame is discarded after 500 ms of serial inactivity. JSON parameter ranges are checked before jobs are queued.
+
+References:
+
+- [Hi-Link LD2410S UART protocol V1.00](https://h.hlktech.com/download/HLK-LD2410S-24G/1/HLK-LD2410S-%E4%B8%B2%E5%8F%A3%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%AE%AE-V1.00%20.pdf)
+- [ESP-IDF ESP32-C3 UART documentation](https://docs.espressif.com/projects/esp-idf/en/v5.4.1/esp32c3/api-reference/peripherals/uart.html)
diff --git a/esp32/tools/ld2410s-c3-calib/flash.py b/esp32/tools/ld2410s-c3-calib/flash.py
new file mode 100644
index 0000000..e1f3643
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/flash.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Flash the included default ESP32-C3 binaries without erasing the NVS gap."""
+import argparse
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--port',required=True,help='e.g. /dev/cu.usbmodem101')
+ p.add_argument('--baud',type=int,default=460800)
+ a=p.parse_args()
+ if a.baud<=0:p.error('Baud must be positive')
+ root=Path(__file__).resolve().parent
+ manifest=json.loads((root/'firmware'/'manifest.json').read_text())
+ if importlib.util.find_spec('esptool') is None:
+ p.error("Activate your ESP-IDF environment, or install: python3 -m pip install 'esptool>=4.8,<5'")
+ cmd=[sys.executable,'-m','esptool','--chip',manifest['chip'],'--port',a.port,
+ '--baud',str(a.baud),'--before','default_reset','--after','hard_reset']
+ if manifest.get('no_stub'):cmd.append('--no-stub')
+ cmd.append('write_flash')
+ for flag,value in manifest['flash_settings'].items():cmd.extend(['--'+flag,str(value)])
+ for item in manifest['files']:
+ path=root/'firmware'/item['name']
+ if hashlib.sha256(path.read_bytes()).hexdigest()!=item['sha256']:
+ p.error('Firmware checksum mismatch: '+item['name'])
+ cmd.extend([item['offset'],str(path)])
+ print('Flashing LD2410S bench firmware to '+a.port+'. This replaces the running ESP32 application.',flush=True)
+ return subprocess.run(cmd,check=False).returncode
+
+if __name__=='__main__':sys.exit(main())
diff --git a/esp32/tools/ld2410s-c3-calib/main/CMakeLists.txt b/esp32/tools/ld2410s-c3-calib/main/CMakeLists.txt
new file mode 100644
index 0000000..a7c8289
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/CMakeLists.txt
@@ -0,0 +1,11 @@
+include($ENV{IDF_PATH}/tools/cmake/version.cmake)
+set(bench_requires esp_wifi esp_event esp_netif esp_http_server esp_timer esp_driver_uart nvs_flash)
+# IDF 6 moved cJSON to the Component Registry; the manifest adds it there.
+if(IDF_VERSION_MAJOR LESS 6)
+ list(APPEND bench_requires json)
+endif()
+
+idf_component_register(SRCS "main.c" "radar.c" "protocol.c"
+ INCLUDE_DIRS "."
+ REQUIRES ${bench_requires}
+ EMBED_TXTFILES "web.html")
diff --git a/esp32/tools/ld2410s-c3-calib/main/Kconfig.projbuild b/esp32/tools/ld2410s-c3-calib/main/Kconfig.projbuild
new file mode 100644
index 0000000..222e1a2
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/Kconfig.projbuild
@@ -0,0 +1,22 @@
+menu "LD2410S bench"
+ config BENCH_UART_TX
+ int "ESP32 UART TX GPIO (connect to sensor RX)"
+ range 0 10
+ default 4
+ config BENCH_UART_RX
+ int "ESP32 UART RX GPIO (connect to sensor TX/OT1)"
+ range 0 10
+ default 3
+ config BENCH_AP_SSID
+ string "Bench access point SSID"
+ default "LD2410S-Bench"
+ config BENCH_AP_PASSWORD
+ string "Bench access point password (8 to 63 characters)"
+ default "calibrate2410"
+ config BENCH_STA_SSID
+ string "Optional home Wi-Fi SSID (empty = AP only)"
+ default ""
+ config BENCH_STA_PASSWORD
+ string "Optional home Wi-Fi password"
+ default ""
+endmenu
diff --git a/esp32/tools/ld2410s-c3-calib/main/idf_component.yml b/esp32/tools/ld2410s-c3-calib/main/idf_component.yml
new file mode 100644
index 0000000..f533865
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/idf_component.yml
@@ -0,0 +1,6 @@
+dependencies:
+ idf: ">=5.4.1"
+ espressif/cjson:
+ version: "^1.7.19"
+ rules:
+ - if: "idf_version >=6.0"
diff --git a/esp32/tools/ld2410s-c3-calib/main/main.c b/esp32/tools/ld2410s-c3-calib/main/main.c
new file mode 100644
index 0000000..146d645
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/main.c
@@ -0,0 +1,236 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <math.h>
+#include <inttypes.h>
+#include "sdkconfig.h"
+#include "esp_event.h"
+#include "esp_wifi.h"
+#include "esp_netif.h"
+#include "esp_http_server.h"
+#include "esp_log.h"
+#include "esp_mac.h"
+#include "esp_random.h"
+#include "esp_system.h"
+#include "esp_timer.h"
+#include "nvs_flash.h"
+#include "radar.h"
+
+static const char *TAG="bench";
+static char token[33];
+static esp_netif_t *station;
+extern const uint8_t web_start[] asm("_binary_web_html_start");
+extern const uint8_t web_end[] asm("_binary_web_html_end");
+
+static esp_err_t json_reply(httpd_req_t *req,cJSON *obj,const char *status) {
+ char *text=cJSON_PrintUnformatted(obj); cJSON_Delete(obj);
+ if(!text) return httpd_resp_send_err(req,HTTPD_500_INTERNAL_SERVER_ERROR,"Out of memory");
+ httpd_resp_set_type(req,"application/json"); httpd_resp_set_hdr(req,"Cache-Control","no-store");
+ if(status) httpd_resp_set_status(req,status);
+ esp_err_t err=httpd_resp_send(req,text,HTTPD_RESP_USE_STRLEN); free(text); return err;
+}
+static esp_err_t error_reply(httpd_req_t *req,const char *message,const char *status) {
+ cJSON *obj=cJSON_CreateObject(); cJSON_AddStringToObject(obj,"error",message); return json_reply(req,obj,status);
+}
+static bool valid_host(httpd_req_t *req) {
+ char host[64];
+ if(httpd_req_get_hdr_value_str(req,"Host",host,sizeof(host))!=ESP_OK) return false;
+ char *port=strchr(host,':');
+ if(port) { if(strcmp(port,":80")) return false; *port=0; }
+ if(!strcmp(host,"192.168.4.1")) return true;
+ if(station) {
+ esp_netif_ip_info_t ip;
+ if(esp_netif_get_ip_info(station,&ip)==ESP_OK && ip.ip.addr) {
+ char addr[24]; snprintf(addr,sizeof(addr),IPSTR,IP2STR(&ip.ip));
+ if(!strcmp(host,addr)) return true;
+ }
+ }
+ return false;
+}
+static bool authorized(httpd_req_t *req) {
+ if(!valid_host(req)) { error_reply(req,"Open the dashboard using the ESP32 IP address","403 Forbidden"); return false; }
+ char supplied[40];
+ if(httpd_req_get_hdr_value_str(req,"X-Bench-Token",supplied,sizeof(supplied))!=ESP_OK || strcmp(supplied,token)) {
+ error_reply(req,"Reload the dashboard to refresh the session","403 Forbidden"); return false;
+ }
+ return true;
+}
+static cJSON *reading_json(const ld_reading *r,uint32_t stamp) {
+ cJSON *o=cJSON_CreateObject();
+ cJSON_AddNumberToObject(o,"uptime_ms",stamp); cJSON_AddNumberToObject(o,"state",r->state);
+ cJSON_AddBoolToObject(o,"present",r->state>=2); cJSON_AddNumberToObject(o,"distance_cm",r->distance_cm);
+ cJSON_AddStringToObject(o,"mode",r->energy_valid?"standard":"minimal");
+ if(r->energy_valid) { cJSON *energy=cJSON_AddArrayToObject(o,"energy"); for(unsigned i=0;i<16;i++) cJSON_AddItemToArray(energy,cJSON_CreateNumber(r->energy[i])); }
+ return o;
+}
+static cJSON *info_json(const radar_state *s) {
+ cJSON *o=cJSON_CreateObject(); cJSON_AddStringToObject(o,"firmware",s->firmware);
+ cJSON_AddNumberToObject(o,"uart_rx",CONFIG_BENCH_UART_RX); cJSON_AddNumberToObject(o,"uart_tx",CONFIG_BENCH_UART_TX);
+ cJSON_AddStringToObject(o,"firmware_raw",s->firmware_raw); cJSON_AddStringToObject(o,"serial",s->serial); return o;
+}
+static radar_state *snapshot(void) {
+ radar_state *s=malloc(sizeof(*s)); if(s) radar_copy(s); return s;
+}
+static esp_err_t state_reply(httpd_req_t *req) {
+ radar_state *s=snapshot(); if(!s) return error_reply(req,"Out of memory","500 Internal Server Error");
+ cJSON *o=cJSON_CreateObject();
+ uint32_t now=(uint32_t)(esp_timer_get_time()/1000);
+ cJSON_AddBoolToObject(o,"connected",s->seen && (uint32_t)(now-s->last_ms)<10000);
+ cJSON_AddBoolToObject(o,"demo",false);
+ cJSON_AddItemToObject(o,"latest",s->seen?reading_json(&s->latest,s->last_ms):cJSON_CreateObject());
+ if(s->seen) cJSON_AddNumberToObject(o,"age",(uint32_t)(now-s->last_ms)/1000.0); else cJSON_AddNullToObject(o,"age");
+ if(s->settings_valid) cJSON_AddItemToObject(o,"settings",radar_settings_json(&s->settings)); else cJSON_AddNullToObject(o,"settings");
+ cJSON_AddItemToObject(o,"info",info_json(s)); cJSON_AddStringToObject(o,"error",s->error);
+ cJSON_AddNumberToObject(o,"reports",s->reports); cJSON_AddNumberToObject(o,"samples",s->history_count);
+ cJSON_AddNumberToObject(o,"bad_bytes",s->bad_bytes); cJSON_AddNumberToObject(o,"heap_free",esp_get_free_heap_size());
+ cJSON_AddBoolToObject(o,"backup_available",s->backup_valid);
+ cJSON *job=cJSON_AddObjectToObject(o,"job"); cJSON_AddNumberToObject(job,"id",s->job_id);
+ cJSON_AddBoolToObject(job,"busy",s->job_busy); cJSON_AddStringToObject(job,"action",s->job_action); cJSON_AddStringToObject(job,"status",s->job_status);
+ if(s->calibration_state) {
+ static const char *names[]={"idle","countdown — leave the detection area","running","complete — read settings to see new thresholds","failed or timed out — completion unconfirmed"};
+ cJSON *c=cJSON_AddObjectToObject(o,"calibration"); cJSON_AddNumberToObject(c,"state",s->calibration_state);
+ cJSON_AddStringToObject(c,"status",names[s->calibration_state]); cJSON_AddNumberToObject(c,"progress",s->calibration_progress/100.0);
+ cJSON_AddNumberToObject(c,"remaining_s",s->calibration_state==1?(int32_t)(s->calibration_due_ms-now)/1000.0:0);
+ } else cJSON_AddNullToObject(o,"calibration");
+ cJSON *history=cJSON_AddArrayToObject(o,"history");
+ for(unsigned i=0;i<s->history_count;i++) {
+ unsigned index=(s->history_next+HISTORY_SIZE-s->history_count+i)%HISTORY_SIZE;
+ const radar_sample *r=&s->history[index]; cJSON *item=cJSON_CreateObject();
+ cJSON_AddNumberToObject(item,"uptime_ms",r->uptime_ms); cJSON_AddNumberToObject(item,"distance_cm",r->reading.distance_cm);
+ cJSON_AddBoolToObject(item,"present",r->reading.state>=2); cJSON_AddItemToArray(history,item);
+ }
+ cJSON *logs=cJSON_AddArrayToObject(o,"logs");
+ for(unsigned i=0;i<s->log_count;i++) cJSON_AddItemToArray(logs,cJSON_CreateString(s->logs[(s->log_next+LOG_SIZE-s->log_count+i)%LOG_SIZE]));
+ cJSON *network=cJSON_AddObjectToObject(o,"network"); cJSON_AddStringToObject(network,"ap_ssid",CONFIG_BENCH_AP_SSID);
+ cJSON_AddStringToObject(network,"ap_ip","192.168.4.1");
+ if(station) { esp_netif_ip_info_t ip; if(esp_netif_get_ip_info(station,&ip)==ESP_OK && ip.ip.addr) { char addr[24]; snprintf(addr,sizeof(addr),IPSTR,IP2STR(&ip.ip)); cJSON_AddStringToObject(network,"sta_ip",addr); } }
+ free(s); return json_reply(req,o,NULL);
+}
+static esp_err_t backup_reply(httpd_req_t *req,bool previous) {
+ radar_state *s=snapshot(); if(!s) return error_reply(req,"Out of memory","500 Internal Server Error");
+ if(previous?!s->backup_valid:!s->settings_valid) { free(s); return error_reply(req,"No settings available; read the sensor first","409 Conflict"); }
+ cJSON *o=cJSON_CreateObject(); cJSON_AddStringToObject(o,"format","ld2410s-settings-v1");
+ cJSON_AddItemToObject(o,"info",info_json(s)); cJSON_AddItemToObject(o,"settings",radar_settings_json(previous?&s->backup:&s->settings));
+ free(s); return json_reply(req,o,NULL);
+}
+static esp_err_t csv_reply(httpd_req_t *req) {
+ radar_state *s=snapshot(); if(!s) return error_reply(req,"Out of memory","500 Internal Server Error");
+ httpd_resp_set_type(req,"text/csv"); httpd_resp_set_hdr(req,"Cache-Control","no-store");
+ char line[400]; int len=snprintf(line,sizeof(line),"uptime_ms,state,present,distance_cm");
+ for(unsigned i=0;i<16;i++) len+=snprintf(line+len,sizeof(line)-len,",energy_%u",i);
+ len+=snprintf(line+len,sizeof(line)-len,"\n");
+ esp_err_t err=httpd_resp_send_chunk(req,line,len);
+ for(unsigned i=0;i<s->history_count && err==ESP_OK;i++) {
+ const radar_sample *r=&s->history[(s->history_next+HISTORY_SIZE-s->history_count+i)%HISTORY_SIZE];
+ len=snprintf(line,sizeof(line),"%"PRIu32",%u,%u,%u",r->uptime_ms,r->reading.state,r->reading.state>=2,r->reading.distance_cm);
+ for(unsigned k=0;k<16;k++) {
+ if(r->reading.energy_valid) len+=snprintf(line+len,sizeof(line)-len,",%"PRIu32,r->reading.energy[k]);
+ else len+=snprintf(line+len,sizeof(line)-len,",");
+ }
+ len+=snprintf(line+len,sizeof(line)-len,"\n"); err=httpd_resp_send_chunk(req,line,len);
+ }
+ free(s); if(err==ESP_OK) err=httpd_resp_send_chunk(req,NULL,0); return err;
+}
+static esp_err_t root(httpd_req_t *req) {
+ if(!valid_host(req)) return error_reply(req,"Open the dashboard using the ESP32 IP address","403 Forbidden");
+ httpd_resp_set_type(req,"text/html; charset=utf-8"); httpd_resp_set_hdr(req,"Cache-Control","no-store");
+ const char *start=(const char *)web_start,*marker=strstr(start,"__TOKEN__");
+ if(!marker) return httpd_resp_send_err(req,HTTPD_500_INTERNAL_SERVER_ERROR,"Missing UI token marker");
+ esp_err_t err=httpd_resp_send_chunk(req,start,marker-start);
+ if(err==ESP_OK) err=httpd_resp_send_chunk(req,token,strlen(token));
+ if(err==ESP_OK) err=httpd_resp_send_chunk(req,marker+9,(const char *)web_end-marker-9-1);
+ if(err==ESP_OK) err=httpd_resp_send_chunk(req,NULL,0);
+ return err;
+}
+static esp_err_t get_api(httpd_req_t *req) {
+ if(!authorized(req)) return ESP_OK;
+ if(!strcmp(req->uri,"/api/state")) return state_reply(req);
+ if(!strcmp(req->uri,"/api/backup")) return backup_reply(req,false);
+ if(!strcmp(req->uri,"/api/previous-backup")) return backup_reply(req,true);
+ if(!strcmp(req->uri,"/api/csv")) return csv_reply(req);
+ return error_reply(req,"Not found","404 Not Found");
+}
+static bool integer(const cJSON *o,const char *key,unsigned lo,unsigned hi,uint16_t *out) {
+ const cJSON *v=cJSON_GetObjectItemCaseSensitive(o,key);
+ if(!cJSON_IsNumber(v) || !isfinite(v->valuedouble) || v->valuedouble<lo || v->valuedouble>hi || floor(v->valuedouble)!=v->valuedouble) return false;
+ *out=(uint16_t)v->valuedouble; return true;
+}
+static esp_err_t post_api(httpd_req_t *req) {
+ if(!authorized(req)) return ESP_OK;
+ if(req->content_len<2 || req->content_len>4096) return error_reply(req,"Invalid request size","400 Bad Request");
+ char *text=malloc(req->content_len+1); if(!text) return error_reply(req,"Out of memory","500 Internal Server Error");
+ size_t used=0;
+ while(used<req->content_len) {
+ int n=httpd_req_recv(req,text+used,req->content_len-used);
+ if(n<=0) { free(text); return error_reply(req,"Request receive failed","408 Request Timeout"); }
+ used+=n;
+ }
+ text[used]=0; cJSON *args=cJSON_Parse(text); free(text);
+ if(!cJSON_IsObject(args)) { cJSON_Delete(args); return error_reply(req,"Invalid JSON object","400 Bad Request"); }
+ radar_job j={0}; const char *error=NULL;
+ if(!strcmp(req->uri,"/api/read")) j.kind=JOB_READ;
+ else if(!strcmp(req->uri,"/api/write") || !strcmp(req->uri,"/api/validate")) {
+ if(!radar_parse_settings(cJSON_GetObjectItemCaseSensitive(args,"settings"),&j.settings,&error)) { cJSON_Delete(args); return error_reply(req,error,"400 Bad Request"); }
+ if(!strcmp(req->uri,"/api/validate")) { cJSON_Delete(args); cJSON *o=cJSON_CreateObject(); cJSON_AddBoolToObject(o,"valid",true); return json_reply(req,o,NULL); }
+ j.kind=JOB_WRITE;
+ } else if(!strcmp(req->uri,"/api/mode")) {
+ const cJSON *v=cJSON_GetObjectItemCaseSensitive(args,"mode");
+ if(!cJSON_IsString(v) || (strcmp(v->valuestring,"standard") && strcmp(v->valuestring,"minimal"))) { cJSON_Delete(args); return error_reply(req,"Invalid mode","400 Bad Request"); }
+ j.kind=!strcmp(v->valuestring,"standard")?JOB_STANDARD:JOB_MINIMAL;
+ } else if(!strcmp(req->uri,"/api/calibrate")) {
+ j.kind=JOB_CALIBRATE;
+ if(!integer(args,"trigger",1,65535,&j.trigger) || !integer(args,"hold",1,65535,&j.hold) || !integer(args,"seconds",10,600,&j.seconds) || !integer(args,"delay",0,120,&j.delay)) { cJSON_Delete(args); return error_reply(req,"Invalid calibration factors, scan time, or start delay","400 Bad Request"); }
+ } else if(!strcmp(req->uri,"/api/restore")) {
+ radar_state *s=snapshot();
+ if(!s) { cJSON_Delete(args); return error_reply(req,"Out of memory","500 Internal Server Error"); }
+ if(!s->backup_valid) { free(s); cJSON_Delete(args); return error_reply(req,"No previous backup available","409 Conflict"); }
+ j.kind=JOB_RESTORE; j.settings=s->backup; free(s);
+ } else { cJSON_Delete(args); return error_reply(req,"Not found","404 Not Found"); }
+ cJSON_Delete(args); uint32_t id;
+ if(!radar_submit(&j,&id,&error)) return error_reply(req,error,"409 Conflict");
+ cJSON *o=cJSON_CreateObject(); cJSON_AddNumberToObject(o,"job_id",id); return json_reply(req,o,"202 Accepted");
+}
+static void wifi_event(void *arg,esp_event_base_t base,int32_t id,void *data) {
+ (void)arg;
+ if(base==WIFI_EVENT && id==WIFI_EVENT_STA_START) esp_wifi_connect();
+ if(base==WIFI_EVENT && id==WIFI_EVENT_STA_DISCONNECTED) { ESP_LOGW(TAG,"Home Wi-Fi disconnected; AP remains available; retrying"); esp_wifi_connect(); }
+ if(base==IP_EVENT && id==IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event=data; ESP_LOGI(TAG,"Home network dashboard: http://"IPSTR"/",IP2STR(&event->ip_info.ip)); }
+}
+static void wifi_start(void) {
+ size_t ap_ssid_len=strlen(CONFIG_BENCH_AP_SSID),ap_password_len=strlen(CONFIG_BENCH_AP_PASSWORD);
+ size_t sta_ssid_len=strlen(CONFIG_BENCH_STA_SSID),sta_password_len=strlen(CONFIG_BENCH_STA_PASSWORD);
+ if(!ap_ssid_len || ap_ssid_len>32 || ap_password_len<8 || ap_password_len>63 || sta_ssid_len>32 || sta_password_len>63) { ESP_LOGE(TAG,"Invalid Wi-Fi configuration; check menuconfig"); abort(); }
+ ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
+ esp_netif_create_default_wifi_ap();
+ if(sta_ssid_len) station=esp_netif_create_default_wifi_sta();
+ wifi_init_config_t config=WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&config));
+ ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL));
+ ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,wifi_event,NULL));
+ ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
+ ESP_ERROR_CHECK(esp_wifi_set_mode(sta_ssid_len?WIFI_MODE_APSTA:WIFI_MODE_AP));
+ wifi_config_t ap={.ap={.ssid_len=ap_ssid_len,.channel=1,.authmode=WIFI_AUTH_WPA2_PSK,.max_connection=3,.pmf_cfg={.required=false}}};
+ memcpy(ap.ap.ssid,CONFIG_BENCH_AP_SSID,ap_ssid_len); memcpy(ap.ap.password,CONFIG_BENCH_AP_PASSWORD,ap_password_len);
+ ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP,&ap));
+ if(sta_ssid_len) {
+ wifi_config_t sta={0}; memcpy(sta.sta.ssid,CONFIG_BENCH_STA_SSID,sta_ssid_len); memcpy(sta.sta.password,CONFIG_BENCH_STA_PASSWORD,sta_password_len);
+ ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&sta));
+ }
+ ESP_ERROR_CHECK(esp_wifi_start()); ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
+ ESP_LOGI(TAG,"Connect to Wi-Fi '%s'; open http://192.168.4.1/",CONFIG_BENCH_AP_SSID);
+}
+void app_main(void) {
+ /* Preserve existing NVS; do not automatically erase an old board's data. */
+ ESP_ERROR_CHECK(nvs_flash_init());
+ if(CONFIG_BENCH_UART_TX==CONFIG_BENCH_UART_RX) { ESP_LOGE(TAG,"UART TX and RX must use different pins"); abort(); }
+ for(unsigned i=0;i<4;i++) snprintf(token+8*i,9,"%08"PRIx32,esp_random());
+ radar_start(); wifi_start();
+ httpd_config_t cfg=HTTPD_DEFAULT_CONFIG(); cfg.uri_match_fn=httpd_uri_match_wildcard;
+ cfg.stack_size=8192; cfg.lru_purge_enable=true; cfg.max_open_sockets=4;
+ httpd_handle_t server; ESP_ERROR_CHECK(httpd_start(&server,&cfg));
+ const httpd_uri_t routes[]={
+ {.uri="/",.method=HTTP_GET,.handler=root},
+ {.uri="/api/*",.method=HTTP_GET,.handler=get_api},
+ {.uri="/api/*",.method=HTTP_POST,.handler=post_api},
+ };
+ for(unsigned i=0;i<sizeof(routes)/sizeof(routes[0]);i++) ESP_ERROR_CHECK(httpd_register_uri_handler(server,&routes[i]));
+}
diff --git a/esp32/tools/ld2410s-c3-calib/main/protocol.c b/esp32/tools/ld2410s-c3-calib/main/protocol.c
new file mode 100644
index 0000000..a04fd97
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/protocol.c
@@ -0,0 +1,74 @@
+#include "protocol.h"
+#include <string.h>
+static const uint8_t ch[] = {0xfd,0xfc,0xfb,0xfa}, ct[] = {4,3,2,1};
+static const uint8_t dh[] = {0xf4,0xf3,0xf2,0xf1}, dt[] = {0xf8,0xf7,0xf6,0xf5};
+uint16_t ld_u16(const uint8_t *p) { return (uint16_t)p[0] | (uint16_t)p[1]<<8; }
+uint32_t ld_u32(const uint8_t *p) { return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; }
+void ld_put16(uint8_t *p, uint16_t v) { p[0]=v; p[1]=v>>8; }
+void ld_put32(uint8_t *p, uint32_t v) { for(unsigned i=0;i<4;i++) p[i]=v>>(8*i); }
+size_t ld_command(uint16_t command, const uint8_t *payload, size_t size, uint8_t *out, size_t capacity) {
+ if(size > LD_PAYLOAD_MAX-2 || capacity < size+12 || (size && !payload)) return 0;
+ memcpy(out,ch,4); ld_put16(out+4,size+2); ld_put16(out+6,command);
+ if(size) memcpy(out+8,payload,size);
+ memcpy(out+8+size,ct,4); return size+12;
+}
+static void consume(ld_parser *p,size_t n) {
+ memmove(p->bytes,p->bytes+n,p->used-n); p->used-=n;
+}
+static void parse(ld_parser *p,ld_frame_cb cb,void *ctx) {
+ while(p->used) {
+ uint8_t *b=p->bytes;
+ if(b[0]==0x6e) {
+ if(p->used<5) return;
+ if(b[1]<=3 && b[4]==0x62) { cb(LD_MINIMAL,b,5,ctx); consume(p,5); continue; }
+ } else if(b[0]==ch[0] || b[0]==dh[0]) {
+ const uint8_t *head=b[0]==ch[0]?ch:dh,*tail=b[0]==ch[0]?ct:dt;
+ ld_frame_kind kind=b[0]==ch[0]?LD_ACK:LD_DATA;
+ if(p->used<4 && !memcmp(b,head,p->used)) return;
+ if(p->used>=4 && !memcmp(b,head,4)) {
+ if(p->used<6) return;
+ size_t n=ld_u16(b+4);
+ if(n>=1 && n<=LD_PAYLOAD_MAX) {
+ if(p->used<n+10) return;
+ if(!memcmp(b+6+n,tail,4)) { cb(kind,b+6,n,ctx); consume(p,n+10); continue; }
+ }
+ }
+ }
+ p->bad_bytes++; consume(p,1);
+ }
+}
+void ld_feed(ld_parser *p,const uint8_t *data,size_t size,ld_frame_cb cb,void *ctx) {
+ for(size_t i=0;i<size;i++) {
+ if(p->used==sizeof(p->bytes)) { p->bad_bytes++; consume(p,1); }
+ p->bytes[p->used++]=data[i]; parse(p,cb,ctx);
+ }
+}
+bool ld_decode_reading(ld_frame_kind kind,const uint8_t *data,size_t size,ld_reading *out) {
+ memset(out,0,sizeof(*out));
+ if(kind==LD_MINIMAL && size==5 && data[1]<=3) {
+ out->state=data[1]; out->distance_cm=ld_u16(data+2); return true;
+ }
+ if(kind==LD_DATA && size==70 && data[0]==1 && data[1]<=3) {
+ out->state=data[1]; out->distance_cm=ld_u16(data+2); out->energy_valid=true;
+ for(unsigned i=0;i<LD_GATES;i++) out->energy[i]=ld_u32(data+6+4*i);
+ return true;
+ }
+ return false;
+}
+bool ld_decode_progress(ld_frame_kind kind,const uint8_t *data,size_t size,uint16_t *hundredths) {
+ if(kind!=LD_DATA || size!=3 || data[0]!=3) return false;
+ /* The wire value is an integer percentage: 100 (64 00) means complete.
+ * Keep the dashboard's internal hundredths-of-a-percent representation. */
+ uint16_t percent=ld_u16(data+1);
+ if(percent>100) return false;
+ *hundredths=percent*100; return true;
+}
+const char *ld_validate(const ld_settings *s) {
+ if(s->far_gate<1 || s->far_gate>16 || s->near_gate>s->far_gate) return "Invalid distance gates";
+ if(s->absence_s<10 || s->absence_s>120) return "Absence delay must be 10–120 s";
+ if(s->status_hz_x10<5 || s->status_hz_x10>80 || s->status_hz_x10%5 ||
+ s->distance_hz_x10<5 || s->distance_hz_x10>80 || s->distance_hz_x10%5) return "Report rates must use 0.5 Hz steps from 0.5 to 8 Hz";
+ if(s->response!=5 && s->response!=10) return "Response must be normal (5) or fast (10)";
+ for(unsigned i=0;i<LD_GATES;i++) if(s->trigger[i]<10 || s->trigger[i]>95 || s->hold[i]<10 || s->hold[i]>95) return "Thresholds must be 10–95";
+ return NULL;
+}
diff --git a/esp32/tools/ld2410s-c3-calib/main/protocol.h b/esp32/tools/ld2410s-c3-calib/main/protocol.h
new file mode 100644
index 0000000..6cad135
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/protocol.h
@@ -0,0 +1,33 @@
+#pragma once
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+#define LD_GATES 16
+#define LD_PAYLOAD_MAX 128
+
+typedef enum { LD_ACK, LD_MINIMAL, LD_DATA } ld_frame_kind;
+typedef void (*ld_frame_cb)(ld_frame_kind kind, const uint8_t *data, size_t size, void *ctx);
+typedef struct {
+ uint8_t bytes[LD_PAYLOAD_MAX + 10];
+ size_t used;
+ uint32_t bad_bytes;
+} ld_parser;
+typedef struct {
+ uint8_t state;
+ uint16_t distance_cm;
+ bool energy_valid;
+ uint32_t energy[LD_GATES];
+} ld_reading;
+typedef struct {
+ uint32_t far_gate, near_gate, absence_s, status_hz_x10, distance_hz_x10, response;
+ uint32_t trigger[LD_GATES], hold[LD_GATES];
+} ld_settings;
+uint16_t ld_u16(const uint8_t *p);
+uint32_t ld_u32(const uint8_t *p);
+void ld_put16(uint8_t *p, uint16_t v);
+void ld_put32(uint8_t *p, uint32_t v);
+size_t ld_command(uint16_t command, const uint8_t *payload, size_t size, uint8_t *out, size_t capacity);
+void ld_feed(ld_parser *p, const uint8_t *data, size_t size, ld_frame_cb cb, void *ctx);
+bool ld_decode_reading(ld_frame_kind kind, const uint8_t *data, size_t size, ld_reading *out);
+bool ld_decode_progress(ld_frame_kind kind, const uint8_t *data, size_t size, uint16_t *hundredths);
+const char *ld_validate(const ld_settings *s);
diff --git a/esp32/tools/ld2410s-c3-calib/main/radar.c b/esp32/tools/ld2410s-c3-calib/main/radar.c
new file mode 100644
index 0000000..8fd9ba5
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/radar.c
@@ -0,0 +1,301 @@
+#include "radar.h"
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <math.h>
+#include <inttypes.h>
+#include "sdkconfig.h"
+#include "driver/uart.h"
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "nvs.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/semphr.h"
+#include "freertos/queue.h"
+
+#define UART_PORT UART_NUM_1
+#define WAIT_MS 2500
+static radar_state st;
+static SemaphoreHandle_t state_mutex, ack_sem;
+static QueueHandle_t jobs;
+static uint16_t pending;
+static bool waiting;
+static uint8_t ack[LD_PAYLOAD_MAX];
+static size_t ack_size;
+static const char *TAG="radar";
+static uint32_t now_ms(void) { return (uint32_t)(esp_timer_get_time()/1000); }
+static void lock(void) { xSemaphoreTake(state_mutex,portMAX_DELAY); }
+static void unlock(void) { xSemaphoreGive(state_mutex); }
+static void note(const char *fmt,...) {
+ char text[LOG_LEN]; va_list ap; va_start(ap,fmt); vsnprintf(text,sizeof(text),fmt,ap); va_end(ap);
+ ESP_LOGI(TAG,"%s",text);
+ lock(); snprintf(st.logs[st.log_next],LOG_LEN,"%"PRIu32"s %.125s",now_ms()/1000,text);
+ st.log_next=(st.log_next+1)%LOG_SIZE; if(st.log_count<LOG_SIZE) st.log_count++; unlock();
+}
+void radar_copy(radar_state *out) {
+ lock();
+ if(st.calibration_state==2 && (uint32_t)(now_ms()-st.calibration_started_ms)>((uint32_t)st.calibration_seconds+30)*1000) {
+ st.calibration_state=4;
+ snprintf(st.error,sizeof(st.error),"Calibration timed out; completion unconfirmed. Read settings before trying again.");
+ }
+ memcpy(out,&st,sizeof(st)); unlock();
+}
+static void receive(ld_frame_kind kind,const uint8_t *data,size_t size,void *ctx) {
+ (void)ctx;
+ if(kind==LD_ACK && size>=2) {
+ uint16_t cmd=ld_u16(data);
+ bool signal=false;
+ lock();
+ if(cmd==0x109 && size>=4 && ld_u16(data+2) && st.calibration_state==2) {
+ st.calibration_state=4; snprintf(st.error,sizeof(st.error),"Sensor rejected calibration (status %u)",ld_u16(data+2));
+ }
+ if(waiting && cmd==pending) { memcpy(ack,data+2,size-2); ack_size=size-2; waiting=false; signal=true; }
+ unlock();
+ if(signal) xSemaphoreGive(ack_sem);
+ char hex[80]; size_t used=0;
+ for(size_t i=0;i<size && used+4<sizeof(hex);i++) used+=(size_t)snprintf(hex+used,sizeof(hex)-used,"%02x ",data[i]);
+ hex[used]=0; note("ACK %04x: %s",cmd,hex); return;
+ }
+ uint16_t progress;
+ if(ld_decode_progress(kind,data,size,&progress)) {
+ lock();
+ if(st.calibration_state==2) { st.calibration_progress=progress; if(progress==10000) st.calibration_state=3; }
+ unlock(); return;
+ }
+ ld_reading r;
+ if(ld_decode_reading(kind,data,size,&r)) {
+ uint32_t time=now_ms(); lock();
+ st.latest=r; st.last_ms=time; st.seen=true; st.reports++;
+ st.history[st.history_next]=(radar_sample){.uptime_ms=time,.reading=r};
+ st.history_next=(st.history_next+1)%HISTORY_SIZE;
+ if(st.history_count<HISTORY_SIZE) st.history_count++;
+ unlock(); return;
+ }
+ note("Unknown frame: type=%d bytes=%u",kind,(unsigned)size);
+}
+static void reader(void *arg) {
+ (void)arg; ld_parser parser={0}; uint8_t bytes[128]; uint32_t last_byte=now_ms();
+ for(;;) {
+ int n=uart_read_bytes(UART_PORT,bytes,sizeof(bytes),pdMS_TO_TICKS(100));
+ if(n>0) { ld_feed(&parser,bytes,n,receive,NULL); last_byte=now_ms(); }
+ else if(parser.used && (uint32_t)(now_ms()-last_byte)>500) { parser.bad_bytes+=parser.used; parser.used=0; }
+ lock(); st.bad_bytes=parser.bad_bytes; unlock();
+ }
+}
+static esp_err_t command(uint16_t cmd,const uint8_t *payload,size_t size,uint8_t *result,size_t *result_size) {
+ uint8_t bytes[LD_PAYLOAD_MAX+12]; size_t n=ld_command(cmd,payload,size,bytes,sizeof(bytes));
+ if(!n) return ESP_ERR_INVALID_SIZE;
+ while(xSemaphoreTake(ack_sem,0)==pdTRUE) {}
+ lock(); pending=cmd|0x100; waiting=true; ack_size=0; unlock();
+ note("TX %04x (%u bytes)",cmd,(unsigned)size);
+ if(uart_write_bytes(UART_PORT,bytes,n)!=(int)n) { lock(); waiting=false; unlock(); return ESP_FAIL; }
+ if(xSemaphoreTake(ack_sem,pdMS_TO_TICKS(WAIT_MS))!=pdTRUE) {
+ lock(); waiting=false; unlock(); note("No ACK for %04x",cmd); return ESP_ERR_TIMEOUT;
+ }
+ lock();
+ size_t count=ack_size;
+ if(result && result_size && *result_size>=count) memcpy(result,ack,count);
+ else if(result && count) { unlock(); return ESP_ERR_INVALID_SIZE; }
+ if(result_size) *result_size=count;
+ unlock(); return ESP_OK;
+}
+static esp_err_t checked(uint16_t cmd,const uint8_t *payload,size_t size,uint8_t *result,size_t *result_size) {
+ uint8_t raw[LD_PAYLOAD_MAX]; size_t n=sizeof(raw);
+ esp_err_t err=command(cmd,payload,size,raw,&n);
+ if(err!=ESP_OK) return err;
+ if(n<2) return ESP_ERR_INVALID_RESPONSE;
+ if(ld_u16(raw)!=0) { note("Rejected %04x, status %u",cmd,ld_u16(raw)); return ESP_FAIL; }
+ n-=2;
+ if(result) {
+ if(!result_size || *result_size<n) return ESP_ERR_INVALID_SIZE;
+ memcpy(result,raw+2,n);
+ }
+ if(result_size) *result_size=n;
+ return ESP_OK;
+}
+static esp_err_t begin(void) { const uint8_t v[]={1,0}; return checked(0xff,v,2,NULL,NULL); }
+static esp_err_t end(void) { return checked(0xfe,NULL,0,NULL,NULL); }
+static esp_err_t close_config(esp_err_t err) { esp_err_t exit_err=end(); return err==ESP_OK?exit_err:err; }
+static void settings_to_values(const ld_settings *s,uint32_t *v) {
+ v[0]=s->far_gate; v[1]=s->near_gate; v[2]=s->absence_s; v[3]=s->status_hz_x10; v[4]=s->distance_hz_x10; v[5]=s->response;
+}
+static void values_to_settings(const uint8_t *p,ld_settings *s) {
+ s->far_gate=ld_u32(p); s->near_gate=ld_u32(p+4); s->absence_s=ld_u32(p+8);
+ s->status_hz_x10=ld_u32(p+12); s->distance_hz_x10=ld_u32(p+16); s->response=ld_u32(p+20);
+}
+static esp_err_t read_settings(ld_settings *out) {
+ esp_err_t err=begin(); if(err!=ESP_OK) return err;
+ uint8_t raw[LD_PAYLOAD_MAX],ids[32]; size_t n=sizeof(raw);
+ err=command(0,NULL,0,raw,&n);
+ if(err==ESP_OK) {
+ lock(); size_t used=0;
+ for(size_t i=0;i<n && used+4<sizeof(st.firmware_raw);i++) used+=(size_t)snprintf(st.firmware_raw+used,sizeof(st.firmware_raw)-used,"%02x ",raw[i]);
+ st.firmware_raw[used]=0;
+ /* 1.1.1 module supplied by user: metadata + trailing 3 version words. */
+ if(n==6 || n==12 || n==14) snprintf(st.firmware,sizeof(st.firmware),"%u.%u.%u",ld_u16(raw+n-6),ld_u16(raw+n-4),ld_u16(raw+n-2));
+ else snprintf(st.firmware,sizeof(st.firmware),"Unknown (see raw reply)");
+ unlock();
+ }
+ if(err==ESP_OK) { n=sizeof(raw); err=checked(0x11,NULL,0,raw,&n); }
+ if(err==ESP_OK && n<2) err=ESP_ERR_INVALID_RESPONSE;
+ if(err==ESP_OK) {
+ size_t count=ld_u16(raw); if(count>n-2 || count>=sizeof(st.serial)) err=ESP_ERR_INVALID_RESPONSE;
+ else { lock(); memcpy(st.serial,raw+2,count); st.serial[count]=0; unlock(); }
+ }
+ static const uint16_t common_ids[]={5,10,6,2,12,11};
+ for(unsigned i=0;i<6;i++) ld_put16(ids+2*i,common_ids[i]);
+ if(err==ESP_OK) { n=sizeof(raw); err=checked(0x71,ids,12,raw,&n); }
+ if(err==ESP_OK) { if(n!=24) err=ESP_ERR_INVALID_RESPONSE; else values_to_settings(raw,out); }
+ for(unsigned i=0;i<16;i++) ld_put16(ids+2*i,i);
+ for(unsigned k=0;k<2 && err==ESP_OK;k++) {
+ n=sizeof(raw); err=checked(k?0x77:0x73,ids,32,raw,&n);
+ if(err==ESP_OK) {
+ if(n!=64) err=ESP_ERR_INVALID_RESPONSE;
+ else for(unsigned i=0;i<16;i++) (k?out->hold:out->trigger)[i]=ld_u32(raw+4*i);
+ }
+ }
+ err=close_config(err);
+ if(err==ESP_OK) { lock(); st.settings=*out; st.settings_valid=true; unlock(); }
+ return err;
+}
+static esp_err_t preserve(const ld_settings *s) {
+ nvs_handle_t handle; esp_err_t err=nvs_open("ld2410s_bench",NVS_READWRITE,&handle);
+ if(err!=ESP_OK) return err;
+ err=nvs_set_blob(handle,"previous",s,sizeof(*s));
+ if(err==ESP_OK) err=nvs_commit(handle);
+ nvs_close(handle);
+ if(err==ESP_OK) { lock(); st.backup=*s; st.backup_valid=true; unlock(); note("Previous settings saved to flash"); }
+ return err;
+}
+static esp_err_t write_settings(const ld_settings *target) {
+ if(ld_validate(target)) return ESP_ERR_INVALID_ARG;
+ ld_settings before;
+ esp_err_t err=read_settings(&before); if(err!=ESP_OK) return err;
+ err=preserve(&before); if(err!=ESP_OK) return err;
+ err=begin(); if(err!=ESP_OK) return err;
+ uint8_t payload[96]; uint32_t values[6]; settings_to_values(target,values);
+ static const uint16_t ids[]={5,10,6,2,12,11};
+ for(unsigned i=0;i<6;i++) { ld_put16(payload+6*i,ids[i]); ld_put32(payload+6*i+2,values[i]); }
+ err=checked(0x70,payload,36,NULL,NULL);
+ for(unsigned k=0;k<2 && err==ESP_OK;k++) {
+ for(unsigned i=0;i<16;i++) { ld_put16(payload+6*i,i); ld_put32(payload+6*i+2,(k?target->hold:target->trigger)[i]); }
+ err=checked(k?0x76:0x72,payload,96,NULL,NULL);
+ }
+ err=close_config(err);
+ if(err==ESP_OK) {
+ ld_settings actual;
+ err=read_settings(&actual);
+ if(err==ESP_OK && memcmp(&actual,target,sizeof(actual))) err=ESP_ERR_INVALID_RESPONSE;
+ }
+ if(err!=ESP_OK) note("Write failed: partial changes possible. Previous settings remain saved.");
+ return err;
+}
+static esp_err_t mode(bool standard) {
+ esp_err_t err=begin(); if(err!=ESP_OK) return err;
+ uint8_t v[6]={0}; ld_put32(v+2,standard?1:0);
+ return close_config(checked(0x7a,v,6,NULL,NULL));
+}
+static esp_err_t calibrate(const radar_job *j) {
+ ld_settings before; esp_err_t err=read_settings(&before); if(err!=ESP_OK) return err;
+ err=preserve(&before); if(err!=ESP_OK) return err;
+ lock(); st.calibration_state=1; st.calibration_progress=0; st.calibration_due_ms=now_ms()+j->delay*1000; st.calibration_seconds=j->seconds; unlock();
+ note("Calibration starts in %u seconds. Keep the sensor fixed and area empty.",j->delay);
+ vTaskDelay(pdMS_TO_TICKS(j->delay*1000));
+ err=begin(); if(err!=ESP_OK) return err;
+ uint8_t p[6],bytes[18]; ld_put16(p,j->trigger); ld_put16(p+2,j->hold); ld_put16(p+4,j->seconds);
+ size_t n=ld_command(9,p,6,bytes,sizeof(bytes));
+ lock(); st.calibration_state=2; st.calibration_started_ms=now_ms(); unlock();
+ if(uart_write_bytes(UART_PORT,bytes,n)!=(int)n) err=ESP_FAIL;
+ /* 0009 responds with progress frames, not a conventional ACK. */
+ err=close_config(err);
+ if(err==ESP_OK) note("Calibration scan requested; waiting for device progress");
+ return err;
+}
+static void worker(void *arg) {
+ (void)arg; radar_job j;
+ for(;;) {
+ xQueueReceive(jobs,&j,portMAX_DELAY);
+ esp_err_t err=ESP_OK;
+ switch(j.kind) {
+ case JOB_READ: { ld_settings current; err=read_settings(¤t); break; }
+ case JOB_WRITE: case JOB_RESTORE: err=write_settings(&j.settings); break;
+ case JOB_STANDARD: err=mode(true); break;
+ case JOB_MINIMAL: err=mode(false); break;
+ case JOB_CALIBRATE: err=calibrate(&j); break;
+ }
+ lock(); st.job_busy=false;
+ if(j.kind==JOB_CALIBRATE && st.calibration_state==4 && err==ESP_OK) err=ESP_FAIL;
+ if(err==ESP_OK) {
+ snprintf(st.job_status,sizeof(st.job_status),"%s",j.kind==JOB_CALIBRATE?"Scan requested — watch device progress":"Done");
+ if(j.kind!=JOB_CALIBRATE || st.calibration_state!=4) st.error[0]=0;
+ }
+ else {
+ snprintf(st.job_status,sizeof(st.job_status),"Failed: %s. Check UART wiring/logs; a write may be partial.",esp_err_to_name(err));
+ snprintf(st.error,sizeof(st.error),"%s",st.job_status);
+ if(j.kind==JOB_CALIBRATE) st.calibration_state=4;
+ }
+ unlock(); note("Job finished: %s",esp_err_to_name(err));
+ }
+}
+bool radar_submit(const radar_job *j,uint32_t *id,const char **error) {
+ static const char *names[]={"read","write","standard","minimal","calibrate","restore"};
+ lock();
+ if(st.job_busy || st.calibration_state==1 || st.calibration_state==2) { unlock(); *error="A job or calibration is already running"; return false; }
+ st.job_busy=true; st.job_id++; *id=st.job_id; st.error[0]=0;
+ snprintf(st.job_action,sizeof(st.job_action),"%s",names[j->kind]); snprintf(st.job_status,sizeof(st.job_status),"Working");
+ unlock();
+ if(xQueueSend(jobs,j,0)!=pdTRUE) { lock(); st.job_busy=false; unlock(); *error="Job queue is full"; return false; }
+ return true;
+}
+void radar_start(void) {
+ state_mutex=xSemaphoreCreateMutex(); ack_sem=xSemaphoreCreateBinary(); jobs=xQueueCreate(1,sizeof(radar_job));
+ configASSERT(state_mutex && ack_sem && jobs);
+ uart_config_t config={.baud_rate=115200,.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_ERROR_CHECK(uart_param_config(UART_PORT,&config));
+ ESP_ERROR_CHECK(uart_set_pin(UART_PORT,CONFIG_BENCH_UART_TX,CONFIG_BENCH_UART_RX,UART_PIN_NO_CHANGE,UART_PIN_NO_CHANGE));
+ ESP_ERROR_CHECK(uart_driver_install(UART_PORT,4096,0,0,NULL,0));
+ nvs_handle_t h;
+ if(nvs_open("ld2410s_bench",NVS_READONLY,&h)==ESP_OK) {
+ size_t size=sizeof(st.backup);
+ st.backup_valid=nvs_get_blob(h,"previous",&st.backup,&size)==ESP_OK && size==sizeof(st.backup);
+ nvs_close(h);
+ }
+ configASSERT(xTaskCreate(reader,"radar_rx",4096,NULL,5,NULL)==pdPASS);
+ configASSERT(xTaskCreate(worker,"radar_jobs",6144,NULL,4,NULL)==pdPASS);
+ note("115200 8N1; ESP TX GPIO%d -> sensor RX; ESP RX GPIO%d <- sensor OT1",CONFIG_BENCH_UART_TX,CONFIG_BENCH_UART_RX);
+ vTaskDelay(pdMS_TO_TICKS(1500));
+ radar_job j={.kind=JOB_READ}; uint32_t id; const char *error;
+ radar_submit(&j,&id,&error);
+}
+static const char *keys[]={"far_gate","near_gate","absence_s","status_hz_x10","distance_hz_x10","response"};
+cJSON *radar_settings_json(const ld_settings *s) {
+ cJSON *o=cJSON_CreateObject(),*common=cJSON_AddObjectToObject(o,"common");
+ uint32_t v[6]; settings_to_values(s,v);
+ for(unsigned i=0;i<6;i++) cJSON_AddNumberToObject(common,keys[i],v[i]);
+ cJSON *trigger=cJSON_AddArrayToObject(o,"trigger"),*hold=cJSON_AddArrayToObject(o,"hold");
+ for(unsigned i=0;i<16;i++) { cJSON_AddItemToArray(trigger,cJSON_CreateNumber(s->trigger[i])); cJSON_AddItemToArray(hold,cJSON_CreateNumber(s->hold[i])); }
+ return o;
+}
+static bool number(const cJSON *o,const char *key,uint32_t *out) {
+ const cJSON *v=cJSON_GetObjectItemCaseSensitive(o,key);
+ if(!cJSON_IsNumber(v) || !isfinite(v->valuedouble) || v->valuedouble<0 || v->valuedouble>UINT32_MAX || floor(v->valuedouble)!=v->valuedouble) return false;
+ *out=(uint32_t)v->valuedouble; return true;
+}
+bool radar_parse_settings(const cJSON *o,ld_settings *out,const char **error) {
+ memset(out,0,sizeof(*out));
+ const cJSON *common=cJSON_GetObjectItemCaseSensitive(o,"common"); uint32_t v[6];
+ if(!cJSON_IsObject(o) || cJSON_GetArraySize(o)!=3 || !cJSON_IsObject(common) || cJSON_GetArraySize(common)!=6) { *error="Settings need common, trigger and hold"; return false; }
+ for(unsigned i=0;i<6;i++) if(!number(common,keys[i],&v[i])) { *error="Invalid common parameter"; return false; }
+ out->far_gate=v[0]; out->near_gate=v[1]; out->absence_s=v[2]; out->status_hz_x10=v[3]; out->distance_hz_x10=v[4]; out->response=v[5];
+ for(unsigned k=0;k<2;k++) {
+ const cJSON *arr=cJSON_GetObjectItemCaseSensitive(o,k?"hold":"trigger");
+ if(!cJSON_IsArray(arr) || cJSON_GetArraySize(arr)!=16) { *error="Each threshold array requires 16 gates"; return false; }
+ for(unsigned i=0;i<16;i++) {
+ const cJSON *item=cJSON_GetArrayItem(arr,i);
+ if(!cJSON_IsNumber(item) || !isfinite(item->valuedouble) || item->valuedouble<10 || item->valuedouble>95 || floor(item->valuedouble)!=item->valuedouble) { *error="Thresholds must be integers from 10 to 95"; return false; }
+ (k?out->hold:out->trigger)[i]=(uint32_t)item->valuedouble;
+ }
+ }
+ *error=ld_validate(out); return !*error;
+}
diff --git a/esp32/tools/ld2410s-c3-calib/main/radar.h b/esp32/tools/ld2410s-c3-calib/main/radar.h
new file mode 100644
index 0000000..8192217
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/radar.h
@@ -0,0 +1,32 @@
+#pragma once
+#include "protocol.h"
+#include "esp_err.h"
+#include "cJSON.h"
+#define HISTORY_SIZE 120
+#define LOG_SIZE 32
+#define LOG_LEN 144
+
+typedef enum { JOB_READ, JOB_WRITE, JOB_STANDARD, JOB_MINIMAL, JOB_CALIBRATE, JOB_RESTORE } job_kind;
+typedef struct { job_kind kind; ld_settings settings; uint16_t trigger, hold, seconds, delay; } radar_job;
+typedef struct { uint32_t uptime_ms; ld_reading reading; } radar_sample;
+typedef struct {
+ bool seen, settings_valid, backup_valid, job_busy;
+ ld_reading latest;
+ ld_settings settings, backup;
+ uint32_t last_ms, reports, bad_bytes;
+ char firmware[24], firmware_raw[96], serial[40], error[160];
+ uint32_t job_id;
+ char job_status[160], job_action[24];
+ int calibration_state; /* 0 idle, 1 countdown, 2 running, 3 done, 4 unconfirmed/failed */
+ uint16_t calibration_progress, calibration_seconds;
+ uint32_t calibration_started_ms, calibration_due_ms;
+ radar_sample history[HISTORY_SIZE];
+ unsigned history_next, history_count;
+ char logs[LOG_SIZE][LOG_LEN];
+ unsigned log_next, log_count;
+} radar_state;
+void radar_start(void);
+bool radar_submit(const radar_job *job, uint32_t *id, const char **error);
+void radar_copy(radar_state *out);
+cJSON *radar_settings_json(const ld_settings *s);
+bool radar_parse_settings(const cJSON *object, ld_settings *out, const char **error);
diff --git a/esp32/tools/ld2410s-c3-calib/main/web.html b/esp32/tools/ld2410s-c3-calib/main/web.html
new file mode 100644
index 0000000..530f093
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/main/web.html
@@ -0,0 +1,56 @@
+<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>LD2410S wall bench</title><style>
+:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:#10191f;color:#e5eee9;font:15px system-ui}main{max-width:1180px;margin:auto;padding:30px}h1{font-size:30px;margin:0}h2{font-size:19px;margin-top:0}.muted{color:#9fb0ba}section{background:#1a272e;border:1px solid #30424a;border-radius:14px;padding:22px;margin:18px 0}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}button,input,select{background:#263940;color:inherit;border:1px solid #526770;border-radius:7px;padding:9px}button{cursor:pointer}button:hover{background:#39555e}button:disabled{opacity:.4;cursor:wait}.primary{background:#266c56}.metric{font-size:38px;color:#7ce2b4}label{display:inline-flex;gap:6px;flex-direction:column;margin:8px 8px 8px 0}input[type=number]{width:110px}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:5px;border-bottom:1px solid #30424a}td input{width:100%!important;max-width:150px}canvas{width:100%;height:180px}pre{font-size:12px;white-space:pre-wrap;max-height:200px;overflow:auto}#message{padding:12px;position:sticky;top:0;background:#10191f;z-index:1;color:#ffcf85}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}@media(max-width:750px){.grid{grid-template-columns:1fr}}a{color:#7ce2b4}
+</style><main><p class="muted">UART · 115200 8N1 · ESP32-C3 · Wi-Fi bench</p><h1>LD2410S wall bench</h1><div id="message">Loading sensor state…</div>
+<section><div class="row"><strong>ESP32-C3 UART sensor</strong><button onclick="act('read')">Read / retry sensor</button><span id="connection"></span></div><p id="wiring" class="muted">Sensor OT1/TX → ESP GPIO3 · sensor RX → ESP GPIO4 · shared GND · regulated 3.3 V. Keep the board fixed.</p><div id="info" class="muted"></div><p id="network" class="muted"></p></section>
+<section><div class="grid"><div><h2>Presence</h2><div class="metric" id="presence">—</div><p id="distance">Waiting for readings</p><p class="muted" id="age"></p></div><div><h2>Distance history (cm)</h2><canvas id="history" width="900" height="180"></canvas></div></div><div class="row"><button onclick="act('mode',{mode:'standard'})">Show gate energy</button><button onclick="act('mode',{mode:'minimal'})">Minimal output</button><button onclick="download('/api/csv','ld2410s-readings.csv')">Export readings CSV</button></div><p class="muted">Standard output is for tuning. Restore minimal output for your low-power reader. Presence states: 0/1 absent; 2/3 present.</p></section>
+<section><h2>Detection settings</h2><div id="common"></div><div class="row"><button onclick="readSettings()">Read from sensor</button><button onclick="backup()">Download current settings</button><button onclick="previousBackup()">Download pre-change backup</button><button onclick="restoreBackup()">Restore pre-change backup</button><label>Load backup into editor<input type="file" accept=".json" onchange="loadBackup(this)"></label></div><p class="muted">Loading a backup edits this page only. Apply writes to the sensor. Distance gates are approximately 0.70 m steps; check measured distance at your mounting location.</p></section>
+<section><h2>Gate energy & thresholds</h2><p class="muted">Green: last reported energy · orange: trigger · blue: hold. Higher thresholds reduce sensitivity. Thresholds use device dB values (10–95); energy displays the reported values.</p><canvas id="energy" width="1000" height="180"></canvas><table><thead><tr><th>Gate</th><th>Energy</th><th>Trigger threshold</th><th>Hold threshold</th></tr></thead><tbody id="gates"></tbody></table><p><button class="primary" onclick="apply()">Apply settings & verify</button></p></section>
+<section><h2>Automatic background calibration</h2><p>Mount the sensor in its final position. Keep people and pets out of the detection area during the scan. Calibration changes the sensor’s thresholds. The ESP saves the previous settings to flash first; download a copy too. The start delay gives you time to leave the room.</p><label>Trigger factor<input id="factorT" type="number" min="1" max="65535" value="2"></label><label>Hold factor<input id="factorH" type="number" min="1" max="65535" value="1"></label><label>Scan seconds<input id="seconds" type="number" min="10" max="600" value="120"></label><label>Start delay (s)<input id="delay" type="number" min="0" max="120" value="15"></label><button onclick="calibrate()">Start calibration</button><p id="progress">No scan running</p><p class="muted">After completion, read settings, then test approaching, standing still, walking away, and passing outside your desired range.</p></section>
+<section><h2>UART diagnostics</h2><p id="stats" class="muted"></p><pre id="logs"></pre></section></main>
+<script>
+const token='__TOKEN__', el=id=>document.getElementById(id);let state={},busy=false,loaded=false;
+const labels={far_gate:['Farthest gate',1,16,1],near_gate:['Nearest gate',0,16,1],absence_s:['Absence delay (s)',10,120,1],status_hz_x10:['Status reports (Hz)',.5,8,.5],distance_hz_x10:['Distance reports (Hz)',.5,8,.5]};
+for(const [k,a] of Object.entries(labels)){el('common').insertAdjacentHTML('beforeend',`<label>${a[0]}<input id="${k}" type="number" min="${a[1]}" max="${a[2]}" step="${a[3]}"></label>`)}
+el('common').insertAdjacentHTML('beforeend','<label>Response<select id="response"><option value="5">Normal</option><option value="10">Fast</option></select></label>');
+for(let i=0;i<16;i++)el('gates').insertAdjacentHTML('beforeend',`<tr><td>${i}</td><td id="e${i}">—</td><td><input id="t${i}" type="number" min="10" max="95"></td><td><input id="h${i}" type="number" min="10" max="95"></td></tr>`);
+function message(t){el('message').textContent=t}
+async function api(path,body){const r=await fetch('/api/'+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json','X-Bench-Token':token},body:body===undefined?undefined:JSON.stringify(body)});const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j}
+async function act(action,args={}){
+ if(busy)return;busy=true;document.querySelectorAll('button').forEach(b=>b.disabled=true);message('Working…');
+ try{const j=await api(action,args);let deadline=Date.now()+(action==='calibrate'?Number(el('delay').value)*1000+60000:60000);
+ for(;;){await poll();if(state.job&&state.job.id>=j.job_id&&!state.job.busy){if(state.job.id!==j.job_id)throw Error('Another session submitted a job; read settings again');if(state.job.status.startsWith('Failed'))throw Error(state.job.status);if(state.settings&&(action==='read'||action==='write'||action==='restore'))populate(state.settings);message(state.job.status);break}if(Date.now()>deadline)throw Error('Job wait timed out; check device state before retrying');await new Promise(r=>setTimeout(r,500))}
+ }catch(e){message(e.message)}finally{busy=false;document.querySelectorAll('button').forEach(b=>b.disabled=false)}
+}
+function populate(s){for(const[k,v]of Object.entries(s.common))el(k).value=k.endsWith('_x10')?v/10:v;for(let i=0;i<16;i++){el('t'+i).value=s.trigger[i];el('h'+i).value=s.hold[i]}}
+function edited(){let common={};for(const k of [...Object.keys(labels),'response'])common[k]=Number(el(k).value)*(k.endsWith('_x10')?10:1);return {common,trigger:Array.from({length:16},(_,i)=>Number(el('t'+i).value)),hold:Array.from({length:16},(_,i)=>Number(el('h'+i).value))}}
+async function readSettings(){await act('read')}
+function save(data,name,type){const u=URL.createObjectURL(new Blob([data],{type}));let a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000)}
+async function backup(){try{const j=await api('backup');save(JSON.stringify(j,null,2),'ld2410s-settings.json','application/json');message('Downloaded the last successfully read settings; click Read first for a fresh snapshot')}catch(e){message(e.message)}}
+async function loadBackup(input){try{const j=JSON.parse(await input.files[0].text());await api('validate',{settings:j.settings||j});populate(j.settings||j);message('Backup loaded into editor; click Apply to write')}catch(e){message(e.message)}}
+async function apply(){if(!state.settings){message('Read settings first');return}if(confirm('Write the edited settings to the sensor? Download a backup first.'))await act('write',{settings:edited()})}
+async function calibrate(){if(confirm('Start automatic calibration? This changes thresholds. Keep the detection area empty.'))await act('calibrate',{trigger:Number(el('factorT').value),hold:Number(el('factorH').value),seconds:Number(el('seconds').value),delay:Number(el('delay').value)})}
+async function download(path,name){try{const r=await fetch(path,{headers:{'X-Bench-Token':token}});if(!r.ok)throw Error(await r.text());save(await r.text(),name,'text/csv')}catch(e){message(e.message)}}
+function chart(id,values,thresholds){let c=el(id),x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);const max=Math.max(1,...values,...thresholds.flat());x.strokeStyle='#354851';for(let i=1;i<4;i++){x.beginPath();x.moveTo(0,i*h/4);x.lineTo(w,i*h/4);x.stroke()}x.fillStyle='#aabfc9';x.fillText('max '+Math.round(max),5,12);if(id==='energy'){let slot=w/16;values.forEach((v,i)=>{x.fillStyle='#72dfad';x.fillRect(i*slot+8,h-v/max*(h-20),slot-16,v/max*(h-20));thresholds.forEach((a,j)=>{x.strokeStyle=j?'#6bbcff':'#ffbf72';x.beginPath();let y=h-a[i]/max*(h-20);x.moveTo(i*slot+3,y);x.lineTo((i+1)*slot-3,y);x.stroke()})})}else{x.strokeStyle='#72dfad';x.beginPath();values.forEach((v,i)=>{let px=i*w/Math.max(1,values.length-1),py=h-v/max*(h-20);i?x.lineTo(px,py):x.moveTo(px,py)});x.stroke()}}
+async function previousBackup(){try{const j=await api('previous-backup');save(JSON.stringify(j,null,2),'ld2410s-pre-change.json','application/json');message('Downloaded the settings saved before the last write/calibration')}catch(e){message(e.message)}}
+async function restoreBackup(){if(confirm('Restore the previous settings saved in ESP32 flash?'))await act('restore')}
+async function poll(){try{
+ state=await api('state');let s=state,l=s.latest,stale=s.age===null||s.age>5;
+ el('connection').textContent=s.connected?'Receiving reports':'Waiting for sensor reports';
+ el('presence').textContent=stale?'No fresh data':l.present?'PRESENT':'ABSENT';
+ el('distance').textContent=stale?'Waiting for fresh readings':l.present?l.distance_cm+' cm · raw state '+l.state:'No detected person · raw state '+l.state;
+ el('age').textContent=s.age===null?'No reports received':s.age.toFixed(1)+' s since report · '+l.mode;
+ el('wiring').textContent='Sensor OT1/TX → ESP GPIO'+s.info.uart_rx+' · sensor RX → ESP GPIO'+s.info.uart_tx+' · shared GND · regulated 3.3 V. Keep the board fixed.';
+ el('info').textContent='Firmware '+(s.info.firmware||'—')+' · serial '+(s.info.serial||'—')+' · raw reply '+s.info.firmware_raw;
+ el('network').textContent='AP: '+s.network.ap_ssid+' / '+s.network.ap_ip+(s.network.sta_ip?' · Home Wi-Fi: http://'+s.network.sta_ip+'/':'');
+ el('progress').textContent=s.calibration?s.calibration.status+(s.calibration.state===1?' · starts in '+Math.max(0,Math.ceil(s.calibration.remaining_s))+' s':' · '+s.calibration.progress.toFixed(1)+'%'):'No scan running';
+ el('logs').textContent=s.logs.join('\n');el('stats').textContent=s.reports+' total reports · '+s.samples+' buffered samples · '+s.bad_bytes+' skipped bytes · '+s.heap_free+' free heap bytes'+(s.error?' · '+s.error:'');
+ if(s.settings&&!loaded){populate(s.settings);loaded=true}
+ let e=!stale&&l.energy?l.energy:Array(16).fill(0);e.forEach((v,i)=>el('e'+i).textContent=!stale&&l.energy?v:'—');
+ chart('energy',e,[Array.from({length:16},(_,i)=>Number(el('t'+i).value)||0),Array.from({length:16},(_,i)=>Number(el('h'+i).value)||0)]);
+ chart('history',s.history.map(a=>a.present?a.distance_cm:0),[]);
+ return true;
+ }catch(e){message('Dashboard connection: '+e.message);return false}
+}
+(async()=>{await poll();message('Read settings, enable gate energy, then mount the sensor and test.');setInterval(poll,1000)})();
+</script>
\ No newline at end of file
diff --git a/esp32/tools/ld2410s-c3-calib/sdkconfig.defaults b/esp32/tools/ld2410s-c3-calib/sdkconfig.defaults
new file mode 100644
index 0000000..3ed72af
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/sdkconfig.defaults
@@ -0,0 +1,7 @@
+CONFIG_IDF_TARGET="esp32c3"
+CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y
+CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
+CONFIG_COMPILER_OPTIMIZATION_SIZE=y
+CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
+CONFIG_HTTPD_MAX_URI_LEN=128
diff --git a/esp32/tools/ld2410s-c3-calib/tests/test_protocol.c b/esp32/tools/ld2410s-c3-calib/tests/test_protocol.c
new file mode 100644
index 0000000..62bfa0a
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/tests/test_protocol.c
@@ -0,0 +1,55 @@
+#include "protocol.h"
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+static unsigned frames[3]; static ld_reading latest; static uint16_t progress;
+static void got(ld_frame_kind kind,const uint8_t *data,size_t size,void *ctx) {
+ (void)ctx; frames[kind]++;
+ ld_reading r; if(ld_decode_reading(kind,data,size,&r)) latest=r;
+ uint16_t p; if(ld_decode_progress(kind,data,size,&p)) progress=p;
+}
+int main(void) {
+ uint8_t out[140],payload[96]={0};
+ assert(ld_command(0xff,(uint8_t[]){1,0},2,out,sizeof(out))==14);
+ assert(!memcmp(out,(uint8_t[]){0xfd,0xfc,0xfb,0xfa,4,0,0xff,0,1,0,4,3,2,1},14));
+ assert(ld_command(0x72,payload,96,out,sizeof(out))==108);
+ assert(ld_command(0x72,payload,96,out,100)==0);
+ uint8_t standard[80]={0xf4,0xf3,0xf2,0xf1,70,0,1,3,150,0,0,0};
+ for(unsigned i=0;i<16;i++) ld_put32(standard+12+i*4,i+10);
+ memcpy(standard+76,(uint8_t[]){0xf8,0xf7,0xf6,0xf5},4);
+ const uint8_t minimal[]={0x6e,0,80,0,0x62};
+ for(unsigned split=1;split<90;split++) {
+ ld_parser p={0}; memset(frames,0,sizeof(frames));
+ ld_feed(&p,(uint8_t *)"noise",5,got,NULL);
+ ld_feed(&p,minimal,5,got,NULL); assert(latest.state==0 && latest.distance_cm==80);
+ for(size_t i=0;i<80;i+=split) ld_feed(&p,standard+i,80-i<split?80-i:split,got,NULL);
+ assert(frames[LD_DATA]==1 && frames[LD_MINIMAL]==1);
+ assert(latest.state==3 && latest.distance_cm==150 && latest.energy_valid && latest.energy[15]==25);
+ assert(p.bad_bytes==5);
+ uint8_t calibration[]={0xf4,0xf3,0xf2,0xf1,3,0,3,0x64,0,0xf8,0xf7,0xf6,0xf5};
+ for(size_t i=0;i<sizeof(calibration);i+=split)
+ ld_feed(&p,calibration+i,sizeof(calibration)-i<split?sizeof(calibration)-i:split,got,NULL);
+ assert(progress==10000);
+ out[0]=0xfd; out[1]=0xfc; out[2]=0xfb; out[3]=0xfa;out[4]=255;out[5]=255;
+ ld_feed(&p,out,6,got,NULL); ld_feed(&p,minimal,5,got,NULL); assert(frames[LD_MINIMAL]==2);
+ /* Header bytes inside a valid energy payload must not be mistaken for frames. */
+ memcpy(standard+20,(uint8_t[]){0xfd,0xfc,0xfb,0xfa},4);
+ ld_feed(&p,standard,80,got,NULL); assert(frames[LD_DATA]==3);
+ }
+ /* Device percentages 0..100 become the dashboard's hundredths, not 0..1%. */
+ for(unsigned percent=0;percent<=100;percent++) {
+ uint8_t body[]={3,(uint8_t)percent,0}; uint16_t decoded=65535;
+ assert(ld_decode_progress(LD_DATA,body,3,&decoded));
+ assert(decoded==percent*100);
+ }
+ uint16_t decoded=1234;
+ assert(!ld_decode_progress(LD_DATA,(uint8_t[]){3,101,0},3,&decoded));
+ assert(!ld_decode_progress(LD_DATA,(uint8_t[]){3,0x10,0x27},3,&decoded));
+ assert(!ld_decode_progress(LD_DATA,(uint8_t[]){3,100},2,&decoded));
+ assert(!ld_decode_progress(LD_ACK,(uint8_t[]){3,100,0},3,&decoded));
+ ld_settings s={.far_gate=2,.near_gate=0,.absence_s=10,.status_hz_x10=5,.distance_hz_x10=10,.response=5};
+ for(unsigned i=0;i<16;i++) {s.trigger[i]=40;s.hold[i]=30;}
+ assert(!ld_validate(&s));s.near_gate=3;assert(ld_validate(&s));s.near_gate=0;
+ s.distance_hz_x10=6;assert(ld_validate(&s));s.distance_hz_x10=10;s.trigger[0]=100;assert(ld_validate(&s));
+ puts("Protocol checks passed: framing, every fragmentation size, telemetry, progress, corrupt lengths, header bytes in payload, settings limits");
+}
diff --git a/esp32/tools/ld2410s-c3-calib/tests/test_ui.js b/esp32/tools/ld2410s-c3-calib/tests/test_ui.js
new file mode 100644
index 0000000..de71ced
--- /dev/null
+++ b/esp32/tools/ld2410s-c3-calib/tests/test_ui.js
@@ -0,0 +1,27 @@
+const fs=require('fs'),vm=require('vm'),assert=require('assert');
+const path=require('path');
+const script=fs.readFileSync(path.join(__dirname,'..','main','web.html'),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1];
+const elements=new Map();
+function element(id){if(!elements.has(id))elements.set(id,{value:'',textContent:'',width:1000,height:180,insertAdjacentHTML(){},getContext(){return new Proxy({},{get:(o,k)=>k in o?o[k]:(()=>{}),set:(o,k,v)=>(o[k]=v,true)})}});return elements.get(id)}
+const settings={common:{far_gate:8,near_gate:0,absence_s:10,status_hz_x10:5,distance_hz_x10:5,response:5},trigger:Array(16).fill(40),hold:Array(16).fill(30)};
+let current={connected:true,age:0,latest:{present:true,state:2,distance_cm:80,mode:'standard',energy:Array(16).fill(20)},settings,info:{uart_rx:3,uart_tx:4,firmware:'1.1.1',serial:'12345678',firmware_raw:'test'},network:{ap_ssid:'LD2410S-Bench',ap_ip:'192.168.4.1'},job:{id:0,busy:false,status:'Done'},calibration:null,reports:3,samples:3,bad_bytes:0,heap_free:100000,error:'',logs:[],history:[{present:true,distance_cm:80}]};
+let submitted=[];
+const context=vm.createContext({document:{getElementById:element,querySelectorAll:()=>[]},console,Date,Math,Number,Array,Object,JSON,Promise,Error,setInterval(){},setTimeout,confirm:()=>true,fetch:async(url,options)=>{
+ if(url==='/api/state')return {ok:true,json:async()=>current};
+ const args=JSON.parse(options.body||'{}');submitted.push([url,args]);
+ if(url==='/api/write')current.settings=args.settings;
+ current.job={id:current.job.id+1,busy:false,status:'Done'};
+ return {ok:true,json:async()=>({job_id:current.job.id})};
+}});
+(async()=>{
+ vm.runInContext(script,context);await new Promise(r=>setImmediate(r));
+ assert.equal(element('presence').textContent,'PRESENT');assert.equal(element('far_gate').value,8);
+ current.latest.present=false;current.latest.state=0;await vm.runInContext('poll()',context);assert.equal(element('distance').textContent,'No detected person · raw state 0');
+ element('far_gate').value=2;
+ await vm.runInContext("act('write',{settings:edited()})",context);assert.equal(current.settings.common.far_gate,2);
+ element('delay').value=15;element('factorT').value=2;element('factorH').value=1;element('seconds').value=120;
+ await vm.runInContext('calibrate()',context);
+ assert.deepEqual(submitted.at(-1),['/api/calibrate',{trigger:2,hold:1,seconds:120,delay:15}]);
+ current.age=9;await vm.runInContext('poll()',context);assert.equal(element('presence').textContent,'No fresh data');
+ console.log('Dashboard JavaScript checks passed: initial settings, absence distance suppression, async write job, delayed calibration payload, stale readings');
+})().catch(e=>{console.error(e);process.exitCode=1});