Skip to content

Commit f6a43ef

Browse files
author
Ravi Singh
committed
feat: presence detection + LD2410(C) restoration + Live chip stability
Bench-tested end-to-end on ESP32-C3 + LD2450: presence flips Occupied within ~100 ms of a target entering the cone, vacancy timer counts correctly, distance matches Live tab, no UI wobble on the Live row. Firmware: - New `presence/` component. Reads radar via radar_peek() at 10 Hz, derives {occupied, target_count, stationary, nearest_cm, ms_since_seen} with a configurable vacancy timeout (5..3600 s, default 60 s, persisted to NVS namespace `presence`). - New `radar_peek()` non-consuming snapshot. Motion still drains the 1-slot queue once per frame; presence reads the latest snapshot under a mutex at its own cadence — no contention. - Restored `radar_ld2410.c` from git history. Registry now exposes ld2450 (kit default), ld2410c (presence-only), ld2410 (legacy v5/v6.0 compat), and sim. Driver is selected at runtime via /api/board.radar_kind so users can swap sensors without reflashing. - New `/api/presence` REST endpoint for HA RESTful sensor integrations. - Extended `/api/live` WS payload with occupied / count / stationary / nearest_cm / seconds_since_seen so a single subscription gets both distance + presence coherently. - /api/radar/kinds now returns 4 entries with use-case-tuned descriptions. - vacancy_secs is settable via /api/settings POST and routes through presence_set_vacancy_timeout() so the in-memory value updates immediately (no NVS reload pass needed). - presence.c uses f.distance_cm (the driver's precomputed euclidean distance) for "nearest", NOT f.targets[i].y_cm (the Y axis component). Bench-reproduced bug: a target standing along the sensor's X axis would read y_cm ≈ 0 and the Presence tab would show "Nearest 0 cm" while Live correctly showed 80 cm. Web UI: - New ScreenPresence: big Occupied/Vacant status card, target count + nearest cm + last-seen tiles, vacancy timeout slider with debounced save, sensor-choice card (LD2450 vs LD2410C decision), and an inline Home Assistant RESTful sensor YAML recipe. - Presence tab added between Motion and Hardware with a person icon. - Live "in window" / direction / min / max chips are now fixed-width (min-width sized for longest possible inner text + center-aligned) so the sparkline column doesn't twitch horizontally when chip text flips between "still"/"closer →"/"away →" or "in window"/"outside". Build: - ambisense.bin 0x11f130 (~1.16 MB), 18% partition free. - UI bundle 96.6 KB raw, 28.4 KB gzipped (+~1.6 KB vs alpha.3 for the Presence screen + HA YAML snippet + person icon).
1 parent 8318be7 commit f6a43ef

17 files changed

Lines changed: 610 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
idf_component_register(
2+
SRCS "presence.c"
3+
INCLUDE_DIRS "include"
4+
REQUIRES radar settings esp_timer log freertos
5+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#pragma once
2+
3+
/*
4+
* AmbiSense v6.2 — presence detection.
5+
*
6+
* Derived output. Reads radar via radar_peek() at 10 Hz, applies a
7+
* configurable vacancy timeout, and publishes a debounced presence
8+
* snapshot to webui + future MQTT publisher.
9+
*
10+
* Why a separate component (vs folding into motion):
11+
* motion's job is to produce a smooth single-target position for the
12+
* LED renderer. presence's job is to answer "is anyone in the room
13+
* right now, and how many?" — a different question with a different
14+
* smoothing model (vacancy timer instead of Kalman). Splitting the
15+
* responsibilities lets us swap radar driver underneath without
16+
* touching either consumer.
17+
*
18+
* Sensor matrix (target_count semantics):
19+
* ld2450 : 0..3 simultaneous targets, with x/y/v per target.
20+
* ld2410c : 0 or 1 (binary detect + distance + state moving/stationary).
21+
* ld2410 : same as 2410c.
22+
* sim : driven by sim trace.
23+
*/
24+
25+
#include <stdbool.h>
26+
#include <stdint.h>
27+
#include "esp_err.h"
28+
29+
#ifdef __cplusplus
30+
extern "C" {
31+
#endif
32+
33+
typedef struct {
34+
bool occupied; /* true if any target seen within vacancy_secs */
35+
uint8_t target_count; /* current number of targets (0..3) */
36+
bool stationary; /* true if at least one target has |v| < 5 cm/s
37+
(LD2410-family always reports this true when
38+
the radar's internal state is "stationary") */
39+
int16_t nearest_cm; /* distance to nearest target; -1 if vacant */
40+
uint32_t last_seen_ms; /* monotonic ms since boot of last detection;
41+
0 if never seen */
42+
uint32_t ms_since_seen; /* derived helper: now - last_seen_ms;
43+
UINT32_MAX if never seen */
44+
uint32_t vacancy_secs; /* configured timeout */
45+
} presence_t;
46+
47+
esp_err_t presence_init(void);
48+
49+
/* Snapshot the latest presence state. Returns ESP_OK and fills *out. */
50+
esp_err_t presence_get(presence_t *out);
51+
52+
/* Configure the vacancy timeout in seconds. Persists to NVS namespace
53+
* `presence` key `vacancy`. Default 60. Bounded [5, 3600]. */
54+
esp_err_t presence_set_vacancy_timeout(uint32_t secs);
55+
uint32_t presence_get_vacancy_timeout(void);
56+
57+
#ifdef __cplusplus
58+
}
59+
#endif
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#include "presence.h"
2+
3+
#include <string.h>
4+
#include <stdlib.h>
5+
6+
#include "esp_log.h"
7+
#include "esp_timer.h"
8+
#include "freertos/FreeRTOS.h"
9+
#include "freertos/task.h"
10+
#include "freertos/semphr.h"
11+
12+
#include "radar.h"
13+
#include "settings.h"
14+
15+
static const char *TAG = "presence";
16+
17+
#define PRESENCE_TICK_MS 100 /* 10 Hz */
18+
#define PRESENCE_STATIONARY_CMS 5 /* |v| < 5 cm/s == stationary */
19+
#define VACANCY_DEFAULT_S 60
20+
#define VACANCY_MIN_S 5
21+
#define VACANCY_MAX_S 3600
22+
23+
static struct {
24+
presence_t latest;
25+
SemaphoreHandle_t lock;
26+
uint32_t vacancy_secs;
27+
/* Latest detection timestamp in microseconds since boot.
28+
* 0 means "never seen". */
29+
uint64_t last_seen_us;
30+
bool inited;
31+
TaskHandle_t task;
32+
} s_pres;
33+
34+
static void presence_task(void *arg) {
35+
(void)arg;
36+
while (1) {
37+
radar_frame_t f;
38+
esp_err_t r = radar_peek(&f);
39+
uint64_t now_us = (uint64_t)esp_timer_get_time();
40+
41+
bool any_now = false;
42+
bool any_stationary = false;
43+
int16_t nearest = -1;
44+
uint8_t count = 0;
45+
46+
if (r == ESP_OK) {
47+
count = f.target_count;
48+
any_now = (f.target_count > 0) || f.present;
49+
50+
/* `f.distance_cm` is the EUCLIDEAN distance the LD2450 driver
51+
* computes from the primary target's (x, y) — same number
52+
* the Live tab shows. The per-target `targets[i].y_cm` is
53+
* just the Y axis projection, NOT the radial distance, so
54+
* using it as "nearest" reports 0 when a target sits along
55+
* the X axis. (Bench-reproduced 2026-05-07.)
56+
*
57+
* For LD2410-family, distance_cm is just the moving or
58+
* stationary distance the radar reports. Same field, same
59+
* meaning. So both code paths reduce to: "if anyone is here,
60+
* nearest = f.distance_cm". */
61+
if (any_now) {
62+
nearest = (int16_t)f.distance_cm;
63+
if (count == 0 && f.present) count = 1;
64+
65+
/* Stationary detection: LD2450 reports per-target
66+
* velocity in cm/s; LD2410-family doesn't surface
67+
* velocity through the unified frame at all. Mark
68+
* stationary if any target has |v| under threshold,
69+
* OR if we don't know (LD2410 case — bias toward
70+
* stationary since the family is tuned for it). */
71+
if (f.target_count > 0) {
72+
for (uint8_t i = 0; i < f.target_count && i < RADAR_MAX_TARGETS; ++i) {
73+
if (abs((int)f.targets[i].v_cms) < PRESENCE_STATIONARY_CMS) {
74+
any_stationary = true;
75+
break;
76+
}
77+
}
78+
} else {
79+
any_stationary = true; /* LD2410(C) — unknown velocity */
80+
}
81+
82+
s_pres.last_seen_us = now_us;
83+
}
84+
}
85+
86+
uint64_t timeout_us = (uint64_t)s_pres.vacancy_secs * 1000000ULL;
87+
bool occupied = (s_pres.last_seen_us != 0) &&
88+
((now_us - s_pres.last_seen_us) < timeout_us);
89+
90+
presence_t snap = {
91+
.occupied = occupied,
92+
.target_count = occupied ? count : 0,
93+
.stationary = occupied && any_stationary,
94+
.nearest_cm = occupied ? (nearest < 0 ? 0 : nearest) : -1,
95+
.last_seen_ms = (uint32_t)(s_pres.last_seen_us / 1000ULL),
96+
.ms_since_seen = (s_pres.last_seen_us == 0) ? UINT32_MAX
97+
: (uint32_t)((now_us - s_pres.last_seen_us) / 1000ULL),
98+
.vacancy_secs = s_pres.vacancy_secs,
99+
};
100+
101+
xSemaphoreTake(s_pres.lock, portMAX_DELAY);
102+
s_pres.latest = snap;
103+
xSemaphoreGive(s_pres.lock);
104+
105+
vTaskDelay(pdMS_TO_TICKS(PRESENCE_TICK_MS));
106+
}
107+
}
108+
109+
esp_err_t presence_init(void) {
110+
if (s_pres.inited) return ESP_OK;
111+
s_pres.lock = xSemaphoreCreateMutex();
112+
113+
/* Load vacancy timeout from NVS or fall back to default. */
114+
uint32_t v = 0;
115+
if (settings_get_u32("presence", "vacancy", &v) == ESP_OK &&
116+
v >= VACANCY_MIN_S && v <= VACANCY_MAX_S) {
117+
s_pres.vacancy_secs = v;
118+
} else {
119+
s_pres.vacancy_secs = VACANCY_DEFAULT_S;
120+
}
121+
s_pres.last_seen_us = 0;
122+
/* Initial latest snapshot — vacant, never seen. */
123+
s_pres.latest = (presence_t){
124+
.occupied = false, .target_count = 0, .stationary = false,
125+
.nearest_cm = -1, .last_seen_ms = 0, .ms_since_seen = UINT32_MAX,
126+
.vacancy_secs = s_pres.vacancy_secs,
127+
};
128+
129+
BaseType_t ok = xTaskCreate(presence_task, "presence", 3072, NULL, 4, &s_pres.task);
130+
if (ok != pdPASS) {
131+
ESP_LOGE(TAG, "task create failed");
132+
return ESP_FAIL;
133+
}
134+
s_pres.inited = true;
135+
ESP_LOGI(TAG, "presence detection up; vacancy timeout %u s", (unsigned)s_pres.vacancy_secs);
136+
return ESP_OK;
137+
}
138+
139+
esp_err_t presence_get(presence_t *out) {
140+
if (!s_pres.inited || !out) return ESP_ERR_INVALID_STATE;
141+
xSemaphoreTake(s_pres.lock, portMAX_DELAY);
142+
*out = s_pres.latest;
143+
xSemaphoreGive(s_pres.lock);
144+
return ESP_OK;
145+
}
146+
147+
esp_err_t presence_set_vacancy_timeout(uint32_t secs) {
148+
if (secs < VACANCY_MIN_S || secs > VACANCY_MAX_S) return ESP_ERR_INVALID_ARG;
149+
s_pres.vacancy_secs = secs;
150+
settings_set_u32("presence", "vacancy", secs);
151+
ESP_LOGI(TAG, "vacancy timeout set to %u s", (unsigned)secs);
152+
return ESP_OK;
153+
}
154+
155+
uint32_t presence_get_vacancy_timeout(void) {
156+
return s_pres.inited ? s_pres.vacancy_secs : VACANCY_DEFAULT_S;
157+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
idf_component_register(
2-
SRCS "radar.c" "radar_ld2450.c" "radar_sim.c"
2+
SRCS "radar.c" "radar_ld2410.c" "radar_ld2450.c" "radar_sim.c"
33
INCLUDE_DIRS "include"
44
REQUIRES settings driver esp_timer log freertos
55
)

firmware/components/radar/include/radar.h

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
* binary unconditionally; selection is runtime so users can swap sensors
99
* via the web UI without reflashing.
1010
*
11-
* v6.x drivers (single-sensor architecture, LD2450 only on shipping
12-
* hardware; sim retained for desk testing without a radar attached):
13-
* ld2450 — HiLink LD2450, up to 3 targets with x/y/speed (24 GHz)
14-
* sim — synthetic trace generator for desk testing
11+
* v6.2 drivers (single-sensor architecture):
12+
* ld2450 — HiLink LD2450, up to 3 targets with x/y/speed (24 GHz).
13+
* Best for the stairwell follow-me use case AND general
14+
* moving-presence detection. Kit default.
15+
* ld2410c — HiLink LD2410C, single-target distance + energy + native
16+
* static-presence detection (24 GHz). Best for "is someone
17+
* sitting on the couch" use cases where the LD2450 would
18+
* eventually drop a fully-still target.
19+
* ld2410 — HiLink LD2410(B). Same protocol family as 2410C, kept
20+
* for v5/v6.0 hardware compat — 2410C is preferred for new
21+
* installs.
22+
* sim — synthetic trace generator for desk testing.
1523
*/
1624

1725
#include <stdbool.h>
@@ -51,9 +59,17 @@ typedef struct {
5159
* continuously parses radar frames and pushes them to an internal queue. */
5260
esp_err_t radar_init(const radar_config_t *cfg);
5361

54-
/* Block until a frame is available or the timeout expires. */
62+
/* Block until a frame is available or the timeout expires.
63+
* radar_read CONSUMES — calling it dequeues. Used by the motion task
64+
* which wants a per-frame trigger. */
5565
esp_err_t radar_read(radar_frame_t *out, TickType_t timeout);
5666

67+
/* Snapshot the most recent frame WITHOUT consuming. Multiple consumers
68+
* (motion + presence + diagnostics) can call this independently at
69+
* their own polling rates. Returns ESP_ERR_NOT_FOUND if no frame has
70+
* been parsed yet (e.g. during the first 100 ms after boot). */
71+
esp_err_t radar_peek(radar_frame_t *out);
72+
5773
/* For the simulator driver — replay a scripted trace. */
5874
esp_err_t radar_sim_push_trace(const int16_t *distances_cm, size_t n, uint32_t period_ms);
5975

firmware/components/radar/radar.c

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "freertos/FreeRTOS.h"
88
#include "freertos/task.h"
99
#include "freertos/queue.h"
10+
#include "freertos/semphr.h"
1011

1112
#include "settings.h"
1213

@@ -21,19 +22,41 @@ typedef struct {
2122
} radar_driver_t;
2223

2324
/* Forward decls — drivers live in radar_<kind>.c */
25+
extern size_t radar_ld2410_parse(const uint8_t *buf, size_t len, radar_frame_t *out);
2426
extern size_t radar_ld2450_parse(const uint8_t *buf, size_t len, radar_frame_t *out);
2527
extern size_t radar_sim_parse (const uint8_t *buf, size_t len, radar_frame_t *out);
2628

29+
/* v6.2 sensor matrix:
30+
* ld2450 — best for stairwell follow-me (x/y target tracking) AND
31+
* general presence (auto-falls-back to count-only when
32+
* you don't care about position). Kit default.
33+
* ld2410c — best for static presence (couch / desk / breathing
34+
* detection). Cheaper. Single-target distance-only.
35+
* ld2410 — same protocol family as 2410c; legacy support for
36+
* v5/v6.0 hardware that already has 2410-class units in
37+
* the field.
38+
* sim — synthetic distance trace for desk testing without a
39+
* radar wired up.
40+
*/
2741
static const radar_driver_t k_drivers[] = {
28-
{ "ld2450", radar_ld2450_parse },
29-
{ "sim", radar_sim_parse },
42+
{ "ld2450", radar_ld2450_parse },
43+
{ "ld2410c", radar_ld2410_parse }, /* same protocol family */
44+
{ "ld2410", radar_ld2410_parse },
45+
{ "sim", radar_sim_parse },
3046
};
3147

3248
static struct {
3349
const radar_driver_t *drv;
3450
QueueHandle_t q;
3551
radar_config_t cfg;
3652
bool inited;
53+
/* Latest parsed frame, fan-out copy. radar_read() still drives the
54+
* 1-slot queue (motion drains it once per frame); radar_peek() reads
55+
* this snapshot under lock so multiple peek consumers (presence,
56+
* diagnostics, future) can poll without competing with motion. */
57+
radar_frame_t latest;
58+
bool latest_valid;
59+
SemaphoreHandle_t latest_lock;
3760
/* Diagnostics — let users debug "distance always 0" by seeing whether
3861
* UART bytes are arriving and frames are parsing. */
3962
uint32_t diag_bytes;
@@ -106,6 +129,11 @@ static void radar_task(void *arg) {
106129
s_radar.diag_frames++;
107130
s_radar.diag_last_frame_us = frame.ts_us;
108131
}
132+
/* Fan-out snapshot for radar_peek() consumers. */
133+
xSemaphoreTake(s_radar.latest_lock, portMAX_DELAY);
134+
s_radar.latest = frame;
135+
s_radar.latest_valid = true;
136+
xSemaphoreGive(s_radar.latest_lock);
109137
if (consumed < held) memmove(rx, rx + consumed, held - consumed);
110138
held -= consumed;
111139
}
@@ -130,6 +158,7 @@ esp_err_t radar_init(const radar_config_t *cfg) {
130158

131159
s_radar.cfg = *cfg;
132160
s_radar.q = xQueueCreate(1, sizeof(radar_frame_t));
161+
s_radar.latest_lock = xSemaphoreCreateMutex();
133162

134163
/* The simulator driver doesn't need UART at all. */
135164
if (strcmp(s_radar.drv->id, "sim") != 0) {
@@ -157,3 +186,12 @@ esp_err_t radar_read(radar_frame_t *out, TickType_t timeout) {
157186
if (xQueueReceive(s_radar.q, out, timeout) != pdTRUE) return ESP_ERR_TIMEOUT;
158187
return ESP_OK;
159188
}
189+
190+
esp_err_t radar_peek(radar_frame_t *out) {
191+
if (!s_radar.inited || !out) return ESP_ERR_INVALID_STATE;
192+
if (!s_radar.latest_valid) return ESP_ERR_NOT_FOUND;
193+
xSemaphoreTake(s_radar.latest_lock, portMAX_DELAY);
194+
*out = s_radar.latest;
195+
xSemaphoreGive(s_radar.latest_lock);
196+
return ESP_OK;
197+
}

0 commit comments

Comments
 (0)