Skip to main content

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, vec::Vec};
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 with plaintext beacon)
308    pub beacon: Option<HiveBeacon>,
309    /// Raw encrypted beacon service data (if version 0x02 beacon detected)
310    ///
311    /// Platform code should populate this when it detects service data
312    /// starting with version byte 0x02 (encrypted beacon format).
313    /// The Scanner will attempt decryption if a beacon key is configured.
314    pub encrypted_service_data: Option<Vec<u8>>,
315    /// Device local name
316    pub local_name: Option<String>,
317    /// TX power level (if advertised)
318    pub tx_power: Option<i8>,
319    /// Whether the device is connectable
320    pub connectable: bool,
321}
322
323impl ParsedAdvertisement {
324    /// Check if this is a HIVE device
325    ///
326    /// Returns true if either a plaintext beacon is present or encrypted
327    /// service data is available (which may be decryptable by the Scanner).
328    pub fn is_hive_device(&self) -> bool {
329        self.beacon.is_some() || self.encrypted_service_data.is_some()
330    }
331
332    /// Get the node ID if this is a HIVE device
333    pub fn node_id(&self) -> Option<&NodeId> {
334        self.beacon.as_ref().map(|b| &b.node_id)
335    }
336
337    /// Estimate distance based on RSSI and TX power
338    ///
339    /// Uses the log-distance path loss model:
340    /// distance = 10 ^ ((tx_power - rssi) / (10 * n))
341    /// where n is the path loss exponent (typically 2-4)
342    ///
343    /// Note: Requires std feature for floating point math.
344    #[cfg(feature = "std")]
345    pub fn estimated_distance_meters(&self) -> Option<f32> {
346        let tx_power = self.tx_power.unwrap_or(0) as f32;
347        let rssi = self.rssi as f32;
348        let n = 2.5; // Path loss exponent (indoor environment)
349
350        if rssi >= tx_power {
351            return Some(1.0); // Very close
352        }
353
354        let distance = 10.0_f32.powf((tx_power - rssi) / (10.0 * n));
355        Some(distance)
356    }
357
358    /// Stub for no_std - always returns None
359    #[cfg(not(feature = "std"))]
360    pub fn estimated_distance_meters(&self) -> Option<f32> {
361        None
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_beacon_encode_decode() {
371        let beacon = HiveBeacon::new(NodeId::new(0x12345678))
372            .with_capabilities(capabilities::LITE_NODE | capabilities::SENSOR_ACCEL)
373            .with_hierarchy_level(HierarchyLevel::Squad)
374            .with_geohash(0x98FF88)
375            .with_battery(75);
376
377        let encoded = beacon.encode();
378        let decoded = HiveBeacon::decode(&encoded).unwrap();
379
380        assert_eq!(decoded.version, beacon.version);
381        assert_eq!(decoded.capabilities, beacon.capabilities);
382        assert_eq!(decoded.node_id, beacon.node_id);
383        assert_eq!(decoded.hierarchy_level, beacon.hierarchy_level);
384        assert_eq!(decoded.geohash, beacon.geohash & 0x00FFFFFF);
385        assert_eq!(decoded.battery_percent, beacon.battery_percent);
386    }
387
388    #[test]
389    fn test_beacon_compact_encode_decode() {
390        let beacon = HiveBeacon::new(NodeId::new(0xDEADBEEF))
391            .with_capabilities(capabilities::CAN_RELAY)
392            .with_battery(50);
393
394        let encoded = beacon.encode_compact();
395        assert_eq!(encoded.len(), BEACON_COMPACT_SIZE);
396
397        let decoded = HiveBeacon::decode_compact(&encoded).unwrap();
398
399        assert_eq!(decoded.node_id, beacon.node_id);
400        assert_eq!(decoded.capabilities, beacon.capabilities);
401        assert_eq!(decoded.battery_percent, beacon.battery_percent);
402        assert_eq!(decoded.geohash, 0); // Not in compact format
403    }
404
405    #[test]
406    fn test_beacon_size() {
407        let beacon = HiveBeacon::new(NodeId::new(0x12345678));
408        let encoded = beacon.encode();
409        assert_eq!(encoded.len(), BEACON_SIZE);
410        assert_eq!(encoded.len(), 16);
411    }
412
413    #[test]
414    fn test_beacon_version() {
415        let beacon = HiveBeacon::new(NodeId::new(0x12345678));
416        let encoded = beacon.encode();
417        let version = (encoded[0] >> 4) & 0x0F;
418        assert_eq!(version, BEACON_VERSION);
419    }
420
421    #[test]
422    fn test_beacon_capabilities() {
423        let caps = capabilities::LITE_NODE | capabilities::CODED_PHY | capabilities::HAS_GPS;
424        let beacon = HiveBeacon::new(NodeId::new(0x12345678)).with_capabilities(caps);
425
426        assert!(beacon.is_lite_node());
427        assert!(beacon.supports_coded_phy());
428        assert!(!beacon.can_relay());
429
430        let encoded = beacon.encode();
431        let decoded = HiveBeacon::decode(&encoded).unwrap();
432        assert_eq!(decoded.capabilities, caps);
433    }
434
435    #[test]
436    fn test_sequence_number_wrap() {
437        let mut beacon = HiveBeacon::new(NodeId::new(0x12345678));
438        beacon.seq_num = 0xFFFF;
439        beacon.increment_seq();
440        assert_eq!(beacon.seq_num, 0);
441    }
442
443    #[test]
444    fn test_decode_invalid_length() {
445        let short_data = [0u8; 5];
446        assert!(HiveBeacon::decode(&short_data).is_none());
447        assert!(HiveBeacon::decode_compact(&short_data).is_none());
448    }
449
450    #[test]
451    fn test_estimated_distance() {
452        let adv = ParsedAdvertisement {
453            address: "00:11:22:33:44:55".to_string(),
454            rssi: -60,
455            beacon: None,
456            encrypted_service_data: None,
457            local_name: None,
458            tx_power: Some(-20), // Typical BLE TX power
459            connectable: true,
460        };
461
462        let distance = adv.estimated_distance_meters().unwrap();
463        // Path loss model gives rough estimate - test that it returns a reasonable value
464        // With TX=-20dBm, RSSI=-60dBm, n=2.5: d = 10^(40/25) ≈ 25m
465        assert!(distance > 1.0 && distance < 100.0);
466    }
467
468    #[test]
469    fn test_hive_lite_beacon() {
470        let beacon = HiveBeacon::hive_lite(NodeId::new(0xCAFEBABE));
471        assert!(beacon.is_lite_node());
472        assert!(!beacon.can_relay());
473    }
474}