diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index da67c147b5..e6f6bd9101 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -937,6 +937,37 @@ static void send_vitals_packet(void) s_latest_pkt = pkt; s_pkt_valid = true; + /* Per-slot vitals, sent alongside (never instead of) the aggregate packet + * above. update_multi_person_vitals() already computed these; before this + * they were discarded at the wire, leaving the sink unable to tell what the + * slot count was actually counting. See EDGE_VITALS_SLOTS_MAGIC's comment. */ + { + edge_vitals_slots_pkt_t spkt; + memset(&spkt, 0, sizeof(spkt)); + spkt.magic = EDGE_VITALS_SLOTS_MAGIC; + spkt.node_id = pkt.node_id; + spkt.n_active = n_active; + spkt.max_slots = EDGE_MAX_PERSONS; + spkt.timestamp_ms = pkt.timestamp_ms; + for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { + if (s_persons[p].active) { + spkt.active_mask |= (uint8_t)(1u << p); + } + /* Fixed-point BPM*100 in a uint16 tops out at 655 BPM, far above + * any plausible rate; clamp rather than wrap on a bad estimate. */ + float br = s_persons[p].breathing_bpm; + float hr = s_persons[p].heartrate_bpm; + if (br < 0.0f) br = 0.0f; + if (br > 650.0f) br = 650.0f; + if (hr < 0.0f) hr = 0.0f; + if (hr > 650.0f) hr = 650.0f; + spkt.breathing_bpm[p] = (uint16_t)(br * 100.0f); + spkt.heartrate_bpm[p] = (uint16_t)(hr * 100.0f); + spkt.subcarrier_idx[p] = s_persons[p].subcarrier_idx; + } + stream_sender_send((const uint8_t *)&spkt, sizeof(spkt)); + } + /* ADR-063: If mmWave is active, send fused 48-byte packet instead. */ mmwave_state_t mw; if (mmwave_sensor_get_state(&mw) && mw.detected) { diff --git a/firmware/esp32-csi-node/main/edge_processing.h b/firmware/esp32-csi-node/main/edge_processing.h index 0fe280b0a6..90de337e7a 100644 --- a/firmware/esp32-csi-node/main/edge_processing.h +++ b/firmware/esp32-csi-node/main/edge_processing.h @@ -343,4 +343,39 @@ void edge_get_phase_history(const float **out_buf, uint16_t *out_len, */ void edge_get_variances(float *out_variances, uint16_t n_subcarriers); +/* ---- Per-slot vitals packet (wire format) ---- + * + * update_multi_person_vitals() already computes an independent breathing and + * heart rate for every detected slot — its own phase history, filters and + * autocorrelation. The 32-byte edge_vitals_pkt_t above then transmits only the + * aggregate plus a bare n_persons count, so all per-slot detail is computed + * and thrown away. + * + * That discarded detail is the only thing that can distinguish WHAT is being + * counted. The slot count is a subcarrier-energy-cluster heuristic with no + * size or species awareness, so a room containing pets reports them as + * "persons". Per-slot breathing rates are species-plausible evidence: a + * resting human sits near 12-20 BPM, a rabbit near 30-60, a chinchilla higher + * still. Sending them lets the sink label honestly instead of overclaiming. + * + * Sent IN ADDITION to edge_vitals_pkt_t, never instead of it, so an older sink + * keeps working unchanged and simply ignores this magic. */ +#define EDGE_VITALS_SLOTS_MAGIC 0xC5110009 + +typedef struct __attribute__((packed)) { + uint32_t magic; /**< EDGE_VITALS_SLOTS_MAGIC = 0xC5110009. */ + uint8_t node_id; /**< ESP32 node identifier. */ + uint8_t n_active; /**< Slots currently marked active. */ + uint8_t max_slots; /**< EDGE_MAX_PERSONS, so the sink can validate. */ + uint8_t active_mask; /**< Bit p set when slot p is active. */ + uint32_t timestamp_ms; /**< Milliseconds since boot. */ + uint16_t breathing_bpm[EDGE_MAX_PERSONS]; /**< BPM * 100 per slot. */ + uint16_t heartrate_bpm[EDGE_MAX_PERSONS]; /**< BPM * 100 per slot. */ + uint8_t subcarrier_idx[EDGE_MAX_PERSONS]; /**< Subcarrier group per slot. */ +} edge_vitals_slots_pkt_t; + +_Static_assert(sizeof(edge_vitals_slots_pkt_t) == 32, + "per-slot vitals packet must be 32 bytes"); + + #endif /* EDGE_PROCESSING_H */