hive_btle/discovery/
beacon.rs

1//! HIVE Beacon format for BLE advertisements
2//!
3//! This module defines the wire format for HIVE beacons that are broadcast
4//! via BLE advertising packets. The beacon format is designed to fit within
5//! the 31-byte legacy advertising limit while conveying essential node info.
6//!
7//! ## Wire Format (16 bytes)
8//!
9//! ```text
10//! Byte  0: Version (4 bits) | Capabilities high (4 bits)
11//! Byte  1: Capabilities low (8 bits)
12//! Bytes 2-5: Node ID (32 bits, big-endian)
13//! Byte  6: Hierarchy level (8 bits)
14//! Bytes 7-9: Geohash (24 bits, 6-char precision)
15//! Byte 10: Battery percent (0-100)
16//! Bytes 11-12: Sequence number (16 bits, big-endian)
17//! Bytes 13-15: Reserved (for future use)
18//! ```
19//!
20//! ## Advertising Packet Layout
21//!
22//! The complete advertising packet includes:
23//! - Flags (3 bytes): `02 01 06`
24//! - Complete 128-bit UUID (18 bytes): `11 07 <UUID>`
25//! - Manufacturer Data (remaining): `<len> FF <company_id> <beacon_data>`
26//!
27//! Total: 3 + 18 + (3 + 16) = 40 bytes (requires extended advertising)
28//! Or with shortened beacon: 3 + 18 + (3 + 10) = 34 bytes (still needs extended)
29//!
30//! For legacy (31 bytes), we use service data instead:
31//! - Flags (3 bytes)
32//! - Service Data (18 bytes): `11 16 <UUID_16bit> <beacon_data>`
33//!
34//! Total: 3 + 18 = 21 bytes (fits!)
35
36#[cfg(not(feature = "std"))]
37use alloc::string::String;
38
39use crate::{capabilities, HierarchyLevel, NodeId};
40
41/// HIVE beacon protocol version
42pub const BEACON_VERSION: u8 = 1;
43
44/// Beacon size in bytes
45pub const BEACON_SIZE: usize = 16;
46
47/// Compact beacon size (for legacy advertising)
48pub const BEACON_COMPACT_SIZE: usize = 10;
49
50/// HIVE Beacon data structure
51///
52/// Contains all information broadcast in a HIVE BLE advertisement.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct HiveBeacon {
55    /// Protocol version (0-15)
56    pub version: u8,
57    /// Node capabilities flags
58    pub capabilities: u16,
59    /// Node identifier
60    pub node_id: NodeId,
61    /// Hierarchy level in the mesh
62    pub hierarchy_level: HierarchyLevel,
63    /// Geohash for location (24-bit, ~600m precision)
64    pub geohash: u32,
65    /// Battery percentage (0-100, 255 = unknown)
66    pub battery_percent: u8,
67    /// Sequence number for deduplication
68    pub seq_num: u16,
69}
70
71impl HiveBeacon {
72    /// Create a new beacon with the given node ID
73    pub fn new(node_id: NodeId) -> Self {
74        Self {
75            version: BEACON_VERSION,
76            capabilities: 0,
77            node_id,
78            hierarchy_level: HierarchyLevel::Platform,
79            geohash: 0,
80            battery_percent: 255, // Unknown
81            seq_num: 0,
82        }
83    }
84
85    /// Create a beacon for a HIVE-Lite node
86    pub fn hive_lite(node_id: NodeId) -> Self {
87        Self {
88            version: BEACON_VERSION,
89            capabilities: capabilities::LITE_NODE,
90            node_id,
91            hierarchy_level: HierarchyLevel::Platform,
92            geohash: 0,
93            battery_percent: 255,
94            seq_num: 0,
95        }
96    }
97
98    /// Set capabilities
99    pub fn with_capabilities(mut self, capabilities: u16) -> Self {
100        self.capabilities = capabilities;
101        self
102    }
103
104    /// Set hierarchy level
105    pub fn with_hierarchy_level(mut self, level: HierarchyLevel) -> Self {
106        self.hierarchy_level = level;
107        self
108    }
109
110    /// Set geohash
111    pub fn with_geohash(mut self, geohash: u32) -> Self {
112        self.geohash = geohash & 0x00FFFFFF; // 24 bits only
113        self
114    }
115
116    /// Set battery percentage
117    pub fn with_battery(mut self, percent: u8) -> Self {
118        self.battery_percent = percent.min(100);
119        self
120    }
121
122    /// Increment sequence number
123    pub fn increment_seq(&mut self) {
124        self.seq_num = self.seq_num.wrapping_add(1);
125    }
126
127    /// Encode beacon to bytes (full 16-byte format)
128    pub fn encode(&self) -> [u8; BEACON_SIZE] {
129        let mut buf = [0u8; BEACON_SIZE];
130
131        // Byte 0: Version (4 bits) | Capabilities high (4 bits)
132        buf[0] = ((self.version & 0x0F) << 4) | ((self.capabilities >> 8) as u8 & 0x0F);
133
134        // Byte 1: Capabilities low (8 bits)
135        buf[1] = (self.capabilities & 0xFF) as u8;
136
137        // Bytes 2-5: Node ID (big-endian)
138        let node_id = self.node_id.as_u32();
139        buf[2] = (node_id >> 24) as u8;
140        buf[3] = (node_id >> 16) as u8;
141        buf[4] = (node_id >> 8) as u8;
142        buf[5] = node_id as u8;
143
144        // Byte 6: Hierarchy level
145        buf[6] = self.hierarchy_level.into();
146
147        // Bytes 7-9: Geohash (24 bits, big-endian)
148        buf[7] = (self.geohash >> 16) as u8;
149        buf[8] = (self.geohash >> 8) as u8;
150        buf[9] = self.geohash as u8;
151
152        // Byte 10: Battery percent
153        buf[10] = self.battery_percent;
154
155        // Bytes 11-12: Sequence number (big-endian)
156        buf[11] = (self.seq_num >> 8) as u8;
157        buf[12] = self.seq_num as u8;
158
159        // Bytes 13-15: Reserved
160        buf[13] = 0;
161        buf[14] = 0;
162        buf[15] = 0;
163
164        buf
165    }
166
167    /// Encode beacon to compact format (10 bytes for legacy advertising)
168    ///
169    /// Compact format omits geohash and reserved bytes:
170    /// - Byte 0: Version | Capabilities high
171    /// - Byte 1: Capabilities low
172    /// - Bytes 2-5: Node ID
173    /// - Byte 6: Hierarchy level
174    /// - Byte 7: Battery percent
175    /// - Bytes 8-9: Sequence number
176    pub fn encode_compact(&self) -> [u8; BEACON_COMPACT_SIZE] {
177        let mut buf = [0u8; BEACON_COMPACT_SIZE];
178
179        buf[0] = ((self.version & 0x0F) << 4) | ((self.capabilities >> 8) as u8 & 0x0F);
180        buf[1] = (self.capabilities & 0xFF) as u8;
181
182        let node_id = self.node_id.as_u32();
183        buf[2] = (node_id >> 24) as u8;
184        buf[3] = (node_id >> 16) as u8;
185        buf[4] = (node_id >> 8) as u8;
186        buf[5] = node_id as u8;
187
188        buf[6] = self.hierarchy_level.into();
189        buf[7] = self.battery_percent;
190
191        buf[8] = (self.seq_num >> 8) as u8;
192        buf[9] = self.seq_num as u8;
193
194        buf
195    }
196
197    /// Decode beacon from bytes (full 16-byte format)
198    pub fn decode(data: &[u8]) -> Option<Self> {
199        if data.len() < BEACON_SIZE {
200            return None;
201        }
202
203        let version = (data[0] >> 4) & 0x0F;
204        let capabilities = ((data[0] as u16 & 0x0F) << 8) | (data[1] as u16);
205
206        let node_id = NodeId::new(
207            ((data[2] as u32) << 24)
208                | ((data[3] as u32) << 16)
209                | ((data[4] as u32) << 8)
210                | (data[5] as u32),
211        );
212
213        let hierarchy_level = HierarchyLevel::from(data[6]);
214
215        let geohash = ((data[7] as u32) << 16) | ((data[8] as u32) << 8) | (data[9] as u32);
216
217        let battery_percent = data[10];
218
219        let seq_num = ((data[11] as u16) << 8) | (data[12] as u16);
220
221        Some(Self {
222            version,
223            capabilities,
224            node_id,
225            hierarchy_level,
226            geohash,
227            battery_percent,
228            seq_num,
229        })
230    }
231
232    /// Decode beacon from compact format (10 bytes)
233    pub fn decode_compact(data: &[u8]) -> Option<Self> {
234        if data.len() < BEACON_COMPACT_SIZE {
235            return None;
236        }
237
238        let version = (data[0] >> 4) & 0x0F;
239        let capabilities = ((data[0] as u16 & 0x0F) << 8) | (data[1] as u16);
240
241        let node_id = NodeId::new(
242            ((data[2] as u32) << 24)
243                | ((data[3] as u32) << 16)
244                | ((data[4] as u32) << 8)
245                | (data[5] as u32),
246        );
247
248        let hierarchy_level = HierarchyLevel::from(data[6]);
249        let battery_percent = data[7];
250        let seq_num = ((data[8] as u16) << 8) | (data[9] as u16);
251
252        Some(Self {
253            version,
254            capabilities,
255            node_id,
256            hierarchy_level,
257            geohash: 0, // Not included in compact format
258            battery_percent,
259            seq_num,
260        })
261    }
262
263    /// Check if this is a HIVE-Lite node
264    pub fn is_lite_node(&self) -> bool {
265        self.capabilities & capabilities::LITE_NODE != 0
266    }
267
268    /// Check if this node can relay messages
269    pub fn can_relay(&self) -> bool {
270        self.capabilities & capabilities::CAN_RELAY != 0
271    }
272
273    /// Check if this node supports Coded PHY
274    pub fn supports_coded_phy(&self) -> bool {
275        self.capabilities & capabilities::CODED_PHY != 0
276    }
277}
278
279impl Default for HiveBeacon {
280    fn default() -> Self {
281        Self::new(NodeId::default())
282    }
283}
284
285/// Parsed advertising data from a discovered device
286#[derive(Debug, Clone)]
287pub struct ParsedAdvertisement {
288    /// Device address (MAC or platform-specific)
289    pub address: String,
290    /// RSSI in dBm
291    pub rssi: i8,
292    /// Parsed HIVE beacon (if this is a HIVE device)
293    pub beacon: Option<HiveBeacon>,
294    /// Device local name
295    pub local_name: Option<String>,
296    /// TX power level (if advertised)
297    pub tx_power: Option<i8>,
298    /// Whether the device is connectable
299    pub connectable: bool,
300}
301
302impl ParsedAdvertisement {
303    /// Check if this is a HIVE device
304    pub fn is_hive_device(&self) -> bool {
305        self.beacon.is_some()
306    }
307
308    /// Get the node ID if this is a HIVE device
309    pub fn node_id(&self) -> Option<&NodeId> {
310        self.beacon.as_ref().map(|b| &b.node_id)
311    }
312
313    /// Estimate distance based on RSSI and TX power
314    ///
315    /// Uses the log-distance path loss model:
316    /// distance = 10 ^ ((tx_power - rssi) / (10 * n))
317    /// where n is the path loss exponent (typically 2-4)
318    ///
319    /// Note: Requires std feature for floating point math.
320    #[cfg(feature = "std")]
321    pub fn estimated_distance_meters(&self) -> Option<f32> {
322        let tx_power = self.tx_power.unwrap_or(0) as f32;
323        let rssi = self.rssi as f32;
324        let n = 2.5; // Path loss exponent (indoor environment)
325
326        if rssi >= tx_power {
327            return Some(1.0); // Very close
328        }
329
330        let distance = 10.0_f32.powf((tx_power - rssi) / (10.0 * n));
331        Some(distance)
332    }
333
334    /// Stub for no_std - always returns None
335    #[cfg(not(feature = "std"))]
336    pub fn estimated_distance_meters(&self) -> Option<f32> {
337        None
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_beacon_encode_decode() {
347        let beacon = HiveBeacon::new(NodeId::new(0x12345678))
348            .with_capabilities(capabilities::LITE_NODE | capabilities::SENSOR_ACCEL)
349            .with_hierarchy_level(HierarchyLevel::Squad)
350            .with_geohash(0x98FF88)
351            .with_battery(75);
352
353        let encoded = beacon.encode();
354        let decoded = HiveBeacon::decode(&encoded).unwrap();
355
356        assert_eq!(decoded.version, beacon.version);
357        assert_eq!(decoded.capabilities, beacon.capabilities);
358        assert_eq!(decoded.node_id, beacon.node_id);
359        assert_eq!(decoded.hierarchy_level, beacon.hierarchy_level);
360        assert_eq!(decoded.geohash, beacon.geohash & 0x00FFFFFF);
361        assert_eq!(decoded.battery_percent, beacon.battery_percent);
362    }
363
364    #[test]
365    fn test_beacon_compact_encode_decode() {
366        let beacon = HiveBeacon::new(NodeId::new(0xDEADBEEF))
367            .with_capabilities(capabilities::CAN_RELAY)
368            .with_battery(50);
369
370        let encoded = beacon.encode_compact();
371        assert_eq!(encoded.len(), BEACON_COMPACT_SIZE);
372
373        let decoded = HiveBeacon::decode_compact(&encoded).unwrap();
374
375        assert_eq!(decoded.node_id, beacon.node_id);
376        assert_eq!(decoded.capabilities, beacon.capabilities);
377        assert_eq!(decoded.battery_percent, beacon.battery_percent);
378        assert_eq!(decoded.geohash, 0); // Not in compact format
379    }
380
381    #[test]
382    fn test_beacon_size() {
383        let beacon = HiveBeacon::new(NodeId::new(0x12345678));
384        let encoded = beacon.encode();
385        assert_eq!(encoded.len(), BEACON_SIZE);
386        assert_eq!(encoded.len(), 16);
387    }
388
389    #[test]
390    fn test_beacon_version() {
391        let beacon = HiveBeacon::new(NodeId::new(0x12345678));
392        let encoded = beacon.encode();
393        let version = (encoded[0] >> 4) & 0x0F;
394        assert_eq!(version, BEACON_VERSION);
395    }
396
397    #[test]
398    fn test_beacon_capabilities() {
399        let caps = capabilities::LITE_NODE | capabilities::CODED_PHY | capabilities::HAS_GPS;
400        let beacon = HiveBeacon::new(NodeId::new(0x12345678)).with_capabilities(caps);
401
402        assert!(beacon.is_lite_node());
403        assert!(beacon.supports_coded_phy());
404        assert!(!beacon.can_relay());
405
406        let encoded = beacon.encode();
407        let decoded = HiveBeacon::decode(&encoded).unwrap();
408        assert_eq!(decoded.capabilities, caps);
409    }
410
411    #[test]
412    fn test_sequence_number_wrap() {
413        let mut beacon = HiveBeacon::new(NodeId::new(0x12345678));
414        beacon.seq_num = 0xFFFF;
415        beacon.increment_seq();
416        assert_eq!(beacon.seq_num, 0);
417    }
418
419    #[test]
420    fn test_decode_invalid_length() {
421        let short_data = [0u8; 5];
422        assert!(HiveBeacon::decode(&short_data).is_none());
423        assert!(HiveBeacon::decode_compact(&short_data).is_none());
424    }
425
426    #[test]
427    fn test_estimated_distance() {
428        let adv = ParsedAdvertisement {
429            address: "00:11:22:33:44:55".to_string(),
430            rssi: -60,
431            beacon: None,
432            local_name: None,
433            tx_power: Some(-20), // Typical BLE TX power
434            connectable: true,
435        };
436
437        let distance = adv.estimated_distance_meters().unwrap();
438        // Path loss model gives rough estimate - test that it returns a reasonable value
439        // With TX=-20dBm, RSSI=-60dBm, n=2.5: d = 10^(40/25) ≈ 25m
440        assert!(distance > 1.0 && distance < 100.0);
441    }
442
443    #[test]
444    fn test_hive_lite_beacon() {
445        let beacon = HiveBeacon::hive_lite(NodeId::new(0xCAFEBABE));
446        assert!(beacon.is_lite_node());
447        assert!(!beacon.can_relay());
448    }
449}