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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user