Merge origin/main into feat/accuracy-sprint-001

Resolved conflicts:
- edge_processing.c: kept batch-limited watchdog approach (proven on hardware)
  with PR's per-frame yield comment for clarity
- Cargo.toml: accepted PR's new dependencies
- main.rs: accepted PR's Kalman tracker + multi-node fusion additions

Co-Authored-By: taylorjdawson <taylor@example.com>
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-04-03 08:57:26 -04:00
110 changed files with 32722 additions and 447 deletions
+103 -22
View File
@@ -42,6 +42,12 @@ static const char *TAG = "edge_proc";
static edge_ring_buf_t s_ring;
/* Scratch buffers for BPM estimation — moved from stack to static to avoid
* stack overflow. process_frame + update_multi_person_vitals combined used
* ~6.5-7.5 KB of the 8 KB task stack. These save ~4 KB of stack. */
static float s_scratch_br[EDGE_PHASE_HISTORY_LEN];
static float s_scratch_hr[EDGE_PHASE_HISTORY_LEN];
static inline bool ring_push(const uint8_t *iq, uint16_t len,
int8_t rssi, uint8_t channel)
{
@@ -268,6 +274,9 @@ static uint8_t s_prev_iq[EDGE_MAX_IQ_BYTES];
static uint16_t s_prev_iq_len;
static bool s_has_prev_iq;
/** ADR-069: Feature vector sequence counter. */
static uint16_t s_feature_seq;
/** Multi-person vitals state. */
static edge_person_vitals_t s_persons[EDGE_MAX_PERSONS];
static edge_biquad_t s_person_bq_br[EDGE_MAX_PERSONS];
@@ -402,10 +411,10 @@ static uint16_t delta_compress(const uint8_t *curr, uint16_t len,
}
/**
* Send a compressed CSI frame (magic 0xC5110003).
* Send a compressed CSI frame (magic 0xC5110005, reassigned from 0xC5110003 for ADR-069).
*
* Header:
* [0..3] Magic 0xC5110003 (LE)
* [0..3] Magic 0xC5110005 (LE)
* [4] Node ID
* [5] Channel
* [6..7] Original I/Q length (LE u16)
@@ -511,20 +520,18 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
/* Estimate BPM when we have enough history. */
if (pv->history_len >= 64) {
/* Build contiguous buffer for zero-crossing. */
float br_buf[EDGE_PHASE_HISTORY_LEN];
float hr_buf[EDGE_PHASE_HISTORY_LEN];
/* Build contiguous buffer (reuse static scratch to save ~2 KB stack). */
uint16_t buf_len = pv->history_len;
for (uint16_t i = 0; i < buf_len; i++) {
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
br_buf[i] = s_person_br_filt[p][ri];
hr_buf[i] = s_person_hr_filt[p][ri];
s_scratch_br[i] = s_person_br_filt[p][ri];
s_scratch_hr[i] = s_person_hr_filt[p][ri];
}
float br = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
float hr = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
float br = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
float hr = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
/* Sanity clamp. */
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
@@ -628,6 +635,70 @@ static void send_vitals_packet(void)
}
}
/* ======================================================================
* ADR-069: Feature Vector Packet (48 bytes, sent at 1 Hz alongside vitals)
* ====================================================================== */
static void send_feature_vector(void)
{
edge_feature_pkt_t pkt;
memset(&pkt, 0, sizeof(pkt));
pkt.magic = EDGE_FEATURE_MAGIC;
pkt.node_id = g_nvs_config.node_id;
pkt.reserved = 0;
pkt.seq = s_feature_seq++;
pkt.timestamp_us = esp_timer_get_time();
/* Dim 0: Presence score (0.0-1.0, normalized from raw score) */
float p = s_presence_score;
pkt.features[0] = p > 10.0f ? 1.0f : (p < 0.0f ? 0.0f : p / 10.0f);
/* Dim 1: Motion energy (normalized, 0-1 range) */
float m = s_motion_energy;
pkt.features[1] = m > 10.0f ? 1.0f : (m < 0.0f ? 0.0f : m / 10.0f);
/* Dim 2: Breathing rate (BPM / 30, 0-1 range) */
pkt.features[2] = s_breathing_bpm > 0.0f
? (s_breathing_bpm / 30.0f > 1.0f ? 1.0f : s_breathing_bpm / 30.0f)
: 0.0f;
/* Dim 3: Heart rate (BPM / 120, 0-1 range) */
pkt.features[3] = s_heartrate_bpm > 0.0f
? (s_heartrate_bpm / 120.0f > 1.0f ? 1.0f : s_heartrate_bpm / 120.0f)
: 0.0f;
/* Dim 4: Phase variance mean (top-K subcarriers) */
float var_mean = 0.0f;
if (s_top_k_count > 0) {
float var_sum = 0.0f;
uint8_t k = s_top_k_count < EDGE_TOP_K ? s_top_k_count : EDGE_TOP_K;
for (uint8_t i = 0; i < k; i++) {
var_sum += (float)welford_variance(&s_subcarrier_var[s_top_k[i]]);
}
var_mean = var_sum / (float)k;
}
pkt.features[4] = var_mean > 1.0f ? 1.0f : (var_mean < 0.0f ? 0.0f : var_mean);
/* Dim 5: Person count (n_persons / 4, 0-1 range) */
uint8_t n_active = 0;
for (uint8_t i = 0; i < EDGE_MAX_PERSONS; i++) {
if (s_persons[i].active) n_active++;
}
pkt.features[5] = (float)n_active / 4.0f;
if (pkt.features[5] > 1.0f) pkt.features[5] = 1.0f;
/* Dim 6: Fall risk (0.0 or 1.0 based on recent detection) */
pkt.features[6] = s_fall_detected ? 1.0f : 0.0f;
/* Dim 7: RSSI normalized ((rssi + 100) / 100, 0-1 range) */
pkt.features[7] = ((float)s_latest_rssi + 100.0f) / 100.0f;
if (pkt.features[7] > 1.0f) pkt.features[7] = 1.0f;
if (pkt.features[7] < 0.0f) pkt.features[7] = 0.0f;
stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
}
/* ======================================================================
* Main DSP Pipeline (runs on Core 1)
* ====================================================================== */
@@ -688,20 +759,18 @@ static void process_frame(const edge_ring_slot_t *slot)
/* --- Step 7: BPM estimation (zero-crossing) --- */
if (s_history_len >= 64) {
/* Build contiguous buffers from ring. */
float br_buf[EDGE_PHASE_HISTORY_LEN];
float hr_buf[EDGE_PHASE_HISTORY_LEN];
/* Build contiguous buffers from ring (using static scratch to save stack). */
uint16_t buf_len = s_history_len;
for (uint16_t i = 0; i < buf_len; i++) {
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
br_buf[i] = s_breathing_filtered[ri];
hr_buf[i] = s_heartrate_filtered[ri];
s_scratch_br[i] = s_breathing_filtered[ri];
s_scratch_hr[i] = s_heartrate_filtered[ri];
}
float br_bpm = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
float hr_bpm = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
float br_bpm = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
float hr_bpm = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
@@ -784,6 +853,7 @@ static void process_frame(const edge_ring_slot_t *slot)
int64_t interval_us = (int64_t)s_cfg.vital_interval_ms * 1000;
if ((now_us - s_last_vitals_send_us) >= interval_us) {
send_vitals_packet();
send_feature_vector(); /* ADR-069: 48-byte feature vector at same 1 Hz cadence. */
s_last_vitals_send_us = now_us;
if ((s_frame_count % 200) == 0) {
@@ -831,15 +901,26 @@ static void edge_task(void *arg)
edge_ring_slot_t slot;
/* Maximum frames to process before a longer yield. On busy LANs
* (corporate networks, many APs), the ring buffer fills continuously.
* Without a batch limit the task processes frames back-to-back with
* only 1-tick yields, which on high frame rates can still starve
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
while (1) {
if (ring_pop(&slot)) {
uint8_t processed = 0;
while (processed < EDGE_BATCH_LIMIT && ring_pop(&slot)) {
process_frame(&slot);
/* Yield after every frame to feed the Core 1 watchdog.
* process_frame() is CPU-intensive (biquad filters, Welford stats,
* BPM estimation, multi-person vitals) and can take several ms.
* Without this yield, edge_dsp at priority 5 starves IDLE1 at
* priority 0, triggering the task watchdog. See issue #266. */
processed++;
/* Yield after every frame to feed the Core 1 watchdog. */
vTaskDelay(1);
}
if (processed > 0) {
/* Post-batch yield: ~20 ms so IDLE1 can run and feed the
* Core 1 watchdog even under sustained load. */
{ TickType_t d = pdMS_TO_TICKS(20); vTaskDelay(d > 0 ? d : 1); }
} else {
/* No frames available — yield briefly. */
vTaskDelay(pdMS_TO_TICKS(1));
+18 -1
View File
@@ -26,7 +26,7 @@
/* ---- Magic numbers ---- */
#define EDGE_VITALS_MAGIC 0xC5110002 /**< Vitals packet magic. */
#define EDGE_COMPRESSED_MAGIC 0xC5110003 /**< Compressed frame magic. */
#define EDGE_COMPRESSED_MAGIC 0xC5110005 /**< Compressed frame magic (was 0xC5110003, reassigned for ADR-069). */
/* ---- Buffer sizes ---- */
#define EDGE_RING_SLOTS 16 /**< SPSC ring buffer slots (power of 2). */
@@ -46,6 +46,9 @@
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
/* ---- DSP task tuning ---- */
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */
/* ---- SPSC ring buffer slot ---- */
typedef struct {
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
@@ -106,6 +109,20 @@ typedef struct __attribute__((packed)) {
_Static_assert(sizeof(edge_vitals_pkt_t) == 32, "vitals packet must be 32 bytes");
/* ---- ADR-069: CSI Feature Vector packet (48 bytes, wire format) ---- */
#define EDGE_FEATURE_MAGIC 0xC5110003 /**< Feature vector packet magic. */
typedef struct __attribute__((packed)) {
uint32_t magic; /**< EDGE_FEATURE_MAGIC = 0xC5110003. */
uint8_t node_id; /**< ESP32 node identifier. */
uint8_t reserved; /**< Alignment padding. */
uint16_t seq; /**< Sequence number. */
int64_t timestamp_us; /**< Microseconds since boot. */
float features[8]; /**< 8-dim normalized feature vector. */
} edge_feature_pkt_t;
_Static_assert(sizeof(edge_feature_pkt_t) == 48, "feature packet must be 48 bytes");
/* ---- ADR-063: Fused vitals packet (48 bytes, wire format) ---- */
#define EDGE_FUSED_MAGIC 0xC5110004 /**< Fused vitals packet magic. */
+11
View File
@@ -167,6 +167,17 @@ void app_main(void)
}
#else
csi_collector_init();
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
if (g_nvs_config.channel_hop_count > 1) {
ESP_LOGI(TAG, "Starting channel hopping: %u channels, dwell=%lu ms",
(unsigned)g_nvs_config.channel_hop_count,
(unsigned long)g_nvs_config.dwell_ms);
csi_collector_set_hop_table(
g_nvs_config.channel_list,
g_nvs_config.channel_hop_count,
g_nvs_config.dwell_ms);
}
#endif
/* ADR-039: Initialize edge processing pipeline. */
File diff suppressed because one or more lines are too long
Binary file not shown.
+11
View File
@@ -71,6 +71,14 @@ def build_nvs_csv(args):
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
# NVS blob: write as hex-encoded string for CSV compatibility
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
# ADR-073: Multi-frequency channel hopping
if args.hop_channels is not None:
channels = [int(c.strip()) for c in args.hop_channels.split(",")]
writer.writerow(["hop_count", "data", "u8", str(len(channels))])
# Store as NVS blob (firmware reads "chan_list" as uint8 blob)
chan_bytes = bytes(channels)
writer.writerow(["chan_list", "data", "hex2bin", chan_bytes.hex()])
writer.writerow(["dwell_ms", "data", "u32", str(args.hop_dwell)])
# ADR-066: Swarm bridge configuration
if args.seed_url is not None:
writer.writerow(["seed_url", "data", "string", args.seed_url])
@@ -181,6 +189,9 @@ def main():
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
"Overrides auto-detection from connected AP.")
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
# ADR-073: Multi-frequency channel hopping
parser.add_argument("--hop-channels", type=str, help="Comma-separated channel list for hopping (e.g. '1,6,11')")
parser.add_argument("--hop-dwell", type=int, default=200, help="Dwell time per channel in ms (default: 200)")
# ADR-066: Swarm bridge
parser.add_argument("--seed-url", type=str, help="Cognitum Seed base URL (e.g. http://10.1.10.236)")
parser.add_argument("--seed-token", type=str, help="Seed Bearer token (from pairing)")
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
# ESP32-S3 CSI Node — Default SDK Configuration
# This file is applied automatically by idf.py when no sdkconfig exists.
# Target: ESP32-S3
CONFIG_IDF_TARGET="esp32s3"
# Use custom partition table (8MB flash with OTA — ADR-045)
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_display.csv"
# Flash configuration: 8MB (Quad SPI)
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
# Compiler optimization: optimize for size to reduce binary
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
# Enable CSI (Channel State Information) in WiFi driver
CONFIG_ESP_WIFI_CSI_ENABLED=y
# NVS encryption disabled by default (requires eFuse provisioning).
# Enable only after burning HMAC key to eFuse block.
# CONFIG_NVS_ENCRYPTION is not set
# Disable unused features to reduce binary size
CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# LWIP: enable extended socket options for UDP multicast
CONFIG_LWIP_SO_RCVBUF=y
# FreeRTOS: increase task stack for CSI processing
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192