feat: Add baseline calibration & presence detection (v1.7)

CALIBRATE command captures per-subcarrier CSI amplitudes over a timed
window and stores the averaged baseline in NVS. PRESENCE command enables
real-time scoring via normalized Euclidean distance against the baseline,
with rolling window averaging and 10s holdoff on state transitions.

New commands: CALIBRATE [3-60|STATUS|CLEAR], PRESENCE [ON|OFF|THRESHOLD]
New NVS keys: bl_amps (blob), bl_nsub, presence, pr_thresh
New STATUS fields: presence=, pr_score=
New events: calibrate=done, presence=0|1
This commit is contained in:
user
2026-02-04 23:04:19 +01:00
parent 738c759573
commit 528e34cb25
4 changed files with 341 additions and 11 deletions

View File

@@ -120,6 +120,19 @@ Note: Promiscuous mode (probe/deauth capture) disabled on original ESP32 — bre
- [ ] Deep sleep mode with wake-on-CSI-motion
- [ ] Battery-optimized duty cycling
## v1.7 - Baseline Calibration & Presence Detection [DONE]
- [x] CALIBRATE command (capture N seconds of CSI with room empty, average per-subcarrier amplitudes, store in NVS)
- [x] CALIBRATE STATUS / CALIBRATE CLEAR subcommands
- [x] Presence scoring (normalized Euclidean distance of live CSI vs baseline, rolling window)
- [x] PRESENCE ON/OFF command (NVS persisted, requires valid baseline)
- [x] PRESENCE THRESHOLD command (tunable 0.001-1.0, NVS persisted)
- [x] Presence events (`EVENT,<hostname>,presence=<0|1> score=<float>`) with 10s holdoff
- [x] Calibration done event (`EVENT,<hostname>,calibrate=done packets=<n> nsub=<n>`)
- [x] presence= and pr_score= fields in STATUS reply
- [x] NVS persistence for baseline (bl_amps blob, bl_nsub) and presence config
- [ ] Tune presence threshold per room with real-world testing
- [ ] Pi-side presence event handling in watch daemon
## v2.0 - Hardware Upgrade (ESP32-S3/C6)
Requires replacing current ESP32 (original) DevKitC V1 boards with ESP32-S3

View File

@@ -2,9 +2,11 @@
**Last Updated:** 2026-02-04
## Current Sprint: v1.6+ — Validation & Measurement
## Current Sprint: v1.7+ — Presence Tuning & Integration
### P2 - Normal
- [ ] Tune presence threshold per room with real-world testing
- [ ] Pi-side presence event handling in watch daemon
- [ ] Power consumption measurements using POWERTEST + external meter
- [ ] Test OTA rollback (flash bad firmware, verify auto-revert)
- [ ] Create HA webhook automations for deauth_flood / unknown_probe
@@ -16,6 +18,20 @@
- [ ] Document esp-radar console features
- [ ] Pin mapping for ESP32-DevKitC V1
## Completed: v1.7 - Baseline Calibration & Presence Detection
- [x] CALIBRATE command (capture N seconds of CSI, average per-subcarrier amplitudes)
- [x] CALIBRATE STATUS / CALIBRATE CLEAR subcommands
- [x] Presence scoring (normalized Euclidean distance vs baseline, rolling window of 50)
- [x] PRESENCE ON/OFF command (NVS persisted, requires valid baseline)
- [x] PRESENCE THRESHOLD command (0.001-1.0, NVS persisted, default 0.05)
- [x] Presence events (`EVENT,<hostname>,presence=<0|1> score=<float>`) with 10s holdoff
- [x] Calibration done event (`EVENT,<hostname>,calibrate=done packets=<n> nsub=<n>`)
- [x] presence= and pr_score= fields in STATUS reply
- [x] NVS persistence: bl_amps (blob), bl_nsub (i8), presence (i8), pr_thresh (i32)
- [x] config_save_blob / config_erase_key NVS helpers
- [x] n_sub field in csi_features_t, amps_out parameter in csi_extract_features
## Completed: v1.6 - Power Management
- [x] ESP-IDF power management framework (DFS 240/80 MHz + light sleep)
@@ -151,10 +167,10 @@
## Notes
- Adaptive threshold varies by environment; 0.001-0.01 is a good starting range
- NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count`, `csi_mode`, `hybrid_n`, `auth_secret`, `flood_thresh`, `flood_window`, `scan_rate`, `probe_rate`, `powersave`
- NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count`, `csi_mode`, `hybrid_n`, `auth_secret`, `flood_thresh`, `flood_window`, `scan_rate`, `probe_rate`, `powersave`, `presence`, `pr_thresh`, `bl_nsub`, `bl_amps`
- EVENT packets include sensor hostname: `EVENT,<hostname>,motion=... rate=... wander=...`
- ALERT_DATA format: `ALERT_DATA,<hostname>,<deauth|disassoc>,<sender_mac>,<target_mac>,<rssi>` or `ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>`
- STATUS fields: `uptime=`, `uptime_s=`, `heap=`, `rssi=`, `channel=`, `tx_power=`, `rate=`, `csi_rate=`, `hostname=`, `version=`, `adaptive=`, `motion=`, `ble=`, `target=`, `temp=`, `csi_count=`, `boots=`, `rssi_min=`, `rssi_max=`, `csi_mode=`, `hybrid_n=`, `auth=`, `flood_thresh=`, `powersave=`
- STATUS fields: `uptime=`, `uptime_s=`, `heap=`, `rssi=`, `channel=`, `tx_power=`, `rate=`, `csi_rate=`, `hostname=`, `version=`, `adaptive=`, `motion=`, `ble=`, `target=`, `temp=`, `csi_count=`, `boots=`, `rssi_min=`, `rssi_max=`, `csi_mode=`, `hybrid_n=`, `auth=`, `flood_thresh=`, `powersave=`, `presence=`, `pr_score=`
- PROBE_DATA format: `PROBE_DATA,<hostname>,<mac>,<rssi>,<ssid>`
- Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE)
- mDNS service: `_esp-csi._udp` on data port (for sensor discovery)

View File

@@ -55,6 +55,14 @@ esp-cmd <host> AUTH mysecret123 # Enable HMAC auth (8-64 char secret)
esp-cmd <host> AUTH OFF # Disable auth
esp-cmd <host> FLOODTHRESH # Query deauth flood threshold (5/10s)
esp-cmd <host> FLOODTHRESH 10 30 # Set: 10 deauths in 30s = flood
esp-cmd <host> CALIBRATE # Start baseline capture (default 10s, room must be empty)
esp-cmd <host> CALIBRATE 20 # Calibrate for 20 seconds
esp-cmd <host> CALIBRATE STATUS # Show baseline info (valid/invalid, nsub)
esp-cmd <host> CALIBRATE CLEAR # Delete baseline, disable presence
esp-cmd <host> PRESENCE # Query: on/off, baseline, threshold, score
esp-cmd <host> PRESENCE ON # Enable presence detection (needs baseline)
esp-cmd <host> PRESENCE OFF # Disable presence detection
esp-cmd <host> PRESENCE THRESHOLD 0.08 # Set presence threshold (0.001-1.0)
esp-cmd <host> REBOOT # Restart device
```
@@ -142,6 +150,38 @@ esp-cmd amber-maple.local THRESHOLD 0.005 # Tune sensitivity
# Good starting range: 0.001 - 0.01
```
## Presence Detection
CSI-based presence detection compares live per-subcarrier amplitudes against
a baseline captured with the room empty.
### Setup
```bash
# 1. Ensure room is empty, CSI is flowing (200+ packets)
esp-cmd amber-maple.local CALIBRATE 10 # Capture 10s baseline
# Wait for: EVENT,amber-maple,calibrate=done packets=1000 nsub=52
# 2. Verify baseline
esp-cmd amber-maple.local CALIBRATE STATUS # Should show "valid nsub=52"
# 3. Enable presence detection
esp-cmd amber-maple.local PRESENCE ON # Starts scoring
# 4. Tune threshold (optional)
esp-cmd amber-maple.local PRESENCE THRESHOLD 0.08 # Higher = less sensitive
```
### How It Works
- **Baseline:** Average per-subcarrier amplitude over N seconds (stored in NVS)
- **Scoring:** Normalized Euclidean distance: `sqrt(sum((live-baseline)^2) / sum(baseline^2))`
- **Window:** Rolling average of 50 scores
- **Threshold:** Default 0.05 (5% normalized distance)
- **Holdoff:** 10s debounce between state transitions
- **Events:** `EVENT,<hostname>,presence=1 score=0.0832` / `EVENT,<hostname>,presence=0 score=0.0234`
- **Memory:** ~1 KB total (baseline + accumulators + score buffer)
### LED States
| LED | Meaning |
@@ -250,6 +290,8 @@ CSI_DATA,<hostname>,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" # RAW
CSI_DATA,<hostname>,seq,mac,rssi,rate,...,len,first_word,"F:rms,std,max,idx,energy" # COMPACT mode
BLE_DATA,<hostname>,mac,rssi,pub|rnd,name
EVENT,<hostname>,motion=0|1 rate=<hz> wander=<value>
EVENT,<hostname>,calibrate=done packets=<n> nsub=<n>
EVENT,<hostname>,presence=0|1 score=<float>
ALERT_DATA,<hostname>,deauth|disassoc,sender_mac,target_mac,rssi
ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>
PROBE_DATA,<hostname>,mac,rssi,ssid
@@ -288,6 +330,9 @@ only generated on ESP32-C6 and newer chips.
| hybrid_n | 10 | Raw packet interval (hybrid mode) |
| auth | on/off | HMAC command authentication |
| flood_thresh | 5/10 | Deauth flood: count/window_seconds |
| powersave | on/off | WiFi modem sleep |
| presence | on/off | Presence detection |
| pr_score | 0.0432 | Current presence score (0 = no change from baseline) |
## PROFILE Sections

View File

@@ -109,6 +109,7 @@ typedef struct {
float amp_std;
float amp_max;
uint8_t amp_max_idx;
uint8_t n_sub;
uint32_t energy;
} csi_features_t;
@@ -167,6 +168,31 @@ static int s_deauth_ring_count = 0;
static volatile bool s_powertest_running = false;
static bool s_powersave = false;
/* Baseline calibration & presence detection */
#define BASELINE_MAX_SUBS 64
#define PRESENCE_WINDOW 50
#define PRESENCE_HOLDOFF_US 10000000LL /* 10s debounce */
#define DEFAULT_PR_THRESH 0.05f /* 5% normalized distance */
static float s_baseline_amps[BASELINE_MAX_SUBS];
static int s_baseline_nsub = 0; /* 0 = no baseline */
static bool s_presence_enabled = false;
static float s_pr_threshold = DEFAULT_PR_THRESH;
static volatile float s_pr_last_score = 0.0f;
static volatile bool s_presence_detected = false;
static int64_t s_last_presence_time = 0;
/* Calibration state (transient) */
static volatile bool s_calibrating = false;
static float s_calib_accum[BASELINE_MAX_SUBS];
static volatile uint32_t s_calib_count = 0;
static int s_calib_target = 0;
static int s_calib_nsub = 0;
/* Presence scoring buffer */
static float s_pr_scores[PRESENCE_WINDOW];
static uint32_t s_pr_score_idx = 0;
/* Probe dedup rate (moved before config_load_nvs for NVS access) */
#define PROBE_DEDUP_DEFAULT_US 10000000LL
static int64_t s_probe_dedup_us = PROBE_DEDUP_DEFAULT_US;
@@ -242,10 +268,26 @@ static void config_load_nvs(void)
if (nvs_get_i8(h, "powersave", &powersave) == ESP_OK) {
s_powersave = (powersave != 0);
}
int8_t presence;
if (nvs_get_i8(h, "presence", &presence) == ESP_OK) {
s_presence_enabled = (presence != 0);
}
int32_t pr_thresh;
if (nvs_get_i32(h, "pr_thresh", &pr_thresh) == ESP_OK && pr_thresh > 0) {
s_pr_threshold = (float)pr_thresh / 1000000.0f;
}
int8_t bl_nsub;
if (nvs_get_i8(h, "bl_nsub", &bl_nsub) == ESP_OK && bl_nsub > 0 && bl_nsub <= BASELINE_MAX_SUBS) {
size_t bl_len = (size_t)bl_nsub * sizeof(float);
if (nvs_get_blob(h, "bl_amps", s_baseline_amps, &bl_len) == ESP_OK) {
s_baseline_nsub = (int)bl_nsub;
}
}
nvs_close(h);
ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d csi_mode=%d hybrid_n=%d powersave=%d",
ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d csi_mode=%d hybrid_n=%d powersave=%d presence=%d pr_thresh=%.4f baseline_nsub=%d",
s_hostname, s_send_frequency, s_tx_power_dbm, s_adaptive, s_motion_threshold, s_ble_enabled,
s_target_ip, s_target_port, (int)s_csi_mode, s_hybrid_interval, s_powersave);
s_target_ip, s_target_port, (int)s_csi_mode, s_hybrid_interval, s_powersave,
s_presence_enabled, s_pr_threshold, s_baseline_nsub);
} else {
ESP_LOGI(TAG, "NVS: no saved config, using defaults");
}
@@ -296,6 +338,28 @@ static esp_err_t config_save_str(const char *key, const char *value)
return err;
}
static esp_err_t config_save_blob(const char *key, const void *data, size_t len)
{
nvs_handle_t h;
esp_err_t err = nvs_open("csi_config", NVS_READWRITE, &h);
if (err != ESP_OK) return err;
err = nvs_set_blob(h, key, data, len);
if (err == ESP_OK) err = nvs_commit(h);
nvs_close(h);
return err;
}
static esp_err_t config_erase_key(const char *key)
{
nvs_handle_t h;
esp_err_t err = nvs_open("csi_config", NVS_READWRITE, &h);
if (err != ESP_OK) return err;
err = nvs_erase_key(h, key);
if (err == ESP_OK) err = nvs_commit(h);
nvs_close(h);
return err;
}
/* --- LED --- */
static void led_gpio_init(void)
@@ -372,7 +436,7 @@ static void led_task(void *arg)
/* --- CSI feature extraction --- */
static void csi_extract_features(const int8_t *buf, int len, float gain, csi_features_t *out)
static void csi_extract_features(const int8_t *buf, int len, float gain, csi_features_t *out, float *amps_out)
{
int n_pairs = len / 2;
if (n_pairs > 64) n_pairs = 64;
@@ -399,6 +463,10 @@ static void csi_extract_features(const int8_t *buf, int len, float gain, csi_fea
}
}
if (amps_out && n_pairs > 0) {
memcpy(amps_out, amps, n_pairs * sizeof(float));
}
float mean_amp = (n_pairs > 0) ? sum_amp / n_pairs : 0.0f;
/* Pass 2: compute variance */
@@ -413,6 +481,7 @@ static void csi_extract_features(const int8_t *buf, int len, float gain, csi_fea
out->amp_std = sqrtf(var);
out->amp_max = max_amp;
out->amp_max_idx = max_idx;
out->n_sub = (uint8_t)n_pairs;
out->energy = energy;
}
@@ -457,10 +526,41 @@ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info)
ESP_LOGD(TAG, "compensate_gain %f, agc_gain %d, fft_gain %d", compensate_gain, agc_gain, fft_gain);
#endif
/* Extract features (used for compact mode and adaptive sampling) */
/* Extract features (used for compact mode, adaptive sampling, calibration, presence) */
csi_features_t features = {0};
if (s_csi_mode != CSI_MODE_RAW || s_adaptive) {
csi_extract_features(info->buf, info->len, compensate_gain, &features);
bool need_amps = s_calibrating || (s_presence_enabled && s_baseline_nsub > 0);
static float s_live_amps[64];
if (s_csi_mode != CSI_MODE_RAW || s_adaptive || need_amps) {
csi_extract_features(info->buf, info->len, compensate_gain, &features,
need_amps ? s_live_amps : NULL);
}
/* Calibration: accumulate per-subcarrier amplitudes */
if (s_calibrating && features.n_sub > 0) {
if (s_calib_count == 0) {
s_calib_nsub = features.n_sub;
memset(s_calib_accum, 0, sizeof(s_calib_accum));
}
int nsub = (features.n_sub < s_calib_nsub) ? features.n_sub : s_calib_nsub;
for (int i = 0; i < nsub; i++) {
s_calib_accum[i] += s_live_amps[i];
}
s_calib_count++;
}
/* Presence scoring: normalized Euclidean distance vs baseline */
if (!s_calibrating && s_presence_enabled && s_baseline_nsub > 0 && features.n_sub > 0) {
int nsub = (features.n_sub < s_baseline_nsub) ? features.n_sub : s_baseline_nsub;
float sum_diff_sq = 0.0f;
float sum_base_sq = 0.0f;
for (int i = 0; i < nsub; i++) {
float d = s_live_amps[i] - s_baseline_amps[i];
sum_diff_sq += d * d;
sum_base_sq += s_baseline_amps[i] * s_baseline_amps[i];
}
float score = (sum_base_sq > 0.0f) ? sqrtf(sum_diff_sq / sum_base_sq) : 0.0f;
s_pr_scores[s_pr_score_idx % PRESENCE_WINDOW] = score;
s_pr_score_idx++;
}
/* Determine whether to send raw I/Q data this packet */
@@ -754,6 +854,60 @@ static void adaptive_task(void *arg)
while (1) {
vTaskDelay(pdMS_TO_TICKS(500));
/* Calibration finalization (NVS I/O happens here, not in WiFi callback) */
if (s_calibrating && s_calib_count >= (uint32_t)s_calib_target) {
int nsub = s_calib_nsub;
uint32_t count = s_calib_count;
for (int i = 0; i < nsub; i++) {
s_baseline_amps[i] = s_calib_accum[i] / (float)count;
}
s_baseline_nsub = nsub;
config_save_blob("bl_amps", s_baseline_amps, nsub * sizeof(float));
config_save_i8("bl_nsub", (int8_t)nsub);
char event[128];
int len = snprintf(event, sizeof(event),
"EVENT,%s,calibrate=done packets=%lu nsub=%d",
s_hostname, (unsigned long)count, nsub);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, event, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
}
ESP_LOGI(TAG, "Calibration done: %lu packets, %d subcarriers",
(unsigned long)count, nsub);
s_calibrating = false;
}
/* Presence event emission */
if (s_presence_enabled && s_baseline_nsub > 0 && s_pr_score_idx >= PRESENCE_WINDOW) {
float sum = 0.0f;
for (int i = 0; i < PRESENCE_WINDOW; i++) {
sum += s_pr_scores[i];
}
float mean_score = sum / PRESENCE_WINDOW;
s_pr_last_score = mean_score;
bool detected = (mean_score > s_pr_threshold);
if (detected != s_presence_detected) {
int64_t now = esp_timer_get_time();
if (now - s_last_presence_time > PRESENCE_HOLDOFF_US) {
s_presence_detected = detected;
s_last_presence_time = now;
char event[128];
int len = snprintf(event, sizeof(event),
"EVENT,%s,presence=%d score=%.4f",
s_hostname, detected ? 1 : 0, mean_score);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, event, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
}
ESP_LOGI(TAG, "Presence: %s (score=%.4f threshold=%.4f)",
detected ? "detected" : "cleared", mean_score, s_pr_threshold);
}
}
}
if (!s_adaptive || s_energy_idx < WANDER_WINDOW) continue;
/* Compute mean */
@@ -1272,7 +1426,8 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
"OK STATUS uptime=%s uptime_s=%lld heap=%lu rssi=%d channel=%d tx_power=%d rate=%d csi_rate=%d"
" hostname=%s version=%s adaptive=%s motion=%d ble=%s target=%s:%d"
" temp=%.1f csi_count=%lu boots=%lu rssi_min=%d rssi_max=%d"
" csi_mode=%s hybrid_n=%d auth=%s flood_thresh=%d/%d powersave=%s",
" csi_mode=%s hybrid_n=%d auth=%s flood_thresh=%d/%d powersave=%s"
" presence=%s pr_score=%.4f",
uptime_str, (long long)up, (unsigned long)heap, rssi, channel, (int)s_tx_power_dbm,
s_send_frequency, actual_rate,
s_hostname, app_desc->version,
@@ -1283,7 +1438,8 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
csi_mode_str, s_hybrid_interval,
s_auth_secret[0] ? "on" : "off",
s_flood_thresh, s_flood_window_s,
s_powersave ? "on" : "off");
s_powersave ? "on" : "off",
s_presence_enabled ? "on" : "off", s_pr_last_score);
return strlen(reply);
}
@@ -1656,6 +1812,106 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return strlen(reply);
}
/* CALIBRATE [seconds | STATUS | CLEAR] */
if (strcmp(cmd, "CALIBRATE") == 0 || strncmp(cmd, "CALIBRATE ", 10) == 0) {
const char *arg = (cmd[9] == ' ') ? cmd + 10 : "";
if (strcmp(arg, "STATUS") == 0) {
if (s_calibrating) {
snprintf(reply, reply_size, "OK CALIBRATE in_progress packets=%lu target=%d",
(unsigned long)s_calib_count, s_calib_target);
} else if (s_baseline_nsub > 0) {
snprintf(reply, reply_size, "OK CALIBRATE baseline valid nsub=%d", s_baseline_nsub);
} else {
snprintf(reply, reply_size, "OK CALIBRATE baseline none");
}
return strlen(reply);
}
if (strcmp(arg, "CLEAR") == 0) {
s_baseline_nsub = 0;
s_presence_enabled = false;
s_presence_detected = false;
s_pr_last_score = 0.0f;
config_erase_key("bl_amps");
config_erase_key("bl_nsub");
config_save_i8("presence", 0);
snprintf(reply, reply_size, "OK CALIBRATE cleared (presence off)");
return strlen(reply);
}
if (s_calibrating) {
snprintf(reply, reply_size, "ERR CALIBRATE already running");
return strlen(reply);
}
if (s_csi_count < 200) {
snprintf(reply, reply_size, "ERR CALIBRATE need 200+ CSI packets first (have %lu)",
(unsigned long)s_csi_count);
return strlen(reply);
}
int seconds = 10;
if (arg[0] != '\0') {
seconds = atoi(arg);
}
if (seconds < 3 || seconds > 60) {
snprintf(reply, reply_size, "ERR CALIBRATE range 3-60 seconds");
return strlen(reply);
}
/* Start calibration */
s_calib_count = 0;
s_calib_nsub = 0;
s_calib_target = seconds * s_send_frequency;
if (s_calib_target < 30) s_calib_target = 30;
s_calibrating = true;
snprintf(reply, reply_size, "OK CALIBRATE started %ds target=%d packets", seconds, s_calib_target);
return strlen(reply);
}
/* PRESENCE [ON|OFF|THRESHOLD <val>] */
if (strcmp(cmd, "PRESENCE") == 0) {
snprintf(reply, reply_size, "OK PRESENCE %s baseline=%s threshold=%.4f score=%.4f",
s_presence_enabled ? "on" : "off",
s_baseline_nsub > 0 ? "yes" : "no",
s_pr_threshold, s_pr_last_score);
return strlen(reply);
}
if (strncmp(cmd, "PRESENCE ", 9) == 0) {
const char *arg = cmd + 9;
if (strncmp(arg, "ON", 2) == 0) {
if (s_baseline_nsub == 0) {
snprintf(reply, reply_size, "ERR PRESENCE need baseline (run CALIBRATE first)");
return strlen(reply);
}
s_presence_enabled = true;
s_presence_detected = false;
s_pr_last_score = 0.0f;
s_pr_score_idx = 0;
s_last_presence_time = 0;
config_save_i8("presence", 1);
snprintf(reply, reply_size, "OK PRESENCE on threshold=%.4f", s_pr_threshold);
} else if (strncmp(arg, "OFF", 3) == 0) {
s_presence_enabled = false;
s_presence_detected = false;
s_pr_last_score = 0.0f;
config_save_i8("presence", 0);
snprintf(reply, reply_size, "OK PRESENCE off");
} else if (strncmp(arg, "THRESHOLD ", 10) == 0) {
float val = strtof(arg + 10, NULL);
if (val < 0.001f || val > 1.0f) {
snprintf(reply, reply_size, "ERR PRESENCE THRESHOLD range 0.001-1.0");
return strlen(reply);
}
s_pr_threshold = val;
config_save_i32("pr_thresh", (int32_t)(val * 1000000.0f));
snprintf(reply, reply_size, "OK PRESENCE THRESHOLD %.4f", val);
} else {
snprintf(reply, reply_size, "ERR PRESENCE ON|OFF|THRESHOLD <0.001-1.0>");
}
return strlen(reply);
}
snprintf(reply, reply_size, "ERR UNKNOWN");
return strlen(reply);
}