hive_btle/discovery/
beacon.rs

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