diff --git a/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md b/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md new file mode 100644 index 00000000..6c856d0c --- /dev/null +++ b/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md @@ -0,0 +1,50 @@ +# ADR 346: Fail closed ESP32 occupancy evidence + +## Status + +Accepted and implemented. Physical qualification is required after each firmware build. + +## Date + +2026 08 31 + +## Context + +The ESP32 Tier 2 pipeline produces two different signals. Presence is a debounced room level decision. Person count is a bounded subcarrier diversity heuristic. A live four node installation emitted packets with `presence=false` and `n_persons=3` or `4`. The server eventually gated the aggregate room count, but raw WebSocket consumers and diagnostics could still treat the contradictory count as occupancy evidence. + +That contradiction is more dangerous than a missed optional count. It can contaminate empty room calibration, train a room model on false labels, and encourage a product claim that the firmware cannot support. The count is not identity, pose, or a validated multi person estimator. + +## Decision + +1. Firmware person slots are subordinate to the debounced presence gate. +2. When presence is false, the firmware clears slot activity, slot history, candidate count, persistence streak, and stable count. +3. The serialized person count is always zero when presence is false and is clamped to `EDGE_MAX_PERSONS` when presence is true. +4. The sensing server repeats the invariant for older firmware. A contradictory or out of range count becomes zero and carries `person_count_valid=false`. +5. Fused CSI plus mmWave packets use either CSI presence or mmWave presence as the supporting presence condition. +6. The node inventory and WebSocket diagnostics expose person count validity. Consumers must not infer a person from an invalid count. +7. No count accuracy claim is created by this change. The firmware output remains a heuristic until a leakage free, held out physical dataset demonstrates otherwise. +8. OTA admission uses the selected update partition size rather than a stale fixed 900 KB ceiling. The status endpoint reports that same hardware bound, while image validation and authenticated OTA remain mandatory. + +## Security and privacy + +The change retains no raw CSI or personal data. It reduces authority by preventing a secondary heuristic from asserting occupancy after the primary gate has closed. The host validates packet length, magic, range, and logical consistency before using count evidence. + +## Consequences + +Older firmware remains wire compatible. Invalid count evidence becomes visibly unavailable instead of silently affecting calibration. A true multi person event can still be undercounted when the presence gate is false, which is the intended fail closed behavior. Current C6 images larger than 900 KB can use the installed 1,900,544 byte OTA slots after one serial upgrade, without weakening the OTA authentication gate. + +The largest uncertainty is whether the current presence gate itself generalizes across the installed rooms. The fix path is a room bound empty baseline plus the fixed room selective held out protocol, not a global threshold reduction. + +## Evidence and acceptance + +MEASURED before implementation on 2026 08 31: four live nodes streamed for 86 seconds with zero transport errors, while edge packets repeatedly contradicted `presence=false` with counts of three or four. + +Software acceptance requires: + +1. Host firmware tests prove absent plus four active slots serializes zero. +2. Rust parser tests prove contradictory and out of range counts fail closed. +3. The node API exposes count validity without breaking older firmware. + +Physical acceptance requires the updated firmware on a confirmed board, a captured boot log, five minutes of live packets, zero logical count contradictions, and no increase in transport errors. Accuracy remains unmeasured until labelled held out sequences are recorded. + +Physical qualification completed for ESP32 C6 node 4 on 2026 08 31. The five minute run observed 242 edge packets, including 61 absent packets, with zero logical count contradictions and zero parse errors. See `docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md`. Other nodes remain unqualified until separately identified and upgraded. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6ea87665..0ac71281 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -106,6 +106,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed | | [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted | | [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) | +| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (implemented, physical qualification pending) | | [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) | | [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed | | [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed | diff --git a/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md b/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md new file mode 100644 index 00000000..89abca9b --- /dev/null +++ b/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md @@ -0,0 +1,56 @@ +# ESP32 C6 occupancy evidence qualification + +## Scope + +This record qualifies the fail closed person count invariant in ADR 346 on one physically attached ESP32 C6. It does not qualify person counting accuracy, identity, pose, room separation, or vital sign accuracy. + +## Hardware and firmware + +| Field | Measured value | +|---|---| +| Board | ESP32 C6 QFN40 revision 0.2 | +| Logical node | 4 | +| Firmware before | 0.7.0 | +| Firmware after | 0.8.4 development build | +| App image | 1,051,168 bytes | +| App SHA 256 | `f9470a31b82612f1740f0cf0943ddb78917cd58784ba16d2e9d57cc8fb39364c` | +| OTA slot size | 1,900,544 bytes | +| CSI stream target | Preserved from NVS | + +The device partition table was read before the update. NVS, OTA metadata, bootloader, and partition table were not overwritten. A private recovery copy was created outside the repository and excluded from version control. + +## Software gates + +| Gate | Result | +|---|---| +| Firmware host tests | PASS, 54 assertions across encoding, vital evidence, and mmWave detection | +| Rust sensing server package | PASS, 532 library tests plus all package integration and documentation tests | +| Mobile Jest suite | PASS, 164 suites and 1,223 tests | +| Mobile TypeScript | PASS | +| Mobile ESLint | PASS | +| Mobile security verifier | PASS | +| Repository wide Rust formatting | PREEXISTING DRIFT outside this change; changed code builds and package tests pass | + +## Physical result + +MEASURED on 2026 08 31 from the live local sensing WebSocket for 300 seconds: + +| Node | Firmware state | Edge packets | Absent packets | Absent with nonzero count | Result | +|---|---|---:|---:|---:|---| +| 4 | Updated | 242 | 61 | 0 | PASS | +| 3 | Unupdated control | 216 | 216 | 216 | Expected control failure | +| 7 | Unupdated control | 280 | 278 | 278 | Expected control failure | + +Node 4 reduced the targeted logical contradiction from observed to zero, a 100 percent reduction for this invariant during this run. This is not a person count accuracy result. + +The WebSocket run had zero JSON parse errors and one expected client close at completion. The sensing server remained ready with `engine_error_count=0`. A separate 45 second serial observation recorded 120 log lines, 17 CSI callback markers, zero ENOMEM backoffs, and zero other error lines. + +The updated OTA status endpoint reports the selected 1,900,544 byte partition rather than the stale 921,600 byte constant. The 1,051,168 byte image therefore fits with 849,376 bytes of partition headroom. + +## Remaining qualification + +Nodes 3 and 7 still demonstrate the old contradictory behavior and must be upgraded only after their network identity, OTA credential, and rollback path are verified. The current run had no labelled ground truth, so multi person fidelity and adjacent room rejection remain unmeasured. + +## Acceptance test + +Repeat a five minute capture after every firmware change. Pass only when every updated node has at least 30 absent packets, zero packets where `presence=false` and `n_persons>0`, zero parser errors, and a ready sensing server with zero engine errors. diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index a92d0403..580b32c8 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -409,6 +409,21 @@ static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS]; static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN]; static float s_person_hr_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN]; +/** Clear person slots whenever the room-level presence gate is closed. */ +static void reset_person_count_state(void) +{ + s_person_count_candidate = 0; + s_person_count_streak = 0; + s_person_count_stable = 0; + for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { + s_persons[p].active = false; + s_persons[p].history_len = 0; + s_persons[p].history_idx = 0; + s_persons[p].breathing_bpm = 0.0f; + s_persons[p].heartrate_bpm = 0.0f; + } +} + /** Latest vitals packet (thread-safe via volatile copy). */ static volatile edge_vitals_pkt_t s_latest_pkt; static volatile bool s_pkt_valid; @@ -898,7 +913,10 @@ static void send_vitals_packet(void) for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { if (s_persons[p].active) n_active++; } - pkt.n_persons = n_active; + /* Fail closed: the slot heuristic cannot assert occupants while the + * debounced presence gate is false. The host repeats this invariant for + * backward compatibility with older firmware. */ + pkt.n_persons = edge_evidence_person_count(s_presence_detected, n_active); pkt.motion_energy = s_motion_energy; pkt.presence_score = s_presence_score; @@ -1202,8 +1220,15 @@ static void process_frame(const edge_ring_slot_t *slot) } } - /* --- Step 11: Multi-person vitals --- */ - update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate); + /* --- Step 11: Multi-person vitals --- + * Person slots are subordinate to the room presence gate. Processing or + * retaining slots while absent produced contradictory packets such as + * presence=false with n_persons=4. */ + if (s_presence_detected) { + update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate); + } else { + reset_person_count_state(); + } /* Yield after multi-person DSP so IDLE1 can feed Core 1 watchdog (#683). */ if (s_cfg.tier >= 2) vTaskDelay(1); diff --git a/firmware/esp32-csi-node/main/edge_processing.h b/firmware/esp32-csi-node/main/edge_processing.h index 826f080e..249a78c7 100644 --- a/firmware/esp32-csi-node/main/edge_processing.h +++ b/firmware/esp32-csi-node/main/edge_processing.h @@ -38,6 +38,20 @@ /* ---- Multi-person ---- */ #define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */ +/** + * Enforce the wire-level occupancy invariant. + * + * A subcarrier slot estimate is supporting evidence only. It cannot assert an + * occupant when the independently debounced presence gate is false. Keeping + * this helper in the public firmware header lets host tests exercise the exact + * function used by the device build. + */ +static inline uint8_t edge_evidence_person_count(bool presence, uint8_t active_count) +{ + if (!presence) return 0; + return active_count > EDGE_MAX_PERSONS ? EDGE_MAX_PERSONS : active_count; +} + /* ---- Multi-person counting gates (issue #998) ---- * * Over-counting root cause: the multi-person path used to split the top-K diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index eed5c667..bad144c2 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -23,9 +23,6 @@ static const char *TAG = "ota_update"; /** OTA HTTP server port. */ #define OTA_PORT 8032 -/** Maximum firmware size (900 KB — matches CI binary size gate). */ -#define OTA_MAX_SIZE (900 * 1024) - /** NVS namespace and key for the OTA pre-shared key. */ #define OTA_NVS_NAMESPACE "security" #define OTA_NVS_KEY "ota_psk" @@ -95,11 +92,11 @@ static esp_err_t ota_status_handler(httpd_req_t *req) int len = snprintf(response, sizeof(response), "{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\"," "\"running_partition\":\"%s\",\"next_partition\":\"%s\"," - "\"max_size\":%d}", + "\"max_size\":%lu}", app->version, app->date, app->time, running ? running->label : "unknown", update ? update->label : "none", - OTA_MAX_SIZE); + (unsigned long)(update ? update->size : 0)); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, len); @@ -121,12 +118,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len); - if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, - "Invalid firmware size (must be 1B - 900KB)"); - return ESP_FAIL; - } - const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); if (update_partition == NULL) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, @@ -134,6 +125,15 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) return ESP_FAIL; } + if (req->content_len <= 0 || (size_t)req->content_len > update_partition->size) { + ESP_LOGW(TAG, "OTA rejected: content_length=%d exceeds partition '%s' size=%lu", + req->content_len, update_partition->label, + (unsigned long)update_partition->size); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "Invalid firmware size for OTA partition"); + return ESP_FAIL; + } + esp_ota_handle_t ota_handle; esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); if (err != ESP_OK) { diff --git a/firmware/esp32-csi-node/test/test_vitals_count_presence.c b/firmware/esp32-csi-node/test/test_vitals_count_presence.c index 5c238e97..db5f92fe 100644 --- a/firmware/esp32-csi-node/test/test_vitals_count_presence.c +++ b/firmware/esp32-csi-node/test/test_vitals_count_presence.c @@ -266,6 +266,19 @@ static void test_debounce_flapping_stays_stable(void) CHECK_EQ_U8("flapping count stays at 1", out, 1); } +/* The packet count is evidence subordinated to presence, never an independent + * occupancy assertion. This guards the field failure where a node emitted + * presence=false with n_persons=3 or 4. */ +static void test_person_count_fails_closed_without_presence(void) +{ + CHECK_EQ_U8("absent with four active slots -> zero", + edge_evidence_person_count(false, 4), 0); + CHECK_EQ_U8("present preserves a bounded count", + edge_evidence_person_count(true, 3), 3); + CHECK_EQ_U8("present count clamps to protocol maximum", + edge_evidence_person_count(true, 255), EDGE_MAX_PERSONS); +} + /* ────────────────────────────────────────────────────────────────────── * #996 — presence_flag_update: dithering score must NOT flicker the flag * ────────────────────────────────────────────────────────────────────── */ @@ -375,6 +388,7 @@ int main(void) test_debounce_rejects_transient_spike(); test_debounce_accepts_sustained_change(); test_debounce_flapping_stays_stable(); + test_person_count_fails_closed_without_presence(); /* #996 presence hysteresis */ test_presence_no_flicker_on_dither(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index a4853c8b..6d0cd900 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -9,6 +9,17 @@ use crate::adaptive_classifier; use crate::types::*; use crate::vital_signs::VitalSigns; +const EDGE_MAX_PERSONS: u8 = 4; + +/// Person count is supporting evidence, never an independent occupancy claim. +/// Return zero and mark invalid for contradictory or out-of-range firmware. +fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { + if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) { + return (0, false); + } + (raw, true) +} + // ── ESP32 UDP frame parsers ───────────────────────────────────────────────── /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). @@ -26,26 +37,63 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let presence = (flags & 0x01) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]); let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); Some(Esp32VitalsPacket { node_id, - presence: (flags & 0x01) != 0, + presence, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, + person_count_valid, motion_energy, presence_score, timestamp_ms, }) } +#[cfg(test)] +mod edge_vitals_integrity_tests { + use super::*; + + fn packet(presence: bool, n_persons: u8) -> Vec { + let mut buf = vec![0u8; 32]; + buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes()); + buf[4] = 4; + buf[5] = u8::from(presence); + buf[13] = n_persons; + buf + } + + #[test] + fn contradictory_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } + + #[test] + fn bounded_present_count_is_preserved() { + let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet"); + assert_eq!(parsed.n_persons, 3); + assert!(parsed.person_count_valid); + } + + #[test] + fn out_of_range_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(true, 5)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } +} + /// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928; /// the original 0xC511_0004 collided with ADR-063 fused vitals). pub fn parse_wasm_output(buf: &[u8]) -> Option { diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index fb1c9e09..3c6ca042 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1713,11 +1713,21 @@ struct Esp32VitalsPacket { heartrate_bpm: f64, rssi: i8, n_persons: u8, + person_count_valid: bool, motion_energy: f32, presence_score: f32, timestamp_ms: u32, } +const EDGE_MAX_PERSONS: u8 = 4; + +fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { + if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) { + return (0, false); + } + (raw, true) +} + /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). fn parse_esp32_vitals(buf: &[u8]) -> Option { if buf.len() < 32 { @@ -1733,26 +1743,56 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let presence = (flags & 0x01) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]); let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); Some(Esp32VitalsPacket { node_id, - presence: (flags & 0x01) != 0, + presence, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, + person_count_valid, motion_energy, presence_score, timestamp_ms, }) } +#[cfg(test)] +mod edge_vitals_integrity_tests { + use super::*; + + fn packet(presence: bool, n_persons: u8) -> Vec { + let mut buf = vec![0u8; 32]; + buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes()); + buf[4] = 4; + buf[5] = u8::from(presence); + buf[13] = n_persons; + buf + } + + #[test] + fn contradictory_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } + + #[test] + fn present_count_is_preserved() { + let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet"); + assert_eq!(parsed.n_persons, 3); + assert!(parsed.person_count_valid); + } +} + // ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ───── /// Single WASM event (type + value). @@ -1829,6 +1869,7 @@ struct EdgeFusedVitalsPacket { heartrate_bpm: f32, rssi: i8, n_persons: u8, + person_count_valid: bool, /// `mmwave_type_t` enum value from firmware. mmwave_type: u8, /// 0-100 fusion quality score. @@ -1863,7 +1904,8 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let any_presence = (flags & 0x09) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(any_presence, buf[13]); let mmwave_type = buf[14]; let fusion_confidence = buf[15]; let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); @@ -1883,6 +1925,7 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option { heartrate_bpm: heartrate_raw as f32 / 10000.0, rssi, n_persons, + person_count_valid, mmwave_type, fusion_confidence, motion_energy, @@ -1939,6 +1982,7 @@ mod issue_928_magic_collision_tests { assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000"); assert_eq!(pkt.rssi, -55); assert_eq!(pkt.n_persons, 1); + assert!(pkt.person_count_valid); assert_eq!(pkt.mmwave_type, 2); assert_eq!(pkt.fusion_confidence, 85); assert!((pkt.motion_energy - 0.42).abs() < 1e-6); @@ -6194,6 +6238,9 @@ async fn nodes_endpoint(State(state): State) -> Json