hive_btle/discovery/
scanner.rs

1//! BLE Scanner for discovering HIVE nodes
2//!
3//! Provides filtering, deduplication, and tracking of discovered HIVE beacons.
4
5#[cfg(not(feature = "std"))]
6use alloc::{string::String, vec::Vec};
7#[cfg(feature = "std")]
8use std::collections::HashMap;
9
10#[cfg(feature = "std")]
11use crate::config::DiscoveryConfig;
12use crate::HierarchyLevel;
13#[cfg(feature = "std")]
14use crate::NodeId;
15
16use super::beacon::{HiveBeacon, ParsedAdvertisement};
17
18/// Default timeout for considering a device "stale" (ms)
19#[cfg(feature = "std")]
20const DEFAULT_DEVICE_TIMEOUT_MS: u64 = 30_000;
21
22/// Minimum interval between processing duplicate beacons from same node (ms)
23#[cfg(feature = "std")]
24const DEDUP_INTERVAL_MS: u64 = 500;
25
26/// Tracked device state
27#[derive(Debug, Clone)]
28pub struct TrackedDevice {
29    /// Last received beacon
30    pub beacon: HiveBeacon,
31    /// Device address
32    pub address: String,
33    /// Last RSSI reading
34    pub rssi: i8,
35    /// RSSI history for averaging (last N readings)
36    pub rssi_history: Vec<i8>,
37    /// When first discovered (monotonic ms timestamp)
38    pub first_seen_ms: u64,
39    /// When last beacon received (monotonic ms timestamp)
40    pub last_seen_ms: u64,
41    /// Estimated distance in meters
42    pub estimated_distance: Option<f32>,
43    /// Whether this device is currently connectable
44    pub connectable: bool,
45}
46
47impl TrackedDevice {
48    /// Create a new tracked device
49    #[cfg(feature = "std")]
50    fn new(
51        beacon: HiveBeacon,
52        address: String,
53        rssi: i8,
54        connectable: bool,
55        current_time_ms: u64,
56    ) -> Self {
57        Self {
58            beacon,
59            address,
60            rssi,
61            rssi_history: vec![rssi],
62            first_seen_ms: current_time_ms,
63            last_seen_ms: current_time_ms,
64            estimated_distance: None,
65            connectable,
66        }
67    }
68
69    /// Update with new beacon data
70    #[cfg(feature = "std")]
71    fn update(&mut self, beacon: HiveBeacon, rssi: i8, connectable: bool, current_time_ms: u64) {
72        self.beacon = beacon;
73        self.rssi = rssi;
74        self.last_seen_ms = current_time_ms;
75        self.connectable = connectable;
76
77        // Keep last 10 RSSI readings for averaging
78        self.rssi_history.push(rssi);
79        if self.rssi_history.len() > 10 {
80            self.rssi_history.remove(0);
81        }
82    }
83
84    /// Get average RSSI
85    pub fn average_rssi(&self) -> i8 {
86        if self.rssi_history.is_empty() {
87            return self.rssi;
88        }
89        let sum: i32 = self.rssi_history.iter().map(|&r| r as i32).sum();
90        (sum / self.rssi_history.len() as i32) as i8
91    }
92
93    /// Check if this device is stale (not seen recently)
94    pub fn is_stale(&self, timeout_ms: u64, current_time_ms: u64) -> bool {
95        current_time_ms.saturating_sub(self.last_seen_ms) > timeout_ms
96    }
97
98    /// Get time since first discovery in milliseconds
99    pub fn time_tracked_ms(&self, current_time_ms: u64) -> u64 {
100        current_time_ms.saturating_sub(self.first_seen_ms)
101    }
102}
103
104/// Filter criteria for scanning
105#[derive(Debug, Clone, Default)]
106pub struct ScanFilter {
107    /// Only include HIVE nodes
108    pub hive_only: bool,
109    /// Only include nodes at or above this hierarchy level
110    pub min_hierarchy_level: Option<HierarchyLevel>,
111    /// Only include nodes with these capabilities (bitmask)
112    pub required_capabilities: Option<u16>,
113    /// Exclude nodes with these capabilities
114    pub excluded_capabilities: Option<u16>,
115    /// Minimum RSSI threshold (exclude weaker signals)
116    pub min_rssi: Option<i8>,
117    /// Maximum estimated distance in meters
118    pub max_distance: Option<f32>,
119    /// Only include connectable devices
120    pub connectable_only: bool,
121}
122
123impl ScanFilter {
124    /// Create a filter for HIVE nodes only
125    pub fn hive_nodes() -> Self {
126        Self {
127            hive_only: true,
128            ..Default::default()
129        }
130    }
131
132    /// Create a filter for potential parents (nodes above our level)
133    pub fn potential_parents(our_level: HierarchyLevel) -> Self {
134        Self {
135            hive_only: true,
136            min_hierarchy_level: Some(our_level),
137            connectable_only: true,
138            ..Default::default()
139        }
140    }
141
142    /// Check if a parsed advertisement passes this filter
143    pub fn matches(&self, adv: &ParsedAdvertisement) -> bool {
144        // HIVE-only filter
145        if self.hive_only && !adv.is_hive_device() {
146            return false;
147        }
148
149        // RSSI filter
150        if let Some(min_rssi) = self.min_rssi {
151            if adv.rssi < min_rssi {
152                return false;
153            }
154        }
155
156        // Distance filter
157        if let Some(max_distance) = self.max_distance {
158            if let Some(distance) = adv.estimated_distance_meters() {
159                if distance > max_distance {
160                    return false;
161                }
162            }
163        }
164
165        // Connectable filter
166        if self.connectable_only && !adv.connectable {
167            return false;
168        }
169
170        // Beacon-specific filters
171        if let Some(ref beacon) = adv.beacon {
172            // Hierarchy level filter
173            if let Some(min_level) = self.min_hierarchy_level {
174                if beacon.hierarchy_level < min_level {
175                    return false;
176                }
177            }
178
179            // Required capabilities
180            if let Some(required) = self.required_capabilities {
181                if beacon.capabilities & required != required {
182                    return false;
183                }
184            }
185
186            // Excluded capabilities
187            if let Some(excluded) = self.excluded_capabilities {
188                if beacon.capabilities & excluded != 0 {
189                    return false;
190                }
191            }
192        }
193
194        true
195    }
196}
197
198/// Scanner state machine
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum ScannerState {
201    /// Not scanning
202    Idle,
203    /// Actively scanning
204    Scanning,
205    /// Paused (e.g., during connection)
206    Paused,
207}
208
209/// BLE Scanner for discovering HIVE nodes
210///
211/// Handles beacon reception, filtering, deduplication, and device tracking.
212///
213/// Note: This type requires the `std` feature for full functionality.
214#[cfg(feature = "std")]
215pub struct Scanner {
216    /// Scanner configuration (will be used for PHY/power management)
217    #[allow(dead_code)]
218    config: DiscoveryConfig,
219    /// Current state
220    state: ScannerState,
221    /// Tracked devices by node ID
222    devices: HashMap<NodeId, TrackedDevice>,
223    /// Address to node ID mapping (for devices without parsed beacon)
224    address_map: HashMap<String, NodeId>,
225    /// Filter criteria
226    filter: ScanFilter,
227    /// Device timeout (ms)
228    device_timeout_ms: u64,
229    /// Last dedup timestamps per node (ms)
230    last_processed: HashMap<NodeId, u64>,
231    /// Current time (monotonic ms, set externally)
232    current_time_ms: u64,
233}
234
235#[cfg(feature = "std")]
236impl Scanner {
237    /// Create a new scanner with default settings
238    pub fn new(config: DiscoveryConfig) -> Self {
239        Self {
240            config,
241            state: ScannerState::Idle,
242            devices: HashMap::new(),
243            address_map: HashMap::new(),
244            filter: ScanFilter::default(),
245            device_timeout_ms: DEFAULT_DEVICE_TIMEOUT_MS,
246            last_processed: HashMap::new(),
247            current_time_ms: 0,
248        }
249    }
250
251    /// Set the current time (call periodically from platform)
252    pub fn set_time_ms(&mut self, time_ms: u64) {
253        self.current_time_ms = time_ms;
254    }
255
256    /// Set the scan filter
257    pub fn set_filter(&mut self, filter: ScanFilter) {
258        self.filter = filter;
259    }
260
261    /// Set device timeout in milliseconds
262    pub fn set_device_timeout_ms(&mut self, timeout_ms: u64) {
263        self.device_timeout_ms = timeout_ms;
264    }
265
266    /// Get current state
267    pub fn state(&self) -> ScannerState {
268        self.state
269    }
270
271    /// Start scanning
272    pub fn start(&mut self) {
273        self.state = ScannerState::Scanning;
274    }
275
276    /// Pause scanning
277    pub fn pause(&mut self) {
278        self.state = ScannerState::Paused;
279    }
280
281    /// Stop scanning
282    pub fn stop(&mut self) {
283        self.state = ScannerState::Idle;
284    }
285
286    /// Process a received advertisement
287    ///
288    /// Returns true if this is a new or updated device that passes the filter.
289    pub fn process_advertisement(&mut self, adv: ParsedAdvertisement) -> bool {
290        // Apply filter
291        if !self.filter.matches(&adv) {
292            return false;
293        }
294
295        // Extract beacon and node ID
296        let (beacon, node_id) = match adv.beacon {
297            Some(ref b) => (b.clone(), b.node_id),
298            None => return false, // No beacon = not a HIVE device
299        };
300
301        // Check deduplication
302        if let Some(&last) = self.last_processed.get(&node_id) {
303            if self.current_time_ms.saturating_sub(last) < DEDUP_INTERVAL_MS {
304                return false;
305            }
306        }
307        self.last_processed.insert(node_id, self.current_time_ms);
308
309        // Update or create tracked device
310        let is_new = !self.devices.contains_key(&node_id);
311
312        if let Some(device) = self.devices.get_mut(&node_id) {
313            // Update existing device
314            device.update(beacon, adv.rssi, adv.connectable, self.current_time_ms);
315        } else {
316            // New device
317            let device = TrackedDevice::new(
318                beacon,
319                adv.address.clone(),
320                adv.rssi,
321                adv.connectable,
322                self.current_time_ms,
323            );
324            self.devices.insert(node_id, device);
325            self.address_map.insert(adv.address, node_id);
326        }
327
328        is_new
329    }
330
331    /// Get a tracked device by node ID
332    pub fn get_device(&self, node_id: &NodeId) -> Option<&TrackedDevice> {
333        self.devices.get(node_id)
334    }
335
336    /// Get node ID for an address
337    pub fn get_node_id_for_address(&self, address: &str) -> Option<&NodeId> {
338        self.address_map.get(address)
339    }
340
341    /// Get all tracked devices
342    pub fn devices(&self) -> impl Iterator<Item = &TrackedDevice> {
343        self.devices.values()
344    }
345
346    /// Get devices sorted by RSSI (strongest first)
347    pub fn devices_by_rssi(&self) -> Vec<&TrackedDevice> {
348        let mut devices: Vec<_> = self.devices.values().collect();
349        devices.sort_by(|a, b| b.rssi.cmp(&a.rssi));
350        devices
351    }
352
353    /// Get devices sorted by hierarchy level (highest first)
354    pub fn devices_by_hierarchy(&self) -> Vec<&TrackedDevice> {
355        let mut devices: Vec<_> = self.devices.values().collect();
356        devices.sort_by(|a, b| b.beacon.hierarchy_level.cmp(&a.beacon.hierarchy_level));
357        devices
358    }
359
360    /// Get count of tracked devices
361    pub fn device_count(&self) -> usize {
362        self.devices.len()
363    }
364
365    /// Remove stale devices
366    ///
367    /// Returns the number of devices removed.
368    pub fn remove_stale(&mut self) -> usize {
369        let timeout = self.device_timeout_ms;
370        let current_time = self.current_time_ms;
371        let stale: Vec<NodeId> = self
372            .devices
373            .iter()
374            .filter(|(_, d)| d.is_stale(timeout, current_time))
375            .map(|(id, _)| *id)
376            .collect();
377
378        let count = stale.len();
379        for node_id in stale {
380            if let Some(device) = self.devices.remove(&node_id) {
381                self.address_map.remove(&device.address);
382                self.last_processed.remove(&node_id);
383            }
384        }
385
386        count
387    }
388
389    /// Clear all tracked devices
390    pub fn clear(&mut self) {
391        self.devices.clear();
392        self.address_map.clear();
393        self.last_processed.clear();
394    }
395
396    /// Find the best parent candidate
397    ///
398    /// Selects based on hierarchy level (prefer higher) and RSSI (prefer stronger).
399    pub fn find_best_parent(&self, our_level: HierarchyLevel) -> Option<&TrackedDevice> {
400        self.devices
401            .values()
402            .filter(|d| {
403                d.beacon.hierarchy_level > our_level && d.connectable && !d.beacon.is_lite_node()
404            })
405            .max_by(|a, b| {
406                // First compare hierarchy level
407                match a.beacon.hierarchy_level.cmp(&b.beacon.hierarchy_level) {
408                    core::cmp::Ordering::Equal => {
409                        // Then compare RSSI
410                        a.average_rssi().cmp(&b.average_rssi())
411                    }
412                    other => other,
413                }
414            })
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn make_adv(node_id: u32, rssi: i8, level: HierarchyLevel) -> ParsedAdvertisement {
423        let beacon = HiveBeacon::new(NodeId::new(node_id))
424            .with_hierarchy_level(level)
425            .with_battery(80);
426
427        ParsedAdvertisement {
428            address: format!("00:11:22:33:44:{:02X}", node_id as u8),
429            rssi,
430            beacon: Some(beacon),
431            local_name: Some(format!("HIVE-{:08X}", node_id)),
432            tx_power: Some(0),
433            connectable: true,
434        }
435    }
436
437    #[test]
438    fn test_scanner_process_advertisement() {
439        let config = DiscoveryConfig::default();
440        let mut scanner = Scanner::new(config);
441        scanner.set_time_ms(1000);
442
443        let adv = make_adv(0x12345678, -60, HierarchyLevel::Platform);
444        assert!(scanner.process_advertisement(adv));
445        assert_eq!(scanner.device_count(), 1);
446
447        // Duplicate within dedup interval should be ignored
448        scanner.set_time_ms(1100);
449        let adv2 = make_adv(0x12345678, -65, HierarchyLevel::Platform);
450        assert!(!scanner.process_advertisement(adv2));
451        assert_eq!(scanner.device_count(), 1);
452    }
453
454    #[test]
455    fn test_scan_filter_hive_only() {
456        let filter = ScanFilter::hive_nodes();
457
458        let hive_adv = make_adv(0x12345678, -60, HierarchyLevel::Platform);
459        assert!(filter.matches(&hive_adv));
460
461        let non_hive = ParsedAdvertisement {
462            address: "AA:BB:CC:DD:EE:FF".to_string(),
463            rssi: -50,
464            beacon: None,
465            local_name: Some("Other Device".to_string()),
466            tx_power: None,
467            connectable: true,
468        };
469        assert!(!filter.matches(&non_hive));
470    }
471
472    #[test]
473    fn test_scan_filter_rssi() {
474        let filter = ScanFilter {
475            hive_only: true,
476            min_rssi: Some(-70),
477            ..Default::default()
478        };
479
480        let strong = make_adv(0x11111111, -60, HierarchyLevel::Platform);
481        assert!(filter.matches(&strong));
482
483        let weak = make_adv(0x22222222, -80, HierarchyLevel::Platform);
484        assert!(!filter.matches(&weak));
485    }
486
487    #[test]
488    fn test_find_best_parent() {
489        let config = DiscoveryConfig::default();
490        let mut scanner = Scanner::new(config);
491        scanner.set_time_ms(0);
492
493        // Add a squad leader
494        let squad = make_adv(0x11111111, -60, HierarchyLevel::Squad);
495        scanner.process_advertisement(squad);
496
497        // Add a platoon leader (higher hierarchy)
498        scanner.set_time_ms(501); // Avoid dedup
499        let platoon = make_adv(0x22222222, -70, HierarchyLevel::Platoon);
500        scanner.process_advertisement(platoon);
501
502        // Find parent for platform node
503        let parent = scanner.find_best_parent(HierarchyLevel::Platform);
504        assert!(parent.is_some());
505        // Should prefer platoon (higher hierarchy) despite weaker signal
506        assert_eq!(
507            parent.unwrap().beacon.hierarchy_level,
508            HierarchyLevel::Platoon
509        );
510    }
511
512    #[test]
513    fn test_devices_by_rssi() {
514        let config = DiscoveryConfig::default();
515        let mut scanner = Scanner::new(config);
516        scanner.set_time_ms(0);
517
518        scanner.process_advertisement(make_adv(0x11111111, -80, HierarchyLevel::Platform));
519        scanner.set_time_ms(501);
520        scanner.process_advertisement(make_adv(0x22222222, -50, HierarchyLevel::Platform));
521        scanner.set_time_ms(1002);
522        scanner.process_advertisement(make_adv(0x33333333, -70, HierarchyLevel::Platform));
523
524        let sorted = scanner.devices_by_rssi();
525        assert_eq!(sorted.len(), 3);
526        assert_eq!(sorted[0].rssi, -50); // Strongest first
527        assert_eq!(sorted[1].rssi, -70);
528        assert_eq!(sorted[2].rssi, -80);
529    }
530
531    #[test]
532    fn test_remove_stale() {
533        let config = DiscoveryConfig::default();
534        let mut scanner = Scanner::new(config);
535        scanner.set_time_ms(0);
536
537        scanner.process_advertisement(make_adv(0x11111111, -60, HierarchyLevel::Platform));
538        assert_eq!(scanner.device_count(), 1);
539
540        // Fast forward past timeout
541        scanner.set_time_ms(35_000);
542        let removed = scanner.remove_stale();
543        assert_eq!(removed, 1);
544        assert_eq!(scanner.device_count(), 0);
545    }
546}