mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
|
||||
@@ -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.
@@ -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.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user