Skip to main content

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};
27use super::encrypted_beacon::{
28    BeaconKey, EncryptedBeacon, ENCRYPTED_BEACON_SIZE, ENCRYPTED_DEVICE_NAME,
29};
30
31/// Maximum advertising data length for legacy advertising
32const LEGACY_ADV_MAX: usize = 31;
33
34/// Maximum advertising data length for extended advertising
35#[allow(dead_code)]
36const EXTENDED_ADV_MAX: usize = 254;
37
38/// AD Type: Flags
39const AD_TYPE_FLAGS: u8 = 0x01;
40
41/// AD Type: Complete List of 16-bit Service UUIDs
42const AD_TYPE_SERVICE_UUID_16: u8 = 0x03;
43
44/// AD Type: Service Data - 16-bit UUID
45const AD_TYPE_SERVICE_DATA_16: u8 = 0x16;
46
47/// AD Type: Complete Local Name
48const AD_TYPE_LOCAL_NAME: u8 = 0x09;
49
50/// AD Type: Shortened Local Name
51const AD_TYPE_SHORT_NAME: u8 = 0x08;
52
53/// AD Type: TX Power Level
54const AD_TYPE_TX_POWER: u8 = 0x0A;
55
56/// Flags value: LE General Discoverable Mode + BR/EDR Not Supported
57const FLAGS_VALUE: u8 = 0x06;
58
59/// Advertiser state
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum AdvertiserState {
62    /// Not advertising
63    Idle,
64    /// Actively advertising
65    Advertising,
66    /// Temporarily paused (e.g., during connection)
67    Paused,
68}
69
70/// Built advertising packet
71#[derive(Debug, Clone)]
72pub struct AdvertisingPacket {
73    /// Advertising data
74    pub adv_data: Vec<u8>,
75    /// Scan response data (optional)
76    pub scan_rsp: Option<Vec<u8>>,
77    /// Whether this uses extended advertising
78    pub extended: bool,
79}
80
81impl AdvertisingPacket {
82    /// Check if this packet fits in legacy advertising
83    pub fn fits_legacy(&self) -> bool {
84        self.adv_data.len() <= LEGACY_ADV_MAX
85            && self
86                .scan_rsp
87                .as_ref()
88                .is_none_or(|sr| sr.len() <= LEGACY_ADV_MAX)
89    }
90
91    /// Total advertising data size
92    pub fn total_size(&self) -> usize {
93        self.adv_data.len() + self.scan_rsp.as_ref().map_or(0, |sr| sr.len())
94    }
95}
96
97/// Advertising mode
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum AdvertisingMode {
100    /// Plaintext beacons (default) - anyone can read mesh/node IDs
101    #[default]
102    Plaintext,
103    /// Encrypted beacons - only mesh members can read IDs
104    Encrypted,
105}
106
107/// HIVE Beacon Advertiser
108///
109/// Manages building and updating BLE advertisements containing HIVE beacons.
110pub struct Advertiser {
111    /// Configuration (will be used for PHY/power management)
112    #[allow(dead_code)]
113    config: DiscoveryConfig,
114    /// Current beacon
115    beacon: HiveBeacon,
116    /// Current state
117    state: AdvertiserState,
118    /// When advertising started (monotonic ms timestamp)
119    started_at_ms: Option<u64>,
120    /// Current time (monotonic ms, set externally)
121    current_time_ms: u64,
122    /// TX power level to advertise
123    tx_power: Option<i8>,
124    /// Device name to include
125    device_name: Option<String>,
126    /// Use extended advertising if available
127    use_extended: bool,
128    /// Last built packet (cached)
129    cached_packet: Option<AdvertisingPacket>,
130    /// Whether cache is dirty
131    cache_dirty: bool,
132    /// Advertising mode (plaintext or encrypted)
133    mode: AdvertisingMode,
134    /// Beacon encryption key (for encrypted mode)
135    beacon_key: Option<BeaconKey>,
136    /// Mesh ID bytes (for encrypted mode identity)
137    mesh_id_bytes: Option<[u8; 4]>,
138}
139
140impl Advertiser {
141    /// Create a new advertiser with the given configuration and node ID
142    pub fn new(config: DiscoveryConfig, node_id: NodeId) -> Self {
143        let beacon = HiveBeacon::new(node_id);
144        Self {
145            config,
146            beacon,
147            state: AdvertiserState::Idle,
148            started_at_ms: None,
149            current_time_ms: 0,
150            tx_power: None,
151            device_name: None,
152            use_extended: false,
153            cached_packet: None,
154            cache_dirty: true,
155            mode: AdvertisingMode::Plaintext,
156            beacon_key: None,
157            mesh_id_bytes: None,
158        }
159    }
160
161    /// Create an advertiser for a HIVE-Lite node
162    pub fn hive_lite(config: DiscoveryConfig, node_id: NodeId) -> Self {
163        let beacon = HiveBeacon::hive_lite(node_id);
164        Self {
165            config,
166            beacon,
167            state: AdvertiserState::Idle,
168            started_at_ms: None,
169            current_time_ms: 0,
170            tx_power: None,
171            device_name: None,
172            use_extended: false,
173            cached_packet: None,
174            cache_dirty: true,
175            mode: AdvertisingMode::Plaintext,
176            beacon_key: None,
177            mesh_id_bytes: None,
178        }
179    }
180
181    /// Set the current time (call periodically from platform)
182    pub fn set_time_ms(&mut self, time_ms: u64) {
183        self.current_time_ms = time_ms;
184    }
185
186    /// Set TX power level
187    pub fn with_tx_power(mut self, tx_power: i8) -> Self {
188        self.tx_power = Some(tx_power);
189        self.cache_dirty = true;
190        self
191    }
192
193    /// Set device name
194    pub fn with_name(mut self, name: String) -> Self {
195        self.device_name = Some(name);
196        self.cache_dirty = true;
197        self
198    }
199
200    /// Enable extended advertising
201    pub fn with_extended_advertising(mut self, enabled: bool) -> Self {
202        self.use_extended = enabled;
203        self.cache_dirty = true;
204        self
205    }
206
207    /// Enable encrypted advertising mode
208    ///
209    /// In encrypted mode:
210    /// - Beacon identity (mesh_id + node_id) is encrypted
211    /// - Device name becomes generic "HIVE"
212    /// - Only mesh members with the beacon key can identify the node
213    ///
214    /// # Arguments
215    /// * `beacon_key` - Encryption key derived from mesh genesis
216    /// * `mesh_id_bytes` - 4-byte mesh identifier for beacon identity
217    pub fn with_encryption(mut self, beacon_key: BeaconKey, mesh_id_bytes: [u8; 4]) -> Self {
218        self.mode = AdvertisingMode::Encrypted;
219        self.beacon_key = Some(beacon_key);
220        self.mesh_id_bytes = Some(mesh_id_bytes);
221        // In encrypted mode, use generic name for privacy
222        self.device_name = Some(ENCRYPTED_DEVICE_NAME.into());
223        self.cache_dirty = true;
224        self
225    }
226
227    /// Set advertising mode
228    pub fn set_mode(&mut self, mode: AdvertisingMode) {
229        self.mode = mode;
230        self.cache_dirty = true;
231    }
232
233    /// Get current advertising mode
234    pub fn mode(&self) -> AdvertisingMode {
235        self.mode
236    }
237
238    /// Update encryption key (for key rotation)
239    pub fn set_beacon_key(&mut self, key: BeaconKey) {
240        self.beacon_key = Some(key);
241        self.cache_dirty = true;
242    }
243
244    /// Get current state
245    pub fn state(&self) -> AdvertiserState {
246        self.state
247    }
248
249    /// Get the current beacon
250    pub fn beacon(&self) -> &HiveBeacon {
251        &self.beacon
252    }
253
254    /// Get mutable access to the beacon
255    pub fn beacon_mut(&mut self) -> &mut HiveBeacon {
256        self.cache_dirty = true;
257        &mut self.beacon
258    }
259
260    /// Update hierarchy level
261    pub fn set_hierarchy_level(&mut self, level: HierarchyLevel) {
262        self.beacon.hierarchy_level = level;
263        self.cache_dirty = true;
264    }
265
266    /// Update capabilities
267    pub fn set_capabilities(&mut self, caps: u16) {
268        self.beacon.capabilities = caps;
269        self.cache_dirty = true;
270    }
271
272    /// Update battery percentage
273    pub fn set_battery(&mut self, percent: u8) {
274        self.beacon.battery_percent = percent.min(100);
275        self.cache_dirty = true;
276    }
277
278    /// Update geohash
279    pub fn set_geohash(&mut self, geohash: u32) {
280        self.beacon.geohash = geohash & 0x00FFFFFF;
281        self.cache_dirty = true;
282    }
283
284    /// Start advertising
285    pub fn start(&mut self) {
286        self.state = AdvertiserState::Advertising;
287        self.started_at_ms = Some(self.current_time_ms);
288    }
289
290    /// Pause advertising
291    pub fn pause(&mut self) {
292        self.state = AdvertiserState::Paused;
293    }
294
295    /// Resume advertising
296    pub fn resume(&mut self) {
297        if self.state == AdvertiserState::Paused {
298            self.state = AdvertiserState::Advertising;
299        }
300    }
301
302    /// Stop advertising
303    pub fn stop(&mut self) {
304        self.state = AdvertiserState::Idle;
305        self.started_at_ms = None;
306    }
307
308    /// Get duration of current advertising session in milliseconds
309    pub fn advertising_duration_ms(&self) -> Option<u64> {
310        self.started_at_ms
311            .map(|t| self.current_time_ms.saturating_sub(t))
312    }
313
314    /// Increment sequence number and invalidate cache
315    pub fn increment_sequence(&mut self) {
316        self.beacon.increment_seq();
317        self.cache_dirty = true;
318    }
319
320    /// Build the advertising packet
321    ///
322    /// Uses cached packet if available and not dirty.
323    pub fn build_packet(&mut self) -> &AdvertisingPacket {
324        if self.cache_dirty || self.cached_packet.is_none() {
325            let packet = self.build_packet_inner();
326            self.cached_packet = Some(packet);
327            self.cache_dirty = false;
328        }
329        self.cached_packet.as_ref().unwrap()
330    }
331
332    /// Force rebuild of advertising packet
333    pub fn rebuild_packet(&mut self) -> &AdvertisingPacket {
334        self.cache_dirty = true;
335        self.build_packet()
336    }
337
338    /// Internal packet building
339    fn build_packet_inner(&self) -> AdvertisingPacket {
340        let mut adv_data = Vec::with_capacity(31);
341        let mut scan_rsp = Vec::with_capacity(31);
342
343        // Flags (3 bytes)
344        adv_data.push(2); // Length
345        adv_data.push(AD_TYPE_FLAGS);
346        adv_data.push(FLAGS_VALUE);
347
348        // Service UUID (4 bytes for 16-bit UUID)
349        adv_data.push(3); // Length
350        adv_data.push(AD_TYPE_SERVICE_UUID_16);
351        adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
352        adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
353
354        // Service Data with beacon - format depends on mode
355        match self.mode {
356            AdvertisingMode::Plaintext => {
357                // Plaintext: compact beacon (10 bytes)
358                let beacon_data = self.beacon.encode_compact();
359                adv_data.push((2 + BEACON_COMPACT_SIZE) as u8); // Length
360                adv_data.push(AD_TYPE_SERVICE_DATA_16);
361                adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
362                adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
363                adv_data.extend_from_slice(&beacon_data);
364            }
365            AdvertisingMode::Encrypted => {
366                // Encrypted: privacy-preserving beacon (21 bytes)
367                if let (Some(key), Some(mesh_id_bytes)) = (&self.beacon_key, &self.mesh_id_bytes) {
368                    let encrypted_beacon = EncryptedBeacon::new(
369                        self.beacon.node_id,
370                        self.beacon.capabilities,
371                        u8::from(self.beacon.hierarchy_level),
372                        self.beacon.battery_percent,
373                    );
374                    let beacon_data = encrypted_beacon.encrypt(key, mesh_id_bytes);
375                    adv_data.push((2 + ENCRYPTED_BEACON_SIZE) as u8); // Length
376                    adv_data.push(AD_TYPE_SERVICE_DATA_16);
377                    adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
378                    adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
379                    adv_data.extend_from_slice(&beacon_data);
380                } else {
381                    // Fallback to plaintext if encryption not configured
382                    let beacon_data = self.beacon.encode_compact();
383                    adv_data.push((2 + BEACON_COMPACT_SIZE) as u8);
384                    adv_data.push(AD_TYPE_SERVICE_DATA_16);
385                    adv_data.push((HIVE_SERVICE_UUID_16BIT & 0xFF) as u8);
386                    adv_data.push((HIVE_SERVICE_UUID_16BIT >> 8) as u8);
387                    adv_data.extend_from_slice(&beacon_data);
388                }
389            }
390        }
391
392        // TX Power (3 bytes) - add if space permits
393        if let Some(tx_power) = self.tx_power {
394            if adv_data.len() + 3 <= LEGACY_ADV_MAX {
395                adv_data.push(2); // Length
396                adv_data.push(AD_TYPE_TX_POWER);
397                adv_data.push(tx_power as u8);
398            } else {
399                // Put in scan response
400                scan_rsp.push(2);
401                scan_rsp.push(AD_TYPE_TX_POWER);
402                scan_rsp.push(tx_power as u8);
403            }
404        }
405
406        // Device name - prefer scan response
407        if let Some(ref name) = self.device_name {
408            let name_bytes = name.as_bytes();
409            let max_name_len = LEGACY_ADV_MAX - 2; // Room for length and type
410
411            if name_bytes.len() <= max_name_len {
412                // Full name fits
413                scan_rsp.push(name_bytes.len() as u8 + 1);
414                scan_rsp.push(AD_TYPE_LOCAL_NAME);
415                scan_rsp.extend_from_slice(name_bytes);
416            } else {
417                // Shorten name
418                let short_name = &name_bytes[..max_name_len.min(name_bytes.len())];
419                scan_rsp.push(short_name.len() as u8 + 1);
420                scan_rsp.push(AD_TYPE_SHORT_NAME);
421                scan_rsp.extend_from_slice(short_name);
422            }
423        }
424
425        let extended =
426            self.use_extended || adv_data.len() > LEGACY_ADV_MAX || scan_rsp.len() > LEGACY_ADV_MAX;
427
428        AdvertisingPacket {
429            adv_data,
430            scan_rsp: if scan_rsp.is_empty() {
431                None
432            } else {
433                Some(scan_rsp)
434            },
435            extended,
436        }
437    }
438
439    /// Get raw advertising data bytes
440    pub fn advertising_data(&mut self) -> Vec<u8> {
441        self.build_packet().adv_data.clone()
442    }
443
444    /// Get raw scan response bytes
445    pub fn scan_response_data(&mut self) -> Option<Vec<u8>> {
446        self.build_packet().scan_rsp.clone()
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::capabilities;
454
455    #[test]
456    fn test_advertiser_new() {
457        let config = DiscoveryConfig::default();
458        let node_id = NodeId::new(0x12345678);
459        let advertiser = Advertiser::new(config, node_id);
460
461        assert_eq!(advertiser.state(), AdvertiserState::Idle);
462        assert_eq!(advertiser.beacon().node_id, node_id);
463    }
464
465    #[test]
466    fn test_advertiser_hive_lite() {
467        let config = DiscoveryConfig::default();
468        let node_id = NodeId::new(0xCAFEBABE);
469        let advertiser = Advertiser::hive_lite(config, node_id);
470
471        assert!(advertiser.beacon().is_lite_node());
472    }
473
474    #[test]
475    fn test_advertiser_state_transitions() {
476        let config = DiscoveryConfig::default();
477        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
478
479        assert_eq!(advertiser.state(), AdvertiserState::Idle);
480
481        advertiser.set_time_ms(1000);
482        advertiser.start();
483        assert_eq!(advertiser.state(), AdvertiserState::Advertising);
484        advertiser.set_time_ms(2000);
485        assert_eq!(advertiser.advertising_duration_ms(), Some(1000));
486
487        advertiser.pause();
488        assert_eq!(advertiser.state(), AdvertiserState::Paused);
489
490        advertiser.resume();
491        assert_eq!(advertiser.state(), AdvertiserState::Advertising);
492
493        advertiser.stop();
494        assert_eq!(advertiser.state(), AdvertiserState::Idle);
495        assert!(advertiser.advertising_duration_ms().is_none());
496    }
497
498    #[test]
499    fn test_build_packet_fits_legacy() {
500        let config = DiscoveryConfig::default();
501        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
502
503        let packet = advertiser.build_packet();
504        assert!(packet.fits_legacy());
505        assert!(!packet.extended);
506
507        // Should be: Flags(3) + UUID(4) + ServiceData(14) = 21 bytes
508        assert!(packet.adv_data.len() <= LEGACY_ADV_MAX);
509    }
510
511    #[test]
512    fn test_build_packet_with_name() {
513        let config = DiscoveryConfig::default();
514        let mut advertiser =
515            Advertiser::new(config, NodeId::new(0x12345678)).with_name("HIVE-12345678".to_string());
516
517        let packet = advertiser.build_packet();
518        assert!(packet.scan_rsp.is_some());
519
520        let scan_rsp = packet.scan_rsp.as_ref().unwrap();
521        // Should contain the name
522        assert!(scan_rsp.contains(&AD_TYPE_LOCAL_NAME));
523    }
524
525    #[test]
526    fn test_build_packet_with_tx_power() {
527        let config = DiscoveryConfig::default();
528        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678)).with_tx_power(0);
529
530        let packet = advertiser.build_packet();
531
532        // TX power should be in adv_data (we have space)
533        assert!(packet.adv_data.contains(&AD_TYPE_TX_POWER));
534    }
535
536    #[test]
537    fn test_packet_caching() {
538        let config = DiscoveryConfig::default();
539        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
540
541        // First build
542        let packet1 = advertiser.build_packet();
543        let data1 = packet1.adv_data.clone();
544
545        // Second build should return same data (cached)
546        let packet2 = advertiser.build_packet();
547        assert_eq!(data1, packet2.adv_data);
548
549        // Modify beacon - should invalidate cache
550        advertiser.set_battery(50);
551        let packet3 = advertiser.build_packet();
552        // Data changes because battery is in beacon
553        assert_ne!(data1, packet3.adv_data);
554    }
555
556    #[test]
557    fn test_sequence_increment() {
558        let config = DiscoveryConfig::default();
559        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
560
561        let seq1 = advertiser.beacon().seq_num;
562        advertiser.increment_sequence();
563        let seq2 = advertiser.beacon().seq_num;
564
565        assert_eq!(seq2, seq1 + 1);
566    }
567
568    #[test]
569    fn test_update_beacon_fields() {
570        let config = DiscoveryConfig::default();
571        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678));
572
573        advertiser.set_hierarchy_level(HierarchyLevel::Squad);
574        assert_eq!(advertiser.beacon().hierarchy_level, HierarchyLevel::Squad);
575
576        advertiser.set_capabilities(capabilities::CAN_RELAY);
577        assert!(advertiser.beacon().can_relay());
578
579        advertiser.set_battery(75);
580        assert_eq!(advertiser.beacon().battery_percent, 75);
581
582        advertiser.set_geohash(0x123456);
583        assert_eq!(advertiser.beacon().geohash, 0x123456);
584    }
585
586    #[test]
587    fn test_encrypted_advertising() {
588        use crate::discovery::mesh_id_to_bytes;
589
590        let config = DiscoveryConfig::default();
591        let beacon_key = BeaconKey::from_base(&[0x42; 32]);
592        let mesh_id_bytes = mesh_id_to_bytes("TEST-MESH");
593
594        let mut advertiser = Advertiser::new(config, NodeId::new(0x12345678))
595            .with_encryption(beacon_key.clone(), mesh_id_bytes);
596
597        assert_eq!(advertiser.mode(), AdvertisingMode::Encrypted);
598
599        let packet = advertiser.build_packet();
600
601        // Encrypted beacon is larger: Flags(3) + UUID(4) + ServiceData(25) = 32 bytes
602        // This exceeds 31-byte legacy limit, so extended advertising is enabled
603        assert!(packet.extended || packet.adv_data.len() > LEGACY_ADV_MAX);
604
605        // Scan response should have generic "HIVE" name
606        let scan_rsp = packet.scan_rsp.as_ref().unwrap();
607        assert!(scan_rsp.windows(4).any(|w| w == b"HIVE"));
608    }
609
610    #[test]
611    fn test_encrypted_beacon_decrypts() {
612        use crate::discovery::mesh_id_to_bytes;
613
614        let config = DiscoveryConfig::default();
615        let beacon_key = BeaconKey::from_base(&[0x42; 32]);
616        let mesh_id_bytes = mesh_id_to_bytes("TEST-MESH");
617        let node_id = NodeId::new(0x12345678);
618
619        let mut advertiser =
620            Advertiser::new(config, node_id).with_encryption(beacon_key.clone(), mesh_id_bytes);
621
622        advertiser.set_hierarchy_level(HierarchyLevel::Squad);
623        advertiser.set_battery(85);
624        advertiser.set_capabilities(0x0F00);
625
626        let packet = advertiser.build_packet();
627
628        // Find service data in advertising data (after UUID header)
629        // Format: len, type, uuid_lo, uuid_hi, beacon_data...
630        let mut offset = 0;
631        let mut found_beacon = false;
632
633        while offset < packet.adv_data.len() {
634            let len = packet.adv_data[offset] as usize;
635            if offset + 1 + len > packet.adv_data.len() {
636                break;
637            }
638
639            let ad_type = packet.adv_data[offset + 1];
640            if ad_type == AD_TYPE_SERVICE_DATA_16 && len >= 2 + ENCRYPTED_BEACON_SIZE {
641                // Skip len, type, uuid (2 bytes)
642                let beacon_data = &packet.adv_data[offset + 4..offset + 4 + ENCRYPTED_BEACON_SIZE];
643
644                // Decrypt and verify
645                if let Some((decrypted, decrypted_mesh_id)) =
646                    EncryptedBeacon::decrypt(beacon_data, &beacon_key)
647                {
648                    assert_eq!(decrypted.node_id, node_id);
649                    assert_eq!(decrypted.capabilities, 0x0F00);
650                    assert_eq!(decrypted.hierarchy_level, u8::from(HierarchyLevel::Squad));
651                    assert_eq!(decrypted.battery_percent, 85);
652                    assert_eq!(decrypted_mesh_id, mesh_id_bytes);
653                    found_beacon = true;
654                }
655            }
656            offset += 1 + len;
657        }
658
659        assert!(
660            found_beacon,
661            "Encrypted beacon not found in advertising data"
662        );
663    }
664}