#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
#[cfg(feature = "std")]
use std::collections::HashMap;
#[cfg(feature = "std")]
use crate::config::DiscoveryConfig;
use crate::HierarchyLevel;
#[cfg(feature = "std")]
use crate::NodeId;
use super::beacon::{ParsedAdvertisement, PeatBeacon};
#[cfg(feature = "std")]
use super::encrypted_beacon::{BeaconKey, EncryptedBeacon};
#[cfg(feature = "std")]
const DEFAULT_DEVICE_TIMEOUT_MS: u64 = 30_000;
#[cfg(feature = "std")]
const DEDUP_INTERVAL_MS: u64 = 500;
#[derive(Debug, Clone)]
pub struct TrackedDevice {
pub beacon: PeatBeacon,
pub address: String,
pub rssi: i8,
pub rssi_history: Vec<i8>,
pub first_seen_ms: u64,
pub last_seen_ms: u64,
pub estimated_distance: Option<f32>,
pub connectable: bool,
}
impl TrackedDevice {
#[cfg(feature = "std")]
fn new(
beacon: PeatBeacon,
address: String,
rssi: i8,
connectable: bool,
current_time_ms: u64,
) -> Self {
Self {
beacon,
address,
rssi,
rssi_history: vec![rssi],
first_seen_ms: current_time_ms,
last_seen_ms: current_time_ms,
estimated_distance: None,
connectable,
}
}
#[cfg(feature = "std")]
fn update(&mut self, beacon: PeatBeacon, rssi: i8, connectable: bool, current_time_ms: u64) {
self.beacon = beacon;
self.rssi = rssi;
self.last_seen_ms = current_time_ms;
self.connectable = connectable;
self.rssi_history.push(rssi);
if self.rssi_history.len() > 10 {
self.rssi_history.remove(0);
}
}
pub fn average_rssi(&self) -> i8 {
if self.rssi_history.is_empty() {
return self.rssi;
}
let sum: i32 = self.rssi_history.iter().map(|&r| r as i32).sum();
(sum / self.rssi_history.len() as i32) as i8
}
pub fn is_stale(&self, timeout_ms: u64, current_time_ms: u64) -> bool {
current_time_ms.saturating_sub(self.last_seen_ms) > timeout_ms
}
pub fn time_tracked_ms(&self, current_time_ms: u64) -> u64 {
current_time_ms.saturating_sub(self.first_seen_ms)
}
}
#[derive(Debug, Clone, Default)]
pub struct ScanFilter {
pub peat_only: bool,
pub min_hierarchy_level: Option<HierarchyLevel>,
pub required_capabilities: Option<u16>,
pub excluded_capabilities: Option<u16>,
pub min_rssi: Option<i8>,
pub max_distance: Option<f32>,
pub connectable_only: bool,
}
impl ScanFilter {
pub fn peat_nodes() -> Self {
Self {
peat_only: true,
..Default::default()
}
}
pub fn potential_parents(our_level: HierarchyLevel) -> Self {
Self {
peat_only: true,
min_hierarchy_level: Some(our_level),
connectable_only: true,
..Default::default()
}
}
pub fn matches(&self, adv: &ParsedAdvertisement) -> bool {
if self.peat_only && !adv.is_peat_device() {
return false;
}
if let Some(min_rssi) = self.min_rssi {
if adv.rssi < min_rssi {
return false;
}
}
if let Some(max_distance) = self.max_distance {
if let Some(distance) = adv.estimated_distance_meters() {
if distance > max_distance {
return false;
}
}
}
if self.connectable_only && !adv.connectable {
return false;
}
if let Some(ref beacon) = adv.beacon {
if let Some(min_level) = self.min_hierarchy_level {
if beacon.hierarchy_level < min_level {
return false;
}
}
if let Some(required) = self.required_capabilities {
if beacon.capabilities & required != required {
return false;
}
}
if let Some(excluded) = self.excluded_capabilities {
if beacon.capabilities & excluded != 0 {
return false;
}
}
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScannerState {
Idle,
Scanning,
Paused,
}
#[cfg(feature = "std")]
pub struct Scanner {
#[allow(dead_code)]
config: DiscoveryConfig,
state: ScannerState,
devices: HashMap<NodeId, TrackedDevice>,
address_map: HashMap<String, NodeId>,
filter: ScanFilter,
device_timeout_ms: u64,
last_processed: HashMap<NodeId, u64>,
current_time_ms: u64,
beacon_key: Option<BeaconKey>,
mesh_id_bytes: Option<[u8; 4]>,
}
#[cfg(feature = "std")]
impl Scanner {
pub fn new(config: DiscoveryConfig) -> Self {
Self {
config,
state: ScannerState::Idle,
devices: HashMap::new(),
address_map: HashMap::new(),
filter: ScanFilter::default(),
device_timeout_ms: DEFAULT_DEVICE_TIMEOUT_MS,
last_processed: HashMap::new(),
current_time_ms: 0,
beacon_key: None,
mesh_id_bytes: None,
}
}
pub fn set_time_ms(&mut self, time_ms: u64) {
self.current_time_ms = time_ms;
}
pub fn set_filter(&mut self, filter: ScanFilter) {
self.filter = filter;
}
pub fn set_device_timeout_ms(&mut self, timeout_ms: u64) {
self.device_timeout_ms = timeout_ms;
}
pub fn set_beacon_key(&mut self, key: BeaconKey, mesh_id_bytes: [u8; 4]) {
self.beacon_key = Some(key);
self.mesh_id_bytes = Some(mesh_id_bytes);
}
pub fn clear_beacon_key(&mut self) {
self.beacon_key = None;
self.mesh_id_bytes = None;
}
pub fn can_decrypt_beacons(&self) -> bool {
self.beacon_key.is_some() && self.mesh_id_bytes.is_some()
}
pub fn state(&self) -> ScannerState {
self.state
}
pub fn start(&mut self) {
self.state = ScannerState::Scanning;
}
pub fn pause(&mut self) {
self.state = ScannerState::Paused;
}
pub fn stop(&mut self) {
self.state = ScannerState::Idle;
}
pub fn process_advertisement(&mut self, adv: ParsedAdvertisement) -> bool {
if !self.filter.matches(&adv) {
return false;
}
let (beacon, node_id) = if let Some(ref b) = adv.beacon {
(b.clone(), b.node_id)
} else if let Some(ref encrypted_data) = adv.encrypted_service_data {
match self.try_decrypt_beacon(encrypted_data) {
Some((decrypted_beacon, _mesh_id)) => {
let node_id = decrypted_beacon.node_id;
(decrypted_beacon, node_id)
}
None => return false, }
} else {
return false; };
if let Some(&last) = self.last_processed.get(&node_id) {
if self.current_time_ms.saturating_sub(last) < DEDUP_INTERVAL_MS {
return false;
}
}
self.last_processed.insert(node_id, self.current_time_ms);
let is_new = !self.devices.contains_key(&node_id);
if let Some(device) = self.devices.get_mut(&node_id) {
device.update(beacon, adv.rssi, adv.connectable, self.current_time_ms);
} else {
let device = TrackedDevice::new(
beacon,
adv.address.clone(),
adv.rssi,
adv.connectable,
self.current_time_ms,
);
self.devices.insert(node_id, device);
self.address_map.insert(adv.address, node_id);
}
is_new
}
fn try_decrypt_beacon(&self, encrypted_data: &[u8]) -> Option<(PeatBeacon, [u8; 4])> {
let key = self.beacon_key.as_ref()?;
let expected_mesh_id = self.mesh_id_bytes?;
let (encrypted_beacon, mesh_id) = EncryptedBeacon::decrypt(encrypted_data, key)?;
if mesh_id != expected_mesh_id {
return None;
}
let beacon = PeatBeacon {
version: 1,
capabilities: encrypted_beacon.capabilities,
node_id: encrypted_beacon.node_id,
hierarchy_level: HierarchyLevel::from(encrypted_beacon.hierarchy_level),
geohash: 0, battery_percent: encrypted_beacon.battery_percent,
seq_num: 0, };
Some((beacon, mesh_id))
}
pub fn get_device(&self, node_id: &NodeId) -> Option<&TrackedDevice> {
self.devices.get(node_id)
}
pub fn get_node_id_for_address(&self, address: &str) -> Option<&NodeId> {
self.address_map.get(address)
}
pub fn devices(&self) -> impl Iterator<Item = &TrackedDevice> {
self.devices.values()
}
pub fn devices_by_rssi(&self) -> Vec<&TrackedDevice> {
let mut devices: Vec<_> = self.devices.values().collect();
devices.sort_by_key(|d| std::cmp::Reverse(d.rssi));
devices
}
pub fn devices_by_hierarchy(&self) -> Vec<&TrackedDevice> {
let mut devices: Vec<_> = self.devices.values().collect();
devices.sort_by_key(|d| std::cmp::Reverse(d.beacon.hierarchy_level));
devices
}
pub fn device_count(&self) -> usize {
self.devices.len()
}
pub fn remove_stale(&mut self) -> usize {
let timeout = self.device_timeout_ms;
let current_time = self.current_time_ms;
let stale: Vec<NodeId> = self
.devices
.iter()
.filter(|(_, d)| d.is_stale(timeout, current_time))
.map(|(id, _)| *id)
.collect();
let count = stale.len();
for node_id in stale {
if let Some(device) = self.devices.remove(&node_id) {
self.address_map.remove(&device.address);
self.last_processed.remove(&node_id);
}
}
count
}
pub fn clear(&mut self) {
self.devices.clear();
self.address_map.clear();
self.last_processed.clear();
}
pub fn find_best_parent(&self, our_level: HierarchyLevel) -> Option<&TrackedDevice> {
self.devices
.values()
.filter(|d| {
d.beacon.hierarchy_level > our_level && d.connectable && !d.beacon.is_lite_node()
})
.max_by(|a, b| {
match a.beacon.hierarchy_level.cmp(&b.beacon.hierarchy_level) {
core::cmp::Ordering::Equal => {
a.average_rssi().cmp(&b.average_rssi())
}
other => other,
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_adv(node_id: u32, rssi: i8, level: HierarchyLevel) -> ParsedAdvertisement {
let beacon = PeatBeacon::new(NodeId::new(node_id))
.with_hierarchy_level(level)
.with_battery(80);
ParsedAdvertisement {
address: format!("00:11:22:33:44:{:02X}", node_id as u8),
rssi,
beacon: Some(beacon),
encrypted_service_data: None,
local_name: Some(format!("PEAT-{:08X}", node_id)),
tx_power: Some(0),
connectable: true,
}
}
#[test]
fn test_scanner_process_advertisement() {
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(1000);
let adv = make_adv(0x12345678, -60, HierarchyLevel::Platform);
assert!(scanner.process_advertisement(adv));
assert_eq!(scanner.device_count(), 1);
scanner.set_time_ms(1100);
let adv2 = make_adv(0x12345678, -65, HierarchyLevel::Platform);
assert!(!scanner.process_advertisement(adv2));
assert_eq!(scanner.device_count(), 1);
}
#[test]
fn test_scan_filter_peat_only() {
let filter = ScanFilter::peat_nodes();
let peat_adv = make_adv(0x12345678, -60, HierarchyLevel::Platform);
assert!(filter.matches(&peat_adv));
let non_peat = ParsedAdvertisement {
address: "AA:BB:CC:DD:EE:FF".to_string(),
rssi: -50,
beacon: None,
encrypted_service_data: None,
local_name: Some("Other Device".to_string()),
tx_power: None,
connectable: true,
};
assert!(!filter.matches(&non_peat));
}
#[test]
fn test_scan_filter_rssi() {
let filter = ScanFilter {
peat_only: true,
min_rssi: Some(-70),
..Default::default()
};
let strong = make_adv(0x11111111, -60, HierarchyLevel::Platform);
assert!(filter.matches(&strong));
let weak = make_adv(0x22222222, -80, HierarchyLevel::Platform);
assert!(!filter.matches(&weak));
}
#[test]
fn test_find_best_parent() {
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(0);
let squad = make_adv(0x11111111, -60, HierarchyLevel::Squad);
scanner.process_advertisement(squad);
scanner.set_time_ms(501); let platoon = make_adv(0x22222222, -70, HierarchyLevel::Platoon);
scanner.process_advertisement(platoon);
let parent = scanner.find_best_parent(HierarchyLevel::Platform);
assert!(parent.is_some());
assert_eq!(
parent.unwrap().beacon.hierarchy_level,
HierarchyLevel::Platoon
);
}
#[test]
fn test_devices_by_rssi() {
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(0);
scanner.process_advertisement(make_adv(0x11111111, -80, HierarchyLevel::Platform));
scanner.set_time_ms(501);
scanner.process_advertisement(make_adv(0x22222222, -50, HierarchyLevel::Platform));
scanner.set_time_ms(1002);
scanner.process_advertisement(make_adv(0x33333333, -70, HierarchyLevel::Platform));
let sorted = scanner.devices_by_rssi();
assert_eq!(sorted.len(), 3);
assert_eq!(sorted[0].rssi, -50); assert_eq!(sorted[1].rssi, -70);
assert_eq!(sorted[2].rssi, -80);
}
#[test]
fn test_remove_stale() {
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(0);
scanner.process_advertisement(make_adv(0x11111111, -60, HierarchyLevel::Platform));
assert_eq!(scanner.device_count(), 1);
scanner.set_time_ms(35_000);
let removed = scanner.remove_stale();
assert_eq!(removed, 1);
assert_eq!(scanner.device_count(), 0);
}
#[test]
fn test_encrypted_beacon_scanning() {
use crate::discovery::{mesh_id_to_bytes, EncryptedBeacon as EB};
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(0);
let beacon_key = BeaconKey::from_base(&[0x42; 32]);
let mesh_id_bytes = mesh_id_to_bytes("TEST-MESH");
let node_id = NodeId::new(0x12345678);
scanner.set_beacon_key(beacon_key.clone(), mesh_id_bytes);
assert!(scanner.can_decrypt_beacons());
let encrypted_beacon = EB::new(node_id, 0x0F00, u8::from(HierarchyLevel::Squad), 85);
let encrypted_data = encrypted_beacon.encrypt(&beacon_key, &mesh_id_bytes);
let adv = ParsedAdvertisement {
address: "00:11:22:33:44:55".to_string(),
rssi: -60,
beacon: None,
encrypted_service_data: Some(encrypted_data),
local_name: Some("PEAT".to_string()),
tx_power: None,
connectable: true,
};
assert!(scanner.process_advertisement(adv));
assert_eq!(scanner.device_count(), 1);
let device = scanner.get_device(&node_id).unwrap();
assert_eq!(device.beacon.node_id, node_id);
assert_eq!(device.beacon.capabilities, 0x0F00);
assert_eq!(device.beacon.hierarchy_level, HierarchyLevel::Squad);
assert_eq!(device.beacon.battery_percent, 85);
}
#[test]
fn test_encrypted_beacon_wrong_mesh_rejected() {
use crate::discovery::{mesh_id_to_bytes, EncryptedBeacon as EB};
let config = DiscoveryConfig::default();
let mut scanner = Scanner::new(config);
scanner.set_time_ms(0);
let beacon_key = BeaconKey::from_base(&[0x42; 32]);
let our_mesh_id = mesh_id_to_bytes("OUR-MESH");
let other_mesh_id = mesh_id_to_bytes("OTHER-MESH");
let node_id = NodeId::new(0x12345678);
scanner.set_beacon_key(beacon_key.clone(), our_mesh_id);
let encrypted_beacon = EB::new(node_id, 0x0F00, u8::from(HierarchyLevel::Squad), 85);
let encrypted_data = encrypted_beacon.encrypt(&beacon_key, &other_mesh_id);
let adv = ParsedAdvertisement {
address: "00:11:22:33:44:55".to_string(),
rssi: -60,
beacon: None,
encrypted_service_data: Some(encrypted_data),
local_name: Some("PEAT".to_string()),
tx_power: None,
connectable: true,
};
assert!(!scanner.process_advertisement(adv));
assert_eq!(scanner.device_count(), 0);
}
}