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

@@ -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);
}