Skip to content

Commit c227f3d

Browse files
committed
feat(#1542 ask 1): surface the associated-AP BSSID as node telemetry
The associated AP IS the sensing-link geometry: when a station roams, every downstream consumer of that node's CSI silently looks at a different Fresnel volume. Field data on #1542 shows published RSSI cannot reveal the change (-47.7 vs -48.2 dBm across two completely different link geometries), so the BSSID itself is the only observable. - firmware: new 32-byte link-status packet (magic 0xC511A111, same auxiliary family as the ADR-110 sync packet) from esp_wifi_sta_get_ap_info(): BSSID, primary channel, AP RSSI, WIFI_EVENT_STA_CONNECTED count. Emitted every CONFIG_LINK_STATUS_EVERY_N_FRAMES CSI callbacks (default 600 = ~30 s at 20 Hz) plus immediately on BSSID change, so a roam is visible within one callback. Packet layout documented in the firmware README. - hardware crate: LinkStatusPacket decoder mirroring SyncPacket (typed errors, canonical wire pin, magic-collision guard). 6 tests. - sensing-server: dispatch on the new magic in the UDP receiver, per-node NodeState storage, NodeInfo.link {bssid, channel, ap_rssi_dbm, reassoc_count, age_ms} (skip-if-none — pre-link firmwares serialize exactly as before), and the RSSI MQTT entity's JSON payload gains optional bssid/channel/reassoc_count (absent for pre-link nodes; byte-identical old payload). Roam events derive downstream by comparing successive snapshots; reassoc_count additionally reveals same-BSSID re-associations. 2 tests. Rust: MEASURED (cargo test -p wifi-densepose-hardware -p wifi-densepose-sensing-server --no-default-features: 0 failed). Firmware: build/flash validation pending on real ESP32-S3 hardware — will be run before this PR is opened, per the hardware-evidence rule.
1 parent a3b6e1d commit c227f3d

7 files changed

Lines changed: 494 additions & 3 deletions

File tree

firmware/esp32-csi-node/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,29 @@ All packets are sent over UDP to the configured aggregator. The magic number in
203203
| `0xC5110001` | CSI Frame (ADR-018) | ~20 Hz | Variable | Raw I/Q per subcarrier per antenna |
204204
| `0xC5110002` | Vitals Packet | 1 Hz | 32 bytes | Presence, breathing BPM, heart rate, fall flag, occupancy |
205205
| `0xC5110004` | WASM Output | Event-driven | Variable | Custom events from WASM modules (u8 type + f32 value) |
206+
| `0xC511A111` | Link Status (#1542) | ~30 s + on BSSID change | 32 bytes | Associated-AP BSSID, channel, AP RSSI, reassoc count |
207+
208+
### Link Status Packet Format (#1542)
209+
210+
The associated AP is the sensing-link geometry; a roam silently changes what a
211+
node senses, and frame RSSI cannot reveal it. This packet surfaces the BSSID.
212+
Sent every `CONFIG_LINK_STATUS_EVERY_N_FRAMES` CSI callbacks (default 600, or
213+
about 30 s at 20 Hz) plus immediately whenever the BSSID observed via
214+
`esp_wifi_sta_get_ap_info()` differs from the last reported one.
215+
216+
```
217+
Offset Size Field
218+
0 4 Magic: 0xC511A111 (LE u32)
219+
4 1 Node ID
220+
5 1 Protocol version (0x01)
221+
6 1 Flags: bit 0 = ap_info_valid
222+
7 1 Primary channel (0 when not associated)
223+
8 6 BSSID (all-zero when not associated)
224+
14 1 AP RSSI as seen by the station, dBm (i8)
225+
15 1 Reserved
226+
16 4 Reassoc count: WIFI_EVENT_STA_CONNECTED events since boot (LE u32)
227+
20 12 Reserved
228+
```
206229

207230
### ADR-018 Binary Frame Format
208231

firmware/esp32-csi-node/main/csi_collector.c

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,55 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
357357
}
358358
}
359359
}
360+
361+
/* #1542 ask 1 — Link-status packet: the associated AP IS the sensing-link
362+
* geometry, and published RSSI is provably blind to a roam (field data on
363+
* the issue: -47.7 vs -48.2 dBm across two completely different link
364+
* geometries). Emit every CONFIG_LINK_STATUS_EVERY_N_FRAMES CSI callbacks
365+
* (default 600 = ~30 s at 20 Hz) PLUS immediately when the observed BSSID
366+
* differs from the last reported one, so a roam is visible within one
367+
* callback rather than one cadence period. Same 32-byte auxiliary-packet
368+
* family as the ADR-110 sync packet (magic 0xC511A111). */
369+
{
370+
#ifndef CONFIG_LINK_STATUS_EVERY_N_FRAMES
371+
#define CONFIG_LINK_STATUS_EVERY_N_FRAMES 600
372+
#endif
373+
extern volatile uint32_t g_wifi_reassoc_count;
374+
static uint8_t s_link_last_bssid[6] = {0};
375+
static bool s_link_ever_sent = false;
376+
377+
wifi_ap_record_t ap = {0};
378+
bool ap_ok = (esp_wifi_sta_get_ap_info(&ap) == ESP_OK);
379+
bool changed = ap_ok && (!s_link_ever_sent ||
380+
memcmp(ap.bssid, s_link_last_bssid, 6) != 0);
381+
if (changed || (s_cb_count % CONFIG_LINK_STATUS_EVERY_N_FRAMES) == 0) {
382+
uint8_t pkt[32] = {0};
383+
uint32_t link_magic = 0xC511A111u; /* #1542 link-status packet */
384+
memcpy(&pkt[0], &link_magic, 4);
385+
pkt[4] = s_node_id;
386+
pkt[5] = 0x01; /* protocol version */
387+
pkt[6] = ap_ok ? 0x01 : 0x00; /* flags: ap_info_valid */
388+
pkt[7] = ap_ok ? ap.primary : 0;
389+
if (ap_ok) memcpy(&pkt[8], ap.bssid, 6);
390+
pkt[14] = ap_ok ? (uint8_t)ap.rssi : 0;
391+
/* pkt[15] reserved */
392+
uint32_t reassoc = g_wifi_reassoc_count;
393+
memcpy(&pkt[16], &reassoc, 4);
394+
/* pkt[20..31] reserved */
395+
int lr = stream_sender_send_priority(pkt, sizeof(pkt));
396+
if (ap_ok) {
397+
memcpy(s_link_last_bssid, ap.bssid, 6);
398+
s_link_ever_sent = true;
399+
}
400+
if (changed) {
401+
ESP_LOGI(TAG, "link-status: BSSID change -> "
402+
"%02x:%02x:%02x:%02x:%02x:%02x ch=%u reassoc=%lu (lr=%d)",
403+
ap.bssid[0], ap.bssid[1], ap.bssid[2],
404+
ap.bssid[3], ap.bssid[4], ap.bssid[5],
405+
(unsigned)ap.primary, (unsigned long)reassoc, lr);
406+
}
407+
}
408+
}
360409
}
361410

362411
/**

firmware/esp32-csi-node/main/main.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,18 @@ static EventGroupHandle_t s_wifi_event_group;
6161
static int s_retry_num = 0;
6262
#define MAX_RETRY 10
6363

64+
/* #1542: WIFI_EVENT_STA_CONNECTED events since boot, read by the
65+
* csi_collector link-status packet so the host can distinguish "stable
66+
* association" from "re-associated to the same BSSID". Monotonic per boot. */
67+
volatile uint32_t g_wifi_reassoc_count = 0;
68+
6469
static void event_handler(void *arg, esp_event_base_t event_base,
6570
int32_t event_id, void *event_data)
6671
{
6772
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
6873
esp_wifi_connect();
74+
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) {
75+
g_wifi_reassoc_count++;
6976
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
7077
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
7178
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);

v2/crates/wifi-densepose-hardware/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ mod esp32_parser;
4646
// the OpportunisticCsiBridge maps today's ESP32 CSI extraction onto the
4747
// standardized report path until an OTA binding exists.
4848
pub mod ieee80211bf;
49+
pub mod link_packet;
4950
pub mod sync_packet;
5051
/// ADR-270 capability-safe vendor RF provider contract.
5152
pub mod vendor_rf;
@@ -99,6 +100,9 @@ pub use rtl8720f::{
99100
RadarPayload as Rtl8720fRadarPayload, ReportType as Rtl8720fReportType,
100101
RTL8720F_RADAR_HEADER_LEN, RTL8720F_RADAR_MAGIC, RTL8720F_RADAR_VERSION,
101102
};
103+
pub use link_packet::{
104+
LinkStatusPacket, LINK_PACKET_MAGIC, LINK_PACKET_PROTO_VER, LINK_PACKET_SIZE,
105+
};
102106
pub use sync_packet::{
103107
SyncPacket, SyncPacketFlags, SYNC_PACKET_MAGIC, SYNC_PACKET_PROTO_VER, SYNC_PACKET_SIZE,
104108
};
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
//! Link-status packet decoder (issue #1542 ask 1: BSSID telemetry).
2+
//!
3+
//! The associated AP *is* the sensing-link geometry: when a station roams,
4+
//! every downstream consumer of that node's CSI is silently looking at a
5+
//! different Fresnel volume. Field data on #1542 shows published RSSI cannot
6+
//! reveal the change (−47.7 vs −48.2 dBm across two completely different
7+
//! link geometries), so the BSSID itself is the only observable.
8+
//!
9+
//! Emitted by the firmware on the same UDP socket as ADR-018 CSI frames and
10+
//! ADR-110 sync packets, distinguished by leading magic `0xC511_A111` (next
11+
//! value in the ADR-110 auxiliary-packet family). Low rate: every
12+
//! `CONFIG_LINK_STATUS_EVERY_N_FRAMES` CSI callbacks (default 600 ≈ 30 s at
13+
//! 20 Hz) plus one immediate emission whenever the BSSID observed via
14+
//! `esp_wifi_sta_get_ap_info()` differs from the previously reported one —
15+
//! so a roam is visible within one CSI callback, not one cadence period.
16+
//!
17+
//! Wire format (32 bytes, little-endian, mirrors the sync-packet layout):
18+
//! ```text
19+
//! [0..3] magic 0xC511A111 (LE u32)
20+
//! [4] node_id
21+
//! [5] proto_ver (currently 0x01)
22+
//! [6] flags: bit 0 = ap_info_valid (esp_wifi_sta_get_ap_info() == ESP_OK)
23+
//! [7] primary channel (0 when ap_info_valid = 0)
24+
//! [8..13] bssid[6] (all-zero when ap_info_valid = 0)
25+
//! [14] AP RSSI as seen by the station, dBm (i8; 0 when invalid)
26+
//! [15] reserved
27+
//! [16..19] reassoc_count (LE u32) — WIFI_EVENT_STA_CONNECTED events since boot
28+
//! [20..31] reserved
29+
//! ```
30+
31+
use serde::{Deserialize, Serialize};
32+
33+
use crate::error::ParseError;
34+
35+
/// Magic constant in the first 4 little-endian bytes of every link-status packet.
36+
pub const LINK_PACKET_MAGIC: u32 = 0xC511_A111;
37+
/// Total wire size of a link-status packet.
38+
pub const LINK_PACKET_SIZE: usize = 32;
39+
/// Wire protocol version currently emitted by firmware.
40+
pub const LINK_PACKET_PROTO_VER: u8 = 0x01;
41+
42+
/// Decoded #1542 link-status packet.
43+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44+
pub struct LinkStatusPacket {
45+
pub node_id: u8,
46+
pub proto_ver: u8,
47+
/// False when the firmware's `esp_wifi_sta_get_ap_info()` call failed
48+
/// (not associated); `bssid`/`channel`/`ap_rssi_dbm` are zero then.
49+
pub ap_info_valid: bool,
50+
/// Primary channel of the associated AP.
51+
pub channel: u8,
52+
/// BSSID of the associated AP — the sensing link's far endpoint identity.
53+
pub bssid: [u8; 6],
54+
/// RSSI of the AP as measured by the station (dBm). Complements the
55+
/// frame-level RSSI already in ADR-018 headers; comes from the same
56+
/// `wifi_ap_record_t` read as the BSSID.
57+
pub ap_rssi_dbm: i8,
58+
/// Count of `WIFI_EVENT_STA_CONNECTED` events since boot. Monotonic per
59+
/// boot; a host observing an increment without a reboot marker knows a
60+
/// re-association happened even if the BSSID ended up unchanged.
61+
pub reassoc_count: u32,
62+
}
63+
64+
impl LinkStatusPacket {
65+
/// Decode a 32-byte link-status packet. Host should dispatch on the
66+
/// leading magic before calling (same convention as `SyncPacket`).
67+
pub fn from_bytes(buf: &[u8]) -> Result<Self, ParseError> {
68+
if buf.len() < LINK_PACKET_SIZE {
69+
return Err(ParseError::InsufficientData {
70+
needed: LINK_PACKET_SIZE,
71+
got: buf.len(),
72+
});
73+
}
74+
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
75+
if magic != LINK_PACKET_MAGIC {
76+
return Err(ParseError::InvalidMagic { expected: LINK_PACKET_MAGIC, got: magic });
77+
}
78+
let node_id = buf[4];
79+
let proto_ver = buf[5];
80+
let ap_info_valid = (buf[6] & 0x01) != 0;
81+
let channel = buf[7];
82+
let mut bssid = [0u8; 6];
83+
bssid.copy_from_slice(&buf[8..14]);
84+
let ap_rssi_dbm = buf[14] as i8;
85+
// buf[15] reserved
86+
let reassoc_count = u32::from_le_bytes(buf[16..20].try_into().unwrap());
87+
// buf[20..32] reserved
88+
Ok(Self {
89+
node_id,
90+
proto_ver,
91+
ap_info_valid,
92+
channel,
93+
bssid,
94+
ap_rssi_dbm,
95+
reassoc_count,
96+
})
97+
}
98+
99+
/// Serialize back to wire bytes (32 bytes, little-endian).
100+
pub fn to_bytes(&self) -> [u8; LINK_PACKET_SIZE] {
101+
let mut out = [0u8; LINK_PACKET_SIZE];
102+
out[0..4].copy_from_slice(&LINK_PACKET_MAGIC.to_le_bytes());
103+
out[4] = self.node_id;
104+
out[5] = self.proto_ver;
105+
out[6] = if self.ap_info_valid { 0x01 } else { 0x00 };
106+
out[7] = self.channel;
107+
out[8..14].copy_from_slice(&self.bssid);
108+
out[14] = self.ap_rssi_dbm as u8;
109+
// out[15] reserved zero
110+
out[16..20].copy_from_slice(&self.reassoc_count.to_le_bytes());
111+
// out[20..32] reserved zero
112+
out
113+
}
114+
115+
/// Canonical lowercase colon-separated BSSID string (`aa:bb:cc:dd:ee:ff`)
116+
/// for JSON/MQTT surfaces.
117+
pub fn bssid_string(&self) -> String {
118+
let b = &self.bssid;
119+
format!(
120+
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
121+
b[0], b[1], b[2], b[3], b[4], b[5]
122+
)
123+
}
124+
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use super::*;
129+
130+
fn typical() -> LinkStatusPacket {
131+
LinkStatusPacket {
132+
node_id: 3,
133+
proto_ver: 1,
134+
ap_info_valid: true,
135+
channel: 6,
136+
bssid: [0x6c, 0xae, 0xf6, 0xb6, 0x52, 0xc7],
137+
ap_rssi_dbm: -48,
138+
reassoc_count: 2,
139+
}
140+
}
141+
142+
#[test]
143+
fn typical_packet_roundtrips() {
144+
let pkt = typical();
145+
let wire = pkt.to_bytes();
146+
let decoded = LinkStatusPacket::from_bytes(&wire).unwrap();
147+
assert_eq!(decoded, pkt);
148+
assert_eq!(decoded.bssid_string(), "6c:ae:f6:b6:52:c7");
149+
}
150+
151+
#[test]
152+
fn unassociated_packet_roundtrips_with_zero_fields() {
153+
let pkt = LinkStatusPacket {
154+
node_id: 5,
155+
proto_ver: 1,
156+
ap_info_valid: false,
157+
channel: 0,
158+
bssid: [0; 6],
159+
ap_rssi_dbm: 0,
160+
reassoc_count: 7,
161+
};
162+
let decoded = LinkStatusPacket::from_bytes(&pkt.to_bytes()).unwrap();
163+
assert_eq!(decoded, pkt);
164+
assert!(!decoded.ap_info_valid);
165+
assert_eq!(decoded.bssid_string(), "00:00:00:00:00:00");
166+
}
167+
168+
#[test]
169+
fn magic_mismatch_is_typed_error() {
170+
let mut wire = typical().to_bytes();
171+
wire[0] = 0x01;
172+
match LinkStatusPacket::from_bytes(&wire).unwrap_err() {
173+
ParseError::InvalidMagic { got, .. } => assert_ne!(got, LINK_PACKET_MAGIC),
174+
other => panic!("expected InvalidMagic, got {other:?}"),
175+
}
176+
}
177+
178+
#[test]
179+
fn short_packet_is_typed_error() {
180+
let wire = [0u8; 16];
181+
match LinkStatusPacket::from_bytes(&wire).unwrap_err() {
182+
ParseError::InsufficientData { needed, got } => {
183+
assert_eq!(needed, LINK_PACKET_SIZE);
184+
assert_eq!(got, 16);
185+
}
186+
other => panic!("expected InsufficientData, got {other:?}"),
187+
}
188+
}
189+
190+
/// Hosts dispatch CSI vs sync vs link purely on the leading u32; the
191+
/// three magics must never collide.
192+
#[test]
193+
fn link_magic_is_distinct_from_sync_and_csi() {
194+
assert_ne!(LINK_PACKET_MAGIC, crate::sync_packet::SYNC_PACKET_MAGIC);
195+
assert_ne!(LINK_PACKET_MAGIC, crate::esp32_parser::ESP32_CSI_MAGIC);
196+
}
197+
198+
/// Canonical wire pin (same convention as the sync packet's
199+
/// `canonical_wire_bytes_match_python_decoder`): if this hex stops
200+
/// matching, a decoder drifted from the wire.
201+
#[test]
202+
fn canonical_wire_bytes_pin() {
203+
let canonical: [u8; 32] = [
204+
0x11, 0xa1, 0x11, 0xc5, // magic 0xC511A111 (LE u32)
205+
0x03, // node_id = 3
206+
0x01, // proto_ver = 1
207+
0x01, // flags: ap_info_valid
208+
0x06, // channel 6
209+
0x6c, 0xae, 0xf6, 0xb6, 0x52, 0xc7, // bssid
210+
0xd0, // ap_rssi = -48 dBm (i8)
211+
0x00, // reserved
212+
0x02, 0x00, 0x00, 0x00, // reassoc_count = 2
213+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
214+
];
215+
let decoded = LinkStatusPacket::from_bytes(&canonical).unwrap();
216+
assert_eq!(decoded, typical());
217+
assert_eq!(decoded.to_bytes(), canonical,
218+
"to_bytes drifted from the canonical pin");
219+
}
220+
}

0 commit comments

Comments
 (0)