diff --git a/.gitignore b/.gitignore index 4734f46d..4297c868 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,15 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak # ESP-IDF set-target backup (local only) firmware/esp32-hello-world/sdkconfig.old +# Host-built firmware test binaries (compiled from test/*.c, not source) +firmware/esp32-csi-node/test/test_adr110 +firmware/esp32-csi-node/test/test_vitals +firmware/esp32-csi-node/test/fuzz_serialize +firmware/esp32-csi-node/test/fuzz_edge +firmware/esp32-csi-node/test/fuzz_nvs +firmware/esp32-csi-node/test/*.exe +firmware/esp32-csi-node/test/*.obj + # Claude Flow swarm runtime state .swarm/ diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 28ef8da9..6dd2fa04 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -43,9 +43,10 @@ MAIN_DIR = ../main FUZZ_DURATION ?= 30 FUZZ_JOBS ?= 1 -.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 host_tests +.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \ + test_vitals run_vitals host_tests -all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 +all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals # --- ADR-110 encoding unit tests --- # Host-side, no libFuzzer needed — plain C99 deterministic table tests @@ -57,8 +58,19 @@ test_adr110: test_adr110_encoding.c run_adr110: test_adr110 ./test_adr110 -host_tests: run_adr110 - @echo "ADR-110 host tests passed" +# --- Vitals count + presence logic unit tests (issue #998 / #996) --- +# Host-side, no libFuzzer. Pins the person-count gate (no over-count for one +# body) and the presence hysteresis (no flicker on a dithering score). Pulls +# the named tuning constants from ../main/edge_processing.h so the test and the +# firmware can never disagree on thresholds. +test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h + cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $< -lm + +run_vitals: test_vitals + ./test_vitals + +host_tests: run_adr110 run_vitals + @echo "Host tests passed (ADR-110 + vitals #998/#996)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -94,5 +106,5 @@ run_nvs: fuzz_nvs run_all: run_serialize run_edge run_nvs clean: - rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 + rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/test_vitals_count_presence.c b/firmware/esp32-csi-node/test/test_vitals_count_presence.c new file mode 100644 index 00000000..5c238e97 --- /dev/null +++ b/firmware/esp32-csi-node/test/test_vitals_count_presence.c @@ -0,0 +1,387 @@ +/** + * @file test_vitals_count_presence.c + * @brief Host-side unit tests for the issue #998 / #996 vitals logic fixes. + * + * Covers two pure decision functions extracted from edge_processing.c: + * 1. count_distinct_persons() — issue #998 person over-count gate + * (energy gate + spatial dedup). + * 2. person_count_debounce() — issue #998 count persistence debounce. + * 3. presence_flag_update() — issue #996 presence hysteresis + clear + * debounce (Schmitt trigger). + * + * Build (Linux/macOS/Windows with any C99 compiler): + * cc -std=c99 -Wall -I../main -o test_vitals \ + * test_vitals_count_presence.c && ./test_vitals + * + * Exits 0 on all-pass, prints which assertion failed otherwise. + * + * Why a separate host test file: these are deterministic logic checks for the + * exact boundary behaviour the issues describe; libFuzzer adds no signal here. + * + * IMPORTANT — these three functions are copied VERBATIM from + * firmware/esp32-csi-node/main/edge_processing.c. They are pure (no globals, + * no ESP-IDF). If the firmware copy changes, update the copy here and re-run + * this test before the firmware change merges. The named tuning constants are + * pulled from the real header so the test and firmware can never disagree on + * thresholds. + * + * HARDWARE-GATED CAVEAT: these tests pin the *logic* (no flicker / no + * over-count for the synthetic traces). True count accuracy and the exact + * energy/separation/hysteresis thresholds that best match a real room vs + * labelled ground truth remain hardware- and data-gated (COM9 ESP32-S3 + + * labelled occupancy). This is a robustness/logic fix, not a validated + * accuracy claim. + */ + +#include +#include +#include + +/* Named tuning constants come from the real firmware header so the test can + * never silently diverge from the constants the firmware compiles with. */ +#include "edge_processing.h" + +/* ────────────────────────────────────────────────────────────────────── + * System under test — copied VERBATIM from edge_processing.c. + * ────────────────────────────────────────────────────────────────────── */ + +/* count_distinct_persons() — issue #998 energy gate + spatial dedup. */ +static uint8_t count_distinct_persons(const float *energy, const uint8_t *sc_idx, + uint8_t n_groups) +{ + if (n_groups == 0) return 0; + + float max_energy = 0.0f; + for (uint8_t g = 0; g < n_groups; g++) { + if (energy[g] > max_energy) max_energy = energy[g]; + } + if (max_energy <= 0.0f) return 0; + + float min_energy = max_energy * EDGE_PERSON_MIN_ENERGY_RATIO; + + uint8_t counted_sc[EDGE_MAX_PERSONS]; + uint8_t count = 0; + + bool used[EDGE_MAX_PERSONS]; + for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) used[g] = false; + + for (uint8_t iter = 0; iter < n_groups && iter < EDGE_MAX_PERSONS; iter++) { + int best = -1; + float best_e = min_energy; + for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) { + if (used[g]) continue; + if (energy[g] >= best_e) { best_e = energy[g]; best = g; } + } + if (best < 0) break; + used[best] = true; + + bool duplicate = false; + for (uint8_t c = 0; c < count; c++) { + int sep = (int)sc_idx[best] - (int)counted_sc[c]; + if (sep < 0) sep = -sep; + if (sep < EDGE_PERSON_MIN_SC_SEP) { duplicate = true; break; } + } + if (duplicate) continue; + + counted_sc[count++] = sc_idx[best]; + } + + if (count == 0) count = 1; + return count; +} + +/* person_count_debounce() — issue #998 count persistence. */ +static uint8_t person_count_debounce(uint8_t raw, uint8_t *candidate, + uint8_t *streak, uint8_t *stable) +{ + if (raw == *stable) { + *candidate = raw; + *streak = 0; + return *stable; + } + if (raw == *candidate) { + if (*streak < 0xFF) (*streak)++; + } else { + *candidate = raw; + *streak = 1; + } + if (*streak >= EDGE_PERSON_PERSIST_FRAMES) { + *stable = *candidate; + *streak = 0; + } + return *stable; +} + +/* presence_flag_update() — issue #996 hysteresis + clear debounce. */ +static bool presence_flag_update(bool prev, float score, float threshold, + uint8_t *below_count) +{ + float low_thresh = threshold * EDGE_PRESENCE_HYST_RATIO; + + if (score > threshold) { + *below_count = 0; + return true; + } + + if (score >= low_thresh) { + *below_count = 0; + return prev; + } + + if (*below_count < 0xFF) (*below_count)++; + if (!prev) { + return false; + } + if (*below_count >= EDGE_PRESENCE_CLEAR_FRAMES) { + *below_count = 0; + return false; + } + return true; +} + +/* ────────────────────────────────────────────────────────────────────── + * Test harness + * ────────────────────────────────────────────────────────────────────── */ + +static int g_failed = 0; +static int g_passed = 0; + +#define CHECK_EQ_U8(label, got, expected) do { \ + if ((uint8_t)(got) == (uint8_t)(expected)) { g_passed++; } \ + else { \ + g_failed++; \ + printf("FAIL: %s — got=%u expected=%u\n", \ + (label), (unsigned)(uint8_t)(got), \ + (unsigned)(uint8_t)(expected)); \ + } \ +} while (0) + +#define CHECK_TRUE(label, cond) do { \ + if (cond) { g_passed++; } \ + else { g_failed++; printf("FAIL: %s — expected true\n", (label)); } \ +} while (0) + +/* ────────────────────────────────────────────────────────────────────── + * #998 — count_distinct_persons: single body must NOT report EDGE_MAX_PERSONS + * ────────────────────────────────────────────────────────────────────── */ + +/* One strong signature + weak multipath echoes in adjacent subcarrier groups. + * This is exactly the field report: one person ~50 cm → persons=4. The energy + * gate + spatial dedup must collapse this to 1. */ +static void test_count_single_strong_signature(void) +{ + /* 4 groups: one dominant, three weak multipath (below the energy gate), + * representative subcarriers clustered (adjacent → one body). */ + float energy[EDGE_MAX_PERSONS] = {10.0f, 0.6f, 0.4f, 0.3f}; + uint8_t sc[EDGE_MAX_PERSONS] = {20, 21, 22, 23}; + CHECK_EQ_U8("single strong signature → 1", + count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1); +} + +/* Even if the weak echoes are spatially spread, they're still below the energy + * gate, so they don't count. */ +static void test_count_single_spread_multipath(void) +{ + float energy[EDGE_MAX_PERSONS] = {10.0f, 1.0f, 0.8f, 0.5f}; + uint8_t sc[EDGE_MAX_PERSONS] = {10, 40, 70, 100}; + CHECK_EQ_U8("single body spread multipath → 1", + count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1); +} + +/* Two genuine, well-separated, comparably-strong signatures → 2. */ +static void test_count_two_well_separated(void) +{ + float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 0.3f, 0.2f}; + uint8_t sc[EDGE_MAX_PERSONS] = {10, 90, 11, 12}; + CHECK_EQ_U8("two well-separated strong → 2", + count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 2); +} + +/* Two strong but spatially ADJACENT signatures collapse to 1 (same body): + * spatial dedup prevents double-counting one person's two strong subcarriers. */ +static void test_count_two_strong_adjacent_dedup(void) +{ + float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 0.3f, 0.2f}; + uint8_t sc[EDGE_MAX_PERSONS] = {20, 21, 60, 61}; /* 20 & 21 adjacent */ + CHECK_EQ_U8("two strong but adjacent → 1 (dedup)", + count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1); +} + +/* No signal at all → 0 persons (empty room). */ +static void test_count_no_signal(void) +{ + float energy[EDGE_MAX_PERSONS] = {0.0f, 0.0f, 0.0f, 0.0f}; + uint8_t sc[EDGE_MAX_PERSONS] = {10, 30, 50, 70}; + CHECK_EQ_U8("no signal → 0", count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 0); +} + +/* Three genuine well-separated strong signatures → 3 (gate doesn't under-count). */ +static void test_count_three_well_separated(void) +{ + float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 8.0f, 0.2f}; + uint8_t sc[EDGE_MAX_PERSONS] = {10, 50, 90, 11}; + CHECK_EQ_U8("three well-separated strong → 3", + count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 3); +} + +/* ────────────────────────────────────────────────────────────────────── + * #998 — person_count_debounce: a single noisy frame can't change the count + * ────────────────────────────────────────────────────────────────────── */ + +static void test_debounce_rejects_transient_spike(void) +{ + uint8_t candidate = 1, streak = 0, stable = 1; /* settled on 1 person */ + + /* One spurious frame reports 4 — must NOT promote. */ + uint8_t out = person_count_debounce(4, &candidate, &streak, &stable); + CHECK_EQ_U8("transient spike held at 1", out, 1); + + /* Back to 1 — resets pending change. */ + out = person_count_debounce(1, &candidate, &streak, &stable); + CHECK_EQ_U8("recovered to 1", out, 1); + CHECK_EQ_U8("streak reset", streak, 0); +} + +static void test_debounce_accepts_sustained_change(void) +{ + uint8_t candidate = 1, streak = 0, stable = 1; + + uint8_t out = 1; + /* A genuine 2-person arrival must hold EDGE_PERSON_PERSIST_FRAMES frames. */ + for (int i = 0; i < EDGE_PERSON_PERSIST_FRAMES; i++) { + out = person_count_debounce(2, &candidate, &streak, &stable); + } + CHECK_EQ_U8("sustained 2 promoted", out, 2); + CHECK_EQ_U8("stable now 2", stable, 2); +} + +/* A flapping count (2,1,2,1,...) never accumulates a streak → stays at stable. */ +static void test_debounce_flapping_stays_stable(void) +{ + uint8_t candidate = 1, streak = 0, stable = 1; + uint8_t out = 1; + for (int i = 0; i < 10; i++) { + out = person_count_debounce((i & 1) ? 1 : 2, &candidate, &streak, &stable); + } + CHECK_EQ_U8("flapping count stays at 1", out, 1); +} + +/* ────────────────────────────────────────────────────────────────────── + * #996 — presence_flag_update: dithering score must NOT flicker the flag + * ────────────────────────────────────────────────────────────────────── */ + +/* Field trace dithers around the OLD single threshold while the person is + * clearly present. With T_high=10, T_low=5, a score sequence that crosses 10 + * up and down must produce a STABLE flag (no per-frame flicker). */ +static void test_presence_no_flicker_on_dither(void) +{ + const float threshold = 10.0f; /* high threshold */ + /* Observed-style trace (issue evidence: 2.6-26.7), but here we model the + * realistic "person present" case where the score mostly sits in/above the + * dead band and only briefly dips. */ + float trace[] = {5.6f, 23.0f, 6.8f, 12.0f, 8.0f, 26.7f, 7.0f, 11.0f, 9.0f, 24.0f}; + int n = (int)(sizeof(trace) / sizeof(trace[0])); + + bool flag = false; + uint8_t below = 0; + int flips = 0; + bool prev = flag; + for (int i = 0; i < n; i++) { + flag = presence_flag_update(flag, trace[i], threshold, &below); + if (i > 0 && flag != prev) flips++; + prev = flag; + } + /* First sample (5.6) is below T_low=5? No, 5.6 >= 5 → dead band, holds + * initial false until 23.0 asserts. After that, dips to 6.8/8.0/7.0/9.0 are + * all >= T_low (5), so they HOLD true. The only transition is the initial + * false→true. No flicker. */ + CHECK_TRUE("presence asserted by end", flag); + CHECK_TRUE("at most one transition (no flicker)", flips <= 1); +} + +/* Hard dither straddling T_low must still not flicker frame-to-frame because of + * the clear debounce: brief sub-T_low dips don't immediately clear. */ +static void test_presence_clear_debounce_holds(void) +{ + const float threshold = 10.0f; /* T_low = 5.0 */ + bool flag = false; + uint8_t below = 0; + + /* Assert. */ + flag = presence_flag_update(flag, 20.0f, threshold, &below); + CHECK_TRUE("asserted on strong score", flag); + + /* A few brief dips below T_low (< CLEAR_FRAMES) must NOT clear. */ + for (int i = 0; i < EDGE_PRESENCE_CLEAR_FRAMES - 1; i++) { + flag = presence_flag_update(flag, 1.0f, threshold, &below); + } + CHECK_TRUE("brief dips below T_low still present", flag); + + /* Recovery resets the debounce. */ + flag = presence_flag_update(flag, 20.0f, threshold, &below); + CHECK_TRUE("recovered", flag); + CHECK_EQ_U8("below_count reset on recovery", below, 0); +} + +/* A genuine departure (score drops and STAYS low) clears within the hold window. */ +static void test_presence_genuine_departure_clears(void) +{ + const float threshold = 10.0f; + bool flag = false; + uint8_t below = 0; + + flag = presence_flag_update(flag, 20.0f, threshold, &below); + CHECK_TRUE("asserted", flag); + + /* Person leaves: score stays well below T_low for CLEAR_FRAMES frames. */ + for (int i = 0; i < EDGE_PRESENCE_CLEAR_FRAMES; i++) { + flag = presence_flag_update(flag, 0.5f, threshold, &below); + } + CHECK_TRUE("cleared after sustained low", !flag); +} + +/* Schmitt gap: a score in the dead band (between T_low and T_high) holds state, + * it neither asserts from false nor clears from true. */ +static void test_presence_dead_band_holds_state(void) +{ + const float threshold = 10.0f; /* dead band 5..10 */ + uint8_t below = 0; + + /* From false, a dead-band score does not assert. */ + bool flag = presence_flag_update(false, 7.0f, threshold, &below); + CHECK_TRUE("dead band does not assert from false", !flag); + + /* From true, a dead-band score does not clear. */ + below = 0; + flag = presence_flag_update(true, 7.0f, threshold, &below); + CHECK_TRUE("dead band does not clear from true", flag); +} + +/* ────────────────────────────────────────────────────────────────────── + * main + * ────────────────────────────────────────────────────────────────────── */ + +int main(void) +{ + /* #998 person count gate */ + test_count_single_strong_signature(); + test_count_single_spread_multipath(); + test_count_two_well_separated(); + test_count_two_strong_adjacent_dedup(); + test_count_no_signal(); + test_count_three_well_separated(); + + /* #998 count debounce */ + test_debounce_rejects_transient_spike(); + test_debounce_accepts_sustained_change(); + test_debounce_flapping_stays_stable(); + + /* #996 presence hysteresis */ + test_presence_no_flicker_on_dither(); + test_presence_clear_debounce_holds(); + test_presence_genuine_departure_clears(); + test_presence_dead_band_holds_state(); + + printf("\n%d passed, %d failed\n", g_passed, g_failed); + return g_failed == 0 ? 0 : 1; +}