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