hive_btle/discovery/
advertiser.rs

1//! HIVE Beacon Advertiser
2//!
3//! Builds and manages BLE advertising packets containing HIVE beacons.
4
5#[cfg(not(feature = "std"))]
6use alloc::{string::String, vec::Vec};
7
8use crate::config::DiscoveryConfig;
9use crate::{HierarchyLevel, NodeId, HIVE_SERVICE_UUID_16BIT};
10
11use super::beacon::{HiveBeacon, BEACON_COMPACT_SIZE};
12
13/// Maximum advertising data length for legacy advertising
14const LEGACY_ADV_MAX: usize = 31;
15
16/// Maximum advertising data length for extended advertising
17#[allow(dead_code)]
18const EXTENDED_ADV_MAX: usize = 254;
19
20/// AD Type: Flags
21const AD_TYPE_FLAGS: u8 = 0x01;
22
23/// AD Type: Complete List of 16-bit Service UUIDs
24const AD_TYPE_SERVICE_UUID_16: u8 = 0x03;
25
26/// AD Type: Service Data - 16-bit UUID
27const AD_TYPE_SERVICE_DATA_16: u8 = 0x16;
28
29/// AD Type: Complete Local Name
30const AD_TYPE_LOCAL_NAME: u8 = 0x09;
31
32/// AD Type: Shortened Local Name
33const AD_TYPE_SHORT_NAME: u8 = 0x08;
34
35/// AD Type: TX Power Level
36const AD_TYPE_TX_POWER: u8 = 0x0A;
37
38/// Flags value: LE General Discoverable Mode + BR/EDR Not Supported
39const FLAGS_VALUE: u8 = 0x06;
40
41/// Advertiser state
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AdvertiserState {
44    /// Not advertising
45    Idle,
46    /// Actively advertising
47    Advertising,
48    /// Temporarily paused (e.g., during connection)
49    Paused,
50}
51
52/// Built advertising packet
53#[derive(Debug, Clone)]
54pub struct AdvertisingPacket {
55    /// Advertising data
56    pub adv_data: Vec<u8>,
57    /// Scan response data (optional)
58    pub scan_rsp: Option<Vec<u8>>,
59    /// Whether this uses extended advertising
60    pub extended: bool,
61}
62
63impl AdvertisingPacket {
64    /// Check if this packet fits in legacy advertising
65    pub fn fits_legacy(&self) -> bool {
66        self.adv_data.len() <= LEGACY_ADV_MAX
67            && self
68                .scan_rsp
69                .as_ref()
70                .is_none_or(|sr| sr.len() <= LEGACY_ADV_MAX)
71    }
72
73    /// Total advertising data size
74    pub fn total_size(&self) -> usize {
75        self.adv_data.len() + self.scan_rsp.as_ref().map_or(0, |sr| sr.len())
76    }
77}
78
79/// HIVE Beacon Advertiser
80///
81/// Manages building and updating BLE advertisements containing HIVE beacons.
82pub struct Advertiser {
83    /// Configuration (will be used for PHY/power management)
84    #[allow(dead_code)]
85    config: DiscoveryConfig,
86    /// Current beacon
87    beacon: HiveBeacon,
88    /// Current state
89    state: AdvertiserState,
90    /// When advertising started (monotonic ms timestamp)
91    started_at_ms: Option<u64>,
92    /// Current time (monotonic ms, set externally)
93    current_time_ms: u64,
94    /// TX power level to advertise
95    tx_power: Option<i8>,
96    /// Device name to include
97    device_name: Option<String>,
98    /// Use extended advertising if available
99    use_extended: bool,
100    /// Last built packet (cached)
101    cached_packet: Option<AdvertisingPacket>,
102    /// Whether cache is dirty
103    cache_dirty: bool,
104}
105
106impl Advertiser {
107    /// Create a new advertiser with the given configuration and node ID
108    pub fn new(config: DiscoveryConfig, node_id: NodeId) -> Self {
109        let beacon = HiveBeacon::new(node_id);
110        Self {
111            config,
112            beacon,
113            state: AdvertiserState::Idle,
114            started_at_ms: None,
115            current_time_ms: 0,
116            tx_power: None,
117            device_name: None,
118            use_extended: false,
119            cached_packet: None,
120            cache_dirty: true,
121        }
122    }
123
124    /// Create an advertiser for a HIVE-Lite node
125    pub fn hive_lite(config: DiscoveryConfig, node_id: NodeId) -> Self {
126        let beacon = HiveBeacon::hive_lite(node_id);
127        Self {
128            config,
129            beacon,
130            state: AdvertiserState::Idle,
131            started_at_ms: None,
132            current_time_ms: 0,
133            tx_power: None,
134            device_name: None,
135            use_extended: false,
136            cached_packet: None,
137            cache_dirty: true,
138        }
139    }
140
141    /// Set the current time (call periodically from platform)
142    pub fn set_time_ms(&mut self, time_ms: u64) {
143        self.current_time_ms = time_ms;
144    }
145
146    /// Set TX power level
147    pub fn with_tx_power(mut self, tx_power: i8) -> Self {
148        self.tx_power = Some(tx_power);
149        self.cache_dirty = true;
150        self
151    }
152
153    /// Set device name
154    pub fn with_name(mut self, name: String) -> Self {
155        self.device_name = Some(name);
156        self.cache_dirty = true;
157        self
158    }
159
160    /// Enable extended advertising
161    pub fn with_extended_advertising(mut self, enabled: bool) -> Self {
162        self.use_extended = enabled;
163        self.cache_dirty = true;
164        self
165    }
166
167    /// Get current state
168    pub fn state(&self) -> AdvertiserState {
169        self.state
170    }
171
172    /// Get the current beacon
173    pub fn beacon(&self) -> &HiveBeacon {
174        &self.beacon
175    }
176
177    /// Get mutable access to the beacon
178    pub fn beacon_mut(&mut self) -> &mut HiveBeacon {
179        self.cache_dirty = true;
180        &mut self.beacon
181    }
182
183    /// Update hierarchy level
184    pub fn set_hierarchy_level(&mut self, level: HierarchyLevel) {
185        self.beacon.hierarchy_level = level;
186        self.cache_dirty = true;
187    }
188
189    /// Update capabilities
190    pub fn set_capabilities(&mut self, caps: u16) {
191        self.beacon.capabilities = caps;
192        self.cache_dirty = true;
193    }
194
195    /// Update battery percentage
196    pub fn set_battery(&mut self, percent: u8) {
197        self.beacon.battery_percent = percent.min(100);
198        self.cache_dirty = true;
199    }
200
201    /// Update geohash
202    pub fn set_geohash(&mut self, geohash: u32) {
203        self.beacon.geohash = geohash & 0x00FFFFFF;
204        self.cache_dirty = true;
205    }
206
207    /// Start advertising
208    pub fn start(&mut self) {
209        self.state = AdvertiserState::Advertising;
210        self.started_at_ms = Some(self.current_time_ms);
211    }
212
213    /// Pause advertising
214    pub fn pause(&mut self) {
215        self.state = AdvertiserState::Paused;
216    }
217
218    /// Resume advertising
219    pub fn resume(&mut self) {
220        if self.state == AdvertiserState::Paused {
221            self.state = AdvertiserState::Advertising;
222        }
223    }
224
225    /// Stop advertising
226    pub fn stop(&mut self) {
227        self.state = AdvertiserState::Idle;
228        self.started_at_ms = None;
229    }
230
231    /// Get duration of current advertising session in milliseconds
232    pub fn advertising_duration_ms(&self) -> Option<u64> {
233        self.started_at_ms
234            .map(|t| self.current_time_ms.saturating_sub(t))
235    }
236
237    /// Increment sequence number and invalidate cache
238    pub fn increment_sequence(&mut self) {
239        self.beacon.increment_seq();
240        self.cache_dirty = true;
241    }
242
243    /// Build the advertising packet
244    ///
245    /// Uses cached packet if available and not dirty.
246    pub fn build_packet(&mut self) -> &AdvertisingPacket {
247        if self.cache_dirty || self.cached_packet.is_none() {
248            let packet = self.build_packet_inner();
249            self.cached_packet = Some(packet);
250            self.cache_dirty = false;
251        }
252        self.cached_packet.as_ref().unwrap()
253    }
254
255    /// Force rebuild of advertising packet
256    pub fn rebuild_packet(&mut self) -> &AdvertisingPacket {
257        self.cache_dirty = true;
258        self.build_packet()
259    }
260
261    /// Internal packet building
262    fn build_packet_inner(&self) -> AdvertisingPacket {
263        let mut adv_data = Vec::with_capacity(31);
264        let mut scan_rsp = Vec::with_capacity(31);
265
266        // Flags (3 bytes)
267        adv_data.push(2); // Length
268        adv_data.push(AD_TYPE_FLAGS);
269        adv_data.push(FLAGS_VALUE);
270
271        // Service UUID (4 bytes for 16-bit UUID)
272        adv_data.push(3); // Length
273        adv_data.push(AD_TYPE_SERVICE_UUID_16);
274        adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
275        adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
276
277        // Service Data with beacon (3 + 10 = 13 bytes for compact beacon)
278        let beacon_data = self.beacon.encode_compact();
279        adv_data.push((2 + BEACON_COMPACT_SIZE) as u8); // Length
280        adv_data.push(AD_TYPE_SERVICE_DATA_16);
281        adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
282        adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
283        adv_data.extend_from_slice(&beacon_data);
284
285        // TX Power (3 bytes) - add if space permits
286        if let Some(tx_power) = self.tx_power {
287            if adv_data.len() + 3 <= LEGACY_ADV_MAX {
288                adv_data.push(2); // Length
289                adv_data.push(AD_TYPE_TX_POWER);
290                adv_data.push(tx_power as u8);
291            } else {
292                // Put in scan response
293                scan_rsp.push(2);
294                scan_rsp.push(AD_TYPE_TX_POWER);
295                scan_rsp.push(tx_power as u8);
296            }
297        }
298
299        // Device name - prefer scan response
300        if let Some(ref name) = self.device_name {
301            let name_bytes = name.as_bytes();
302            let max_name_len = LEGACY_ADV_MAX - 2; // Room for length and type
303
304            if name_bytes.len() <= max_name_len {
305                // Full name fits
306                scan_rsp.push(name_bytes.len() as u8 + 1);
307                scan_rsp.push(AD_TYPE_LOCAL_NAME);
308                scan_rsp.extend_from_slice(name_bytes);
309            } else {
310                // Shorten name
311                let short_name = &name_bytes[..max_name_len.min(name_bytes.len())];
312                scan_rsp.push(short_name.len() as u8 + 1);
313                scan_rsp.push(AD_TYPE_SHORT_NAME);
314                scan_rsp.extend_from_slice(short_name);
315            }
316        }
317
318        let extended =
319            self.use_extended || adv_data.len() > LEGACY_ADV_MAX || scan_rsp.len() > LEGACY_ADV_MAX;
320
321        AdvertisingPacket {
322            adv_data,
323            scan_rsp: if scan_rsp.is_empty() {
324                None
325            } else {
326                Some(scan_rsp)
327            },
328            extended,
329        }
330    }
331
332    /// Get raw advertising data bytes
333    pub fn advertising_data(&mut self) -> Vec<u8> {
334        self.build_packet().adv_data.clone()
335    }
336
337    /// Get raw scan response bytes
338    pub fn scan_response_data(&mut self) -> Option<Vec<u8>> {
339        self.build_packet().scan_rsp.clone()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::capabilities;
347
348    #[test]
349    fn test_advertiser_new() {
350        let config = DiscoveryConfig::default();
351        let node_id = NodeId::new(0x12345678);
352        let advertiser = Advertiser::new(config, node_id);
353
354        assert_eq!(advertiser.state(), AdvertiserState::Idle);
355        assert_eq!(advertiser.beacon().node_id, node_id);
356    }
357
358    #[test]
359    fn test_advertiser_hive_lite() {
360        let config = DiscoveryConfig::default();
361        let node_id = NodeId::new(0xCAFEBABE);
362        let advertiser = Advertiser::hive_lite(config, node_id);
363
364        assert!(advertiser.beacon().is_lite_node());
365    }
366
367    #[test]
368    fn test_advertiser_state_transitions() {
369        let config = DiscoveryConfig::default();
370        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
371
372        assert_eq!(advertiser.state(), AdvertiserState::Idle);
373
374        advertiser.set_time_ms(1000);
375        advertiser.start();
376        assert_eq!(advertiser.state(), AdvertiserState::Advertising);
377        advertiser.set_time_ms(2000);
378        assert_eq!(advertiser.advertising_duration_ms(), Some(1000));
379
380        advertiser.pause();
381        assert_eq!(advertiser.state(), AdvertiserState::Paused);
382
383        advertiser.resume();
384        assert_eq!(advertiser.state(), AdvertiserState::Advertising);
385
386        advertiser.stop();
387        assert_eq!(advertiser.state(), AdvertiserState::Idle);
388        assert!(advertiser.advertising_duration_ms().is_none());
389    }
390
391    #[test]
392    fn test_build_packet_fits_legacy() {
393        let config = DiscoveryConfig::default();
394        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
395
396        let packet = advertiser.build_packet();
397        assert!(packet.fits_legacy());
398        assert!(!packet.extended);
399
400        // Should be: Flags(3) + UUID(4) + ServiceData(14) = 21 bytes
401        assert!(packet.adv_data.len() <= LEGACY_ADV_MAX);
402    }
403
404    #[test]
405    fn test_build_packet_with_name() {
406        let config = DiscoveryConfig::default();
407        let mut advertiser =
408            Advertiser::new(config, NodeId::new(0x12345678)).with_name("HIVE-12345678".to_string());
409
410        let packet = advertiser.build_packet();
411        assert!(packet.scan_rsp.is_some());
412
413        let scan_rsp = packet.scan_rsp.as_ref().unwrap();
414        // Should contain the name
415        assert!(scan_rsp.contains(&AD_TYPE_LOCAL_NAME));
416    }
417
418    #[test]
419    fn test_build_packet_with_tx_power() {
420        let config = DiscoveryConfig::default();
421        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678)).with_tx_power(0);
422
423        let packet = advertiser.build_packet();
424
425        // TX power should be in adv_data (we have space)
426        assert!(packet.adv_data.contains(&AD_TYPE_TX_POWER));
427    }
428
429    #[test]
430    fn test_packet_caching() {
431        let config = DiscoveryConfig::default();
432        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
433
434        // First build
435        let packet1 = advertiser.build_packet();
436        let data1 = packet1.adv_data.clone();
437
438        // Second build should return same data (cached)
439        let packet2 = advertiser.build_packet();
440        assert_eq!(data1, packet2.adv_data);
441
442        // Modify beacon - should invalidate cache
443        advertiser.set_battery(50);
444        let packet3 = advertiser.build_packet();
445        // Data changes because battery is in beacon
446        assert_ne!(data1, packet3.adv_data);
447    }
448
449    #[test]
450    fn test_sequence_increment() {
451        let config = DiscoveryConfig::default();
452        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
453
454        let seq1 = advertiser.beacon().seq_num;
455        advertiser.increment_sequence();
456        let seq2 = advertiser.beacon().seq_num;
457
458        assert_eq!(seq2, seq1 + 1);
459    }
460
461    #[test]
462    fn test_update_beacon_fields() {
463        let config = DiscoveryConfig::default();
464        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
465
466        advertiser.set_hierarchy_level(HierarchyLevel::Squad);
467        assert_eq!(advertiser.beacon().hierarchy_level, HierarchyLevel::Squad);
468
469        advertiser.set_capabilities(capabilities::CAN_RELAY);
470        assert!(advertiser.beacon().can_relay());
471
472        advertiser.set_battery(75);
473        assert_eq!(advertiser.beacon().battery_percent, 75);
474
475        advertiser.set_geohash(0x123456);
476        assert_eq!(advertiser.beacon().geohash, 0x123456);
477    }
478}