diff --git a/ROADMAP.md b/ROADMAP.md index 292ba01..6ddcd58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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,,presence=<0|1> score=`) with 10s holdoff +- [x] Calibration done event (`EVENT,,calibrate=done packets= nsub=`) +- [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 diff --git a/TASKS.md b/TASKS.md index 65588b8..c7de9fd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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,,presence=<0|1> score=`) with 10s holdoff +- [x] Calibration done event (`EVENT,,calibrate=done packets= nsub=`) +- [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,,motion=... rate=... wander=...` - ALERT_DATA format: `ALERT_DATA,,,,,` or `ALERT_DATA,,deauth_flood,,` -- 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,,,,` - Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE) - mDNS service: `_esp-csi._udp` on data port (for sensor discovery) diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index dff4dee..e90b001 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -55,6 +55,14 @@ esp-cmd AUTH mysecret123 # Enable HMAC auth (8-64 char secret) esp-cmd AUTH OFF # Disable auth esp-cmd FLOODTHRESH # Query deauth flood threshold (5/10s) esp-cmd FLOODTHRESH 10 30 # Set: 10 deauths in 30s = flood +esp-cmd CALIBRATE # Start baseline capture (default 10s, room must be empty) +esp-cmd CALIBRATE 20 # Calibrate for 20 seconds +esp-cmd CALIBRATE STATUS # Show baseline info (valid/invalid, nsub) +esp-cmd CALIBRATE CLEAR # Delete baseline, disable presence +esp-cmd PRESENCE # Query: on/off, baseline, threshold, score +esp-cmd PRESENCE ON # Enable presence detection (needs baseline) +esp-cmd PRESENCE OFF # Disable presence detection +esp-cmd PRESENCE THRESHOLD 0.08 # Set presence threshold (0.001-1.0) esp-cmd 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,,presence=1 score=0.0832` / `EVENT,,presence=0 score=0.0234` +- **Memory:** ~1 KB total (baseline + accumulators + score buffer) + ### LED States | LED | Meaning | @@ -250,6 +290,8 @@ CSI_DATA,,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" # RAW CSI_DATA,,seq,mac,rssi,rate,...,len,first_word,"F:rms,std,max,idx,energy" # COMPACT mode BLE_DATA,,mac,rssi,pub|rnd,name EVENT,,motion=0|1 rate= wander= +EVENT,,calibrate=done packets= nsub= +EVENT,,presence=0|1 score= ALERT_DATA,,deauth|disassoc,sender_mac,target_mac,rssi ALERT_DATA,,deauth_flood,, PROBE_DATA,,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 diff --git a/get-started/csi_recv_router/main/app_main.c b/get-started/csi_recv_router/main/app_main.c index 4280a47..81ca500 100644 --- a/get-started/csi_recv_router/main/app_main.c +++ b/get-started/csi_recv_router/main/app_main.c @@ -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 ] */ + 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); }