Skip to main content

hive_btle/
address_rotation.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 address rotation handling
17//!
18//! WearOS and other privacy-focused BLE devices rotate their MAC addresses
19//! periodically. This module provides utilities to identify devices across
20//! address changes using stable identifiers like device names.
21//!
22//! # Example
23//!
24//! ```
25//! use hive_btle::address_rotation::AddressRotationHandler;
26//! use hive_btle::NodeId;
27//!
28//! let mut handler = AddressRotationHandler::new();
29//!
30//! // First discovery
31//! let node_id = NodeId::new(0x12345678);
32//! handler.register_device("WEAROS-ABCD", "AA:BB:CC:DD:EE:01", node_id);
33//!
34//! // Later, same device with rotated address
35//! if let Some(existing) = handler.lookup_by_name("WEAROS-ABCD") {
36//!     // Update the address mapping
37//!     handler.update_address("WEAROS-ABCD", "AA:BB:CC:DD:EE:02");
38//!     println!("Address rotated for node {:?}", existing);
39//! }
40//! ```
41
42use std::collections::HashMap;
43
44use crate::NodeId;
45
46/// Patterns that indicate a device may rotate its BLE address
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DevicePattern {
49    /// WearTAK on WearOS (WT-WEAROS-XXXX)
50    WearTak,
51    /// Generic WearOS device (WEAROS-XXXX)
52    WearOs,
53    /// HIVE mesh device (HIVE_MESH-XXXX or HIVE-XXXX)
54    Hive,
55    /// Unknown pattern (may still rotate addresses)
56    Unknown,
57}
58
59impl DevicePattern {
60    /// Check if this device type is known to rotate addresses
61    pub fn rotates_addresses(&self) -> bool {
62        matches!(self, DevicePattern::WearTak | DevicePattern::WearOs)
63    }
64}
65
66/// Detect the device pattern from a BLE device name
67pub fn detect_device_pattern(name: &str) -> DevicePattern {
68    if name.starts_with("WT-WEAROS-") {
69        DevicePattern::WearTak
70    } else if name.starts_with("WEAROS-") {
71        DevicePattern::WearOs
72    } else if name.starts_with("HIVE_") || name.starts_with("HIVE-") {
73        DevicePattern::Hive
74    } else {
75        DevicePattern::Unknown
76    }
77}
78
79/// Check if a device name matches a WearTAK/WearOS pattern
80pub fn is_weartak_device(name: &str) -> bool {
81    name.starts_with("WT-WEAROS-") || name.starts_with("WEAROS-")
82}
83
84/// Normalize a WearTAK device name
85///
86/// Removes the "WT-" prefix if present to get a consistent "WEAROS-XXXX" format.
87pub fn normalize_weartak_name(name: &str) -> &str {
88    name.strip_prefix("WT-").unwrap_or(name)
89}
90
91/// Result of looking up a device by name
92#[derive(Debug, Clone)]
93pub struct DeviceLookupResult {
94    /// The node ID for this device
95    pub node_id: NodeId,
96    /// The current known address
97    pub current_address: String,
98    /// Whether the address has changed
99    pub address_changed: bool,
100    /// The previous address (if changed)
101    pub previous_address: Option<String>,
102}
103
104/// Handler for BLE address rotation
105///
106/// Maintains mappings between device names and node IDs to handle
107/// address rotation gracefully.
108#[derive(Debug, Default)]
109pub struct AddressRotationHandler {
110    /// Device name to node ID mapping
111    name_to_node: HashMap<String, NodeId>,
112    /// Node ID to device name mapping (reverse lookup)
113    node_to_name: HashMap<NodeId, String>,
114    /// Node ID to current address mapping
115    node_to_address: HashMap<NodeId, String>,
116    /// Address to node ID mapping
117    address_to_node: HashMap<String, NodeId>,
118}
119
120impl AddressRotationHandler {
121    /// Create a new address rotation handler
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Register a new device
127    ///
128    /// Creates mappings for name, address, and node ID.
129    pub fn register_device(&mut self, name: &str, address: &str, node_id: NodeId) {
130        // Register name mapping
131        if !name.is_empty() {
132            self.name_to_node.insert(name.to_string(), node_id);
133            self.node_to_name.insert(node_id, name.to_string());
134        }
135
136        // Register address mapping
137        self.address_to_node.insert(address.to_string(), node_id);
138        self.node_to_address.insert(node_id, address.to_string());
139
140        log::debug!(
141            "Registered device: name='{}' address='{}' node={:?}",
142            name,
143            address,
144            node_id
145        );
146    }
147
148    /// Look up a device by name
149    ///
150    /// Returns the node ID if the name is known.
151    pub fn lookup_by_name(&self, name: &str) -> Option<NodeId> {
152        self.name_to_node.get(name).copied()
153    }
154
155    /// Look up a device by address
156    ///
157    /// Returns the node ID if the address is known.
158    pub fn lookup_by_address(&self, address: &str) -> Option<NodeId> {
159        self.address_to_node.get(address).copied()
160    }
161
162    /// Get the current address for a node
163    pub fn get_address(&self, node_id: &NodeId) -> Option<&String> {
164        self.node_to_address.get(node_id)
165    }
166
167    /// Get the name for a node
168    pub fn get_name(&self, node_id: &NodeId) -> Option<&String> {
169        self.node_to_name.get(node_id)
170    }
171
172    /// Handle a device discovery, detecting address rotation
173    ///
174    /// This is the main entry point for handling discovered devices.
175    /// It checks if we know this device by name and handles address
176    /// rotation automatically.
177    ///
178    /// Returns:
179    /// - `Some(DeviceLookupResult)` if the device was found by name (existing device)
180    /// - `None` if this is a new device
181    pub fn on_device_discovered(
182        &mut self,
183        name: &str,
184        address: &str,
185    ) -> Option<DeviceLookupResult> {
186        // First, try to find by name (handles address rotation)
187        if !name.is_empty() {
188            if let Some(node_id) = self.name_to_node.get(name).copied() {
189                let current_address = self.node_to_address.get(&node_id).cloned();
190                let address_changed = current_address.as_ref() != Some(&address.to_string());
191                let previous_address = if address_changed {
192                    current_address.clone()
193                } else {
194                    None
195                };
196
197                // Update address mapping if changed
198                if address_changed {
199                    self.update_address_internal(node_id, address, current_address.as_deref());
200                }
201
202                return Some(DeviceLookupResult {
203                    node_id,
204                    current_address: address.to_string(),
205                    address_changed,
206                    previous_address,
207                });
208            }
209        }
210
211        // Not found by name, try by address (no rotation)
212        if let Some(node_id) = self.address_to_node.get(address).copied() {
213            return Some(DeviceLookupResult {
214                node_id,
215                current_address: address.to_string(),
216                address_changed: false,
217                previous_address: None,
218            });
219        }
220
221        // New device
222        None
223    }
224
225    /// Update the address for a device (used when address rotation is detected)
226    pub fn update_address(&mut self, name: &str, new_address: &str) -> bool {
227        if let Some(node_id) = self.name_to_node.get(name).copied() {
228            let old_address = self.node_to_address.get(&node_id).cloned();
229            self.update_address_internal(node_id, new_address, old_address.as_deref());
230            true
231        } else {
232            false
233        }
234    }
235
236    /// Internal helper to update address mappings
237    fn update_address_internal(
238        &mut self,
239        node_id: NodeId,
240        new_address: &str,
241        old_address: Option<&str>,
242    ) {
243        // Remove old address mapping
244        if let Some(old) = old_address {
245            self.address_to_node.remove(old);
246            log::info!(
247                "Address rotation detected for {:?}: {} -> {}",
248                node_id,
249                old,
250                new_address
251            );
252        }
253
254        // Add new address mapping
255        self.address_to_node
256            .insert(new_address.to_string(), node_id);
257        self.node_to_address
258            .insert(node_id, new_address.to_string());
259    }
260
261    /// Update the name for a device (e.g., when callsign is received)
262    pub fn update_name(&mut self, node_id: NodeId, new_name: &str) {
263        // Remove old name mapping
264        if let Some(old_name) = self.node_to_name.get(&node_id).cloned() {
265            if old_name != new_name {
266                self.name_to_node.remove(&old_name);
267                log::debug!(
268                    "Name updated for {:?}: '{}' -> '{}'",
269                    node_id,
270                    old_name,
271                    new_name
272                );
273            }
274        }
275
276        // Add new name mapping
277        if !new_name.is_empty() {
278            self.name_to_node.insert(new_name.to_string(), node_id);
279            self.node_to_name.insert(node_id, new_name.to_string());
280        }
281    }
282
283    /// Remove a device from all mappings
284    pub fn remove_device(&mut self, node_id: &NodeId) {
285        // Remove name mappings
286        if let Some(name) = self.node_to_name.remove(node_id) {
287            self.name_to_node.remove(&name);
288        }
289
290        // Remove address mappings
291        if let Some(address) = self.node_to_address.remove(node_id) {
292            self.address_to_node.remove(&address);
293        }
294
295        log::debug!("Removed device {:?} from rotation handler", node_id);
296    }
297
298    /// Clear all mappings
299    pub fn clear(&mut self) {
300        self.name_to_node.clear();
301        self.node_to_name.clear();
302        self.node_to_address.clear();
303        self.address_to_node.clear();
304    }
305
306    /// Get the number of tracked devices
307    pub fn device_count(&self) -> usize {
308        self.node_to_address.len()
309    }
310
311    /// Get statistics about tracked mappings
312    pub fn stats(&self) -> AddressRotationStats {
313        AddressRotationStats {
314            devices_with_names: self.name_to_node.len(),
315            total_devices: self.node_to_address.len(),
316            address_mappings: self.address_to_node.len(),
317        }
318    }
319}
320
321/// Statistics about address rotation handling
322#[derive(Debug, Clone, Copy)]
323pub struct AddressRotationStats {
324    /// Number of devices tracked by name
325    pub devices_with_names: usize,
326    /// Total number of devices tracked
327    pub total_devices: usize,
328    /// Number of address mappings
329    pub address_mappings: usize,
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_device_pattern_detection() {
338        assert_eq!(
339            detect_device_pattern("WT-WEAROS-ABCD"),
340            DevicePattern::WearTak
341        );
342        assert_eq!(detect_device_pattern("WEAROS-1234"), DevicePattern::WearOs);
343        assert_eq!(
344            detect_device_pattern("HIVE_MESH-12345678"),
345            DevicePattern::Hive
346        );
347        assert_eq!(detect_device_pattern("HIVE-12345678"), DevicePattern::Hive);
348        assert_eq!(
349            detect_device_pattern("SomeOtherDevice"),
350            DevicePattern::Unknown
351        );
352    }
353
354    #[test]
355    fn test_weartak_detection() {
356        assert!(is_weartak_device("WT-WEAROS-ABCD"));
357        assert!(is_weartak_device("WEAROS-1234"));
358        assert!(!is_weartak_device("HIVE-12345678"));
359    }
360
361    #[test]
362    fn test_normalize_weartak_name() {
363        assert_eq!(normalize_weartak_name("WT-WEAROS-ABCD"), "WEAROS-ABCD");
364        assert_eq!(normalize_weartak_name("WEAROS-1234"), "WEAROS-1234");
365    }
366
367    #[test]
368    fn test_register_and_lookup() {
369        let mut handler = AddressRotationHandler::new();
370        let node_id = NodeId::new(0x12345678);
371
372        handler.register_device("WEAROS-ABCD", "AA:BB:CC:DD:EE:01", node_id);
373
374        assert_eq!(handler.lookup_by_name("WEAROS-ABCD"), Some(node_id));
375        assert_eq!(
376            handler.lookup_by_address("AA:BB:CC:DD:EE:01"),
377            Some(node_id)
378        );
379        assert_eq!(
380            handler.get_address(&node_id),
381            Some(&"AA:BB:CC:DD:EE:01".to_string())
382        );
383    }
384
385    #[test]
386    fn test_address_rotation_detection() {
387        let mut handler = AddressRotationHandler::new();
388        let node_id = NodeId::new(0x12345678);
389
390        // Initial registration
391        handler.register_device("WEAROS-ABCD", "AA:BB:CC:DD:EE:01", node_id);
392
393        // Simulate address rotation - same name, new address
394        let result = handler
395            .on_device_discovered("WEAROS-ABCD", "AA:BB:CC:DD:EE:02")
396            .unwrap();
397
398        assert_eq!(result.node_id, node_id);
399        assert!(result.address_changed);
400        assert_eq!(
401            result.previous_address,
402            Some("AA:BB:CC:DD:EE:01".to_string())
403        );
404        assert_eq!(result.current_address, "AA:BB:CC:DD:EE:02");
405
406        // Verify mappings updated
407        assert_eq!(
408            handler.lookup_by_address("AA:BB:CC:DD:EE:02"),
409            Some(node_id)
410        );
411        assert_eq!(handler.lookup_by_address("AA:BB:CC:DD:EE:01"), None);
412    }
413
414    #[test]
415    fn test_new_device_discovery() {
416        let mut handler = AddressRotationHandler::new();
417
418        // New device should return None
419        let result = handler.on_device_discovered("WEAROS-NEW", "AA:BB:CC:DD:EE:FF");
420        assert!(result.is_none());
421    }
422
423    #[test]
424    fn test_remove_device() {
425        let mut handler = AddressRotationHandler::new();
426        let node_id = NodeId::new(0x12345678);
427
428        handler.register_device("WEAROS-ABCD", "AA:BB:CC:DD:EE:01", node_id);
429        assert_eq!(handler.device_count(), 1);
430
431        handler.remove_device(&node_id);
432
433        assert_eq!(handler.device_count(), 0);
434        assert!(handler.lookup_by_name("WEAROS-ABCD").is_none());
435        assert!(handler.lookup_by_address("AA:BB:CC:DD:EE:01").is_none());
436    }
437
438    #[test]
439    fn test_update_name() {
440        let mut handler = AddressRotationHandler::new();
441        let node_id = NodeId::new(0x12345678);
442
443        handler.register_device("WEAROS-ABCD", "AA:BB:CC:DD:EE:01", node_id);
444        handler.update_name(node_id, "MyCallsign");
445
446        assert!(handler.lookup_by_name("WEAROS-ABCD").is_none());
447        assert_eq!(handler.lookup_by_name("MyCallsign"), Some(node_id));
448    }
449}