feat: Add v0.4 adaptive sampling — wander detection, auto rate control

On-device CSI wander calculation (coefficient of variation over 50-packet
window). Rate drops to 10 Hz when idle, jumps to 100 Hz on motion with
3s holdoff. EVENT notifications sent to Pi on rate changes. New commands:
ADAPTIVE ON/OFF, THRESHOLD. RATE command disables adaptive mode.
All settings NVS-persisted.
This commit is contained in:
user
2026-02-04 16:34:19 +01:00
parent 2f90a099b7
commit c922e05266
4 changed files with 191 additions and 23 deletions
+9 -5
View File
@@ -34,11 +34,15 @@
- [x] USB-flash first device (partition table change) - [x] USB-flash first device (partition table change)
- [x] End-to-end OTA test - [x] End-to-end OTA test
## v0.4 - Adaptive Sampling ## v0.4 - Adaptive Sampling [DONE]
- [ ] On-device wander calculation (simplified) - [x] On-device CSI wander calculation (coefficient of variation)
- [ ] Reduce to 10 pkt/s when idle - [x] Reduce to 10 pkt/s when idle (3s holdoff)
- [ ] Increase to 100 pkt/s on motion detection - [x] Increase to 100 pkt/s on motion detection
- [ ] Rate change notification to Pi - [x] Rate change EVENT notification to Pi via UDP
- [x] ADAPTIVE ON/OFF command (NVS persisted)
- [x] THRESHOLD command for tuning sensitivity (NVS persisted)
- [x] RATE command disables adaptive mode
- [x] adaptive/motion fields in STATUS reply
## v0.5 - BLE Scanning ## v0.5 - BLE Scanning
- [ ] Enable Bluetooth alongside WiFi - [ ] Enable Bluetooth alongside WiFi
+20 -12
View File
@@ -2,15 +2,15 @@
**Last Updated:** 2026-02-04 **Last Updated:** 2026-02-04
## Current Sprint: v0.4 - Adaptive Sampling ## Current Sprint: v0.5 - BLE Scanning
### P0 - Critical ### P0 - Critical
- [ ] On-device CSI wander calculation (simplified) - [ ] Enable Bluetooth alongside WiFi
- [ ] Adaptive rate: 10 pkt/s idle → 100 pkt/s on motion - [ ] Periodic BLE advertisement scanning
### P1 - Important ### P1 - Important
- [ ] Rate change notification to Pi - [ ] Report device MAC, RSSI, name via UDP
- [ ] Tunable motion threshold via UDP command - [ ] Pi-side BLE device tracking
### P2 - Normal ### P2 - Normal
- [ ] OTA update remaining fleet (muddy-storm, hollow-acorn) via USB - [ ] OTA update remaining fleet (muddy-storm, hollow-acorn) via USB
@@ -20,6 +20,17 @@
- [ ] Document esp-crab dual-antenna capabilities - [ ] Document esp-crab dual-antenna capabilities
- [ ] Document esp-radar console features - [ ] Document esp-radar console features
## Completed: v0.4 - Adaptive Sampling
- [x] On-device CSI wander calculation (coefficient of variation)
- [x] Adaptive rate: 10 pkt/s idle (3s holdoff) → 100 pkt/s on motion
- [x] EVENT notification to Pi on rate change
- [x] ADAPTIVE ON/OFF command (NVS persisted)
- [x] THRESHOLD command for tuning sensitivity (NVS persisted)
- [x] RATE command disables adaptive mode
- [x] adaptive/motion fields in STATUS reply
- [x] OTA deployed and verified on amber-maple
## Completed: v0.3 - OTA Updates ## Completed: v0.3 - OTA Updates
- [x] Dual OTA partition table (`partitions.csv`) - [x] Dual OTA partition table (`partitions.csv`)
@@ -44,8 +55,6 @@
- [x] Pi-side: `esp-cmd` CLI tool - [x] Pi-side: `esp-cmd` CLI tool
- [x] Pi-side: `esp-fleet` fleet management tool - [x] Pi-side: `esp-fleet` fleet management tool
- [x] mDNS hostname, watchdog, human-readable uptime - [x] mDNS hostname, watchdog, human-readable uptime
- [x] Build and flash to device
- [x] Update CHEATSHEET.md
## Completed: v0.1 - Documentation ## Completed: v0.1 - Documentation
@@ -58,8 +67,7 @@
## Notes ## Notes
- NVS offset changes with new partition table — first USB flash resets saved config - Adaptive threshold varies by environment; 0.001-0.01 is a good starting range
- First device must be USB-flashed to switch partition table, subsequent updates via OTA - NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`
- `esp_https_ota` is built into ESP-IDF core — no extra deps needed - EVENT packets sent on CSI UDP port when adaptive rate changes
- OTA download ~896 KB on LAN takes ~3-5s, well under 30s watchdog - OTA download ~904 KB on LAN takes ~3-5s, well under 30s watchdog
- CSI data keeps flowing during OTA download
+20 -2
View File
@@ -34,10 +34,13 @@ idf.py reconfigure # Re-fetch managed components
## Remote Management (esp-cmd) ## Remote Management (esp-cmd)
```bash ```bash
esp-cmd <host> STATUS # Uptime, heap, RSSI, tx_power, rate, version esp-cmd <host> STATUS # Uptime, heap, RSSI, rate, version, adaptive, motion
esp-cmd <host> IDENTIFY # LED solid 5s (find the device) esp-cmd <host> IDENTIFY # LED solid 5s (find the device)
esp-cmd <host> RATE 50 # Set ping rate to 50 Hz (NVS saved) esp-cmd <host> RATE 50 # Set ping rate to 50 Hz (disables adaptive)
esp-cmd <host> POWER 15 # Set TX power to 15 dBm (NVS saved) esp-cmd <host> POWER 15 # Set TX power to 15 dBm (NVS saved)
esp-cmd <host> ADAPTIVE ON # Enable adaptive sampling (NVS saved)
esp-cmd <host> ADAPTIVE OFF # Disable adaptive sampling
esp-cmd <host> THRESHOLD 0.005 # Set motion sensitivity (NVS saved)
esp-cmd <host> OTA http://pi:8070/fw # Trigger OTA update (use esp-ota instead) esp-cmd <host> OTA http://pi:8070/fw # Trigger OTA update (use esp-ota instead)
esp-cmd <host> REBOOT # Restart device esp-cmd <host> REBOOT # Restart device
``` ```
@@ -84,6 +87,21 @@ After that, all updates are OTA.
If new firmware crashes or hangs, the 30s watchdog reboots and bootloader If new firmware crashes or hangs, the 30s watchdog reboots and bootloader
automatically rolls back to the previous firmware. automatically rolls back to the previous firmware.
## Adaptive Sampling
When enabled, the device automatically adjusts ping rate based on CSI wander:
- **Motion detected** (wander > threshold): 100 pkt/s
- **Idle** (wander < threshold for 3s): 10 pkt/s
- Rate changes send `EVENT motion=<0|1> rate=<hz> wander=<value>` via UDP
```bash
esp-cmd amber-maple.local ADAPTIVE ON # Enable
esp-cmd amber-maple.local THRESHOLD 0.005 # Tune sensitivity
# Lower threshold = more sensitive, higher = less sensitive
# Good starting range: 0.001 - 0.01
```
### LED States ### LED States
| LED | Meaning | | LED | Meaning |
+142 -4
View File
@@ -14,6 +14,7 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <math.h>
#include <errno.h> #include <errno.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -80,6 +81,20 @@ static volatile int64_t s_last_csi_time = 0;
static volatile int64_t s_identify_end_time = 0; static volatile int64_t s_identify_end_time = 0;
static volatile bool s_ota_in_progress = false; static volatile bool s_ota_in_progress = false;
/* Adaptive sampling */
#define WANDER_WINDOW 50
#define RATE_ACTIVE 100
#define RATE_IDLE 10
#define IDLE_HOLDOFF_US 3000000LL /* 3s of no motion before dropping rate */
#define DEFAULT_THRESHOLD 0.002f
static bool s_adaptive = false;
static float s_motion_threshold = DEFAULT_THRESHOLD;
static volatile bool s_motion_detected = false;
static volatile int64_t s_last_motion_time = 0;
static uint32_t s_energy_buf[WANDER_WINDOW];
static uint32_t s_energy_idx = 0;
/* UDP socket for CSI data transmission */ /* UDP socket for CSI data transmission */
static int s_udp_socket = -1; static int s_udp_socket = -1;
static struct sockaddr_in s_dest_addr; static struct sockaddr_in s_dest_addr;
@@ -99,8 +114,17 @@ static void config_load_nvs(void)
if (nvs_get_i8(h, "tx_power", &pwr) == ESP_OK && pwr >= 2 && pwr <= 20) { if (nvs_get_i8(h, "tx_power", &pwr) == ESP_OK && pwr >= 2 && pwr <= 20) {
s_tx_power_dbm = pwr; s_tx_power_dbm = pwr;
} }
int8_t adaptive;
if (nvs_get_i8(h, "adaptive", &adaptive) == ESP_OK) {
s_adaptive = (adaptive != 0);
}
int32_t thresh;
if (nvs_get_i32(h, "threshold", &thresh) == ESP_OK && thresh > 0) {
s_motion_threshold = (float)thresh / 1000000.0f;
}
nvs_close(h); nvs_close(h);
ESP_LOGI(TAG, "NVS loaded: rate=%d tx_power=%d", s_send_frequency, s_tx_power_dbm); ESP_LOGI(TAG, "NVS loaded: rate=%d tx_power=%d adaptive=%d threshold=%.6f",
s_send_frequency, s_tx_power_dbm, s_adaptive, s_motion_threshold);
} else { } else {
ESP_LOGI(TAG, "NVS: no saved config, using defaults"); ESP_LOGI(TAG, "NVS: no saved config, using defaults");
} }
@@ -286,6 +310,16 @@ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info)
sendto(s_udp_socket, s_udp_buffer, pos, 0, (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); sendto(s_udp_socket, s_udp_buffer, pos, 0, (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
} }
/* Compute CSI energy for adaptive sampling */
if (s_adaptive) {
uint32_t energy = 0;
for (int i = 0; i < info->len; i++) {
energy += abs(info->buf[i]);
}
s_energy_buf[s_energy_idx % WANDER_WINDOW] = energy;
s_energy_idx++;
}
s_count++; s_count++;
} }
@@ -392,6 +426,71 @@ static esp_err_t wifi_ping_router_start(void)
return ESP_OK; return ESP_OK;
} }
/* --- Adaptive sampling --- */
static void adaptive_task(void *arg)
{
while (1) {
vTaskDelay(pdMS_TO_TICKS(500));
if (!s_adaptive || s_energy_idx < WANDER_WINDOW) continue;
/* Compute mean */
float mean = 0;
for (int i = 0; i < WANDER_WINDOW; i++) {
mean += s_energy_buf[i];
}
mean /= WANDER_WINDOW;
if (mean < 1.0f) continue;
/* Compute variance */
float var = 0;
for (int i = 0; i < WANDER_WINDOW; i++) {
float d = s_energy_buf[i] - mean;
var += d * d;
}
var /= WANDER_WINDOW;
/* Wander = coefficient of variation squared */
float wander = var / (mean * mean);
int64_t now = esp_timer_get_time();
bool motion = wander > s_motion_threshold;
if (motion) {
s_last_motion_time = now;
}
int target_rate;
if (motion || (now - s_last_motion_time < IDLE_HOLDOFF_US)) {
target_rate = RATE_ACTIVE;
} else {
target_rate = RATE_IDLE;
}
s_motion_detected = motion;
if (target_rate != s_send_frequency) {
s_send_frequency = target_rate;
wifi_ping_router_start();
/* Notify Pi */
char event[80];
int len = snprintf(event, sizeof(event),
"EVENT motion=%d rate=%d wander=%.6f",
motion ? 1 : 0, target_rate, wander);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, event, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
}
ESP_LOGI(TAG, "Adaptive: %s -> %d Hz (wander=%.6f)",
motion ? "motion" : "idle", target_rate, wander);
}
}
}
/* --- OTA --- */ /* --- OTA --- */
static void ota_task(void *arg) static void ota_task(void *arg)
@@ -478,9 +577,10 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
} }
snprintf(reply, reply_size, snprintf(reply, reply_size,
"OK STATUS uptime=%s heap=%lu rssi=%d tx_power=%d rate=%d hostname=%s version=%s", "OK STATUS uptime=%s heap=%lu rssi=%d tx_power=%d rate=%d hostname=%s version=%s adaptive=%s motion=%d",
uptime_str, (unsigned long)heap, rssi, (int)s_tx_power_dbm, uptime_str, (unsigned long)heap, rssi, (int)s_tx_power_dbm,
s_send_frequency, CONFIG_CSI_HOSTNAME, app_desc->version); s_send_frequency, CONFIG_CSI_HOSTNAME, app_desc->version,
s_adaptive ? "on" : "off", s_motion_detected ? 1 : 0);
return strlen(reply); return strlen(reply);
} }
@@ -491,10 +591,15 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
snprintf(reply, reply_size, "ERR RATE range 10-100"); snprintf(reply, reply_size, "ERR RATE range 10-100");
return strlen(reply); return strlen(reply);
} }
if (s_adaptive) {
s_adaptive = false;
s_motion_detected = false;
config_save_i8("adaptive", 0);
}
s_send_frequency = val; s_send_frequency = val;
config_save_i32("send_rate", (int32_t)val); config_save_i32("send_rate", (int32_t)val);
wifi_ping_router_start(); wifi_ping_router_start();
snprintf(reply, reply_size, "OK RATE %d", val); snprintf(reply, reply_size, "OK RATE %d (adaptive off)", val);
return strlen(reply); return strlen(reply);
} }
@@ -512,6 +617,38 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return strlen(reply); return strlen(reply);
} }
/* ADAPTIVE ON/OFF */
if (strncmp(cmd, "ADAPTIVE ", 9) == 0) {
const char *arg = cmd + 9;
if (strncmp(arg, "ON", 2) == 0) {
s_adaptive = true;
s_energy_idx = 0;
config_save_i8("adaptive", 1);
snprintf(reply, reply_size, "OK ADAPTIVE on threshold=%.6f", s_motion_threshold);
} else if (strncmp(arg, "OFF", 3) == 0) {
s_adaptive = false;
s_motion_detected = false;
config_save_i8("adaptive", 0);
snprintf(reply, reply_size, "OK ADAPTIVE off");
} else {
snprintf(reply, reply_size, "ERR ADAPTIVE ON or OFF");
}
return strlen(reply);
}
/* THRESHOLD <value> */
if (strncmp(cmd, "THRESHOLD ", 10) == 0) {
float val = strtof(cmd + 10, NULL);
if (val <= 0.0f || val > 1.0f) {
snprintf(reply, reply_size, "ERR THRESHOLD range 0.000001-1.0");
return strlen(reply);
}
s_motion_threshold = val;
config_save_i32("threshold", (int32_t)(val * 1000000.0f));
snprintf(reply, reply_size, "OK THRESHOLD %.6f", val);
return strlen(reply);
}
/* OTA <url> */ /* OTA <url> */
if (strncmp(cmd, "OTA ", 4) == 0) { if (strncmp(cmd, "OTA ", 4) == 0) {
const char *url = cmd + 4; const char *url = cmd + 4;
@@ -639,6 +776,7 @@ void app_main()
wifi_ping_router_start(); wifi_ping_router_start();
xTaskCreate(cmd_task, "cmd_task", 4096, NULL, 5, NULL); xTaskCreate(cmd_task, "cmd_task", 4096, NULL, 5, NULL);
xTaskCreate(adaptive_task, "adaptive", 3072, NULL, 3, NULL);
/* OTA rollback: mark firmware valid if we got this far */ /* OTA rollback: mark firmware valid if we got this far */
const esp_partition_t *running = esp_ota_get_running_partition(); const esp_partition_t *running = esp_ota_get_running_partition();