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