Skip to main content

aranet_core/
platform.rs

1//! Platform-specific Bluetooth configuration and tuning.
2//!
3//! This module provides platform-aware defaults for BLE operations,
4//! accounting for differences between operating systems and BLE stacks.
5//!
6//! # Platform Differences
7//!
8//! | Platform | BLE Stack | Device ID Format | Notes |
9//! |----------|-----------|------------------|-------|
10//! | macOS | CoreBluetooth | UUID | Longer scan times needed (ads ~4s apart) |
11//! | Linux | BlueZ | MAC Address | May need longer connection timeouts |
12//! | Windows | WinRT | MAC Address | Generally reliable defaults |
13//!
14//! # macOS UUID Behavior
15//!
16//! On macOS, CoreBluetooth does **not** expose Bluetooth MAC addresses. Instead, it assigns
17//! a UUID to each discovered device. This has important implications:
18//!
19//! ## UUID Stability
20//!
21//! - **Same Mac, same device**: The UUID is stable for a given device on a given Mac.
22//!   You can reconnect to the same device using the UUID.
23//!
24//! - **Different Macs**: Each Mac assigns a **different** UUID to the same physical device.
25//!   The UUID `A1B2C3D4-...` on Mac A will be different from the UUID assigned on Mac B.
26//!
27//! - **Bluetooth reset**: The UUID may change if you reset Bluetooth settings or unpair
28//!   all devices. This is rare but can happen.
29//!
30//! ## Cross-Platform Considerations
31//!
32//! For applications that need to identify devices across platforms or machines:
33//!
34//! 1. **Use device names**: Device names (e.g., "Aranet4 12345") are consistent across
35//!    platforms and machines. However, names can be changed by users.
36//!
37//! 2. **Use serial numbers**: Each device has a unique serial number accessible via
38//!    `device.read_device_info().serial`. This is the most reliable cross-platform ID.
39//!
40//! 3. **Use the aliasing system**: Create user-friendly aliases (e.g., "Living Room")
41//!    that map to the appropriate platform-specific identifier.
42//!
43//! ## Example: Cross-Platform Device Storage
44//!
45//! ```ignore
46//! use aranet_core::platform::{DeviceAlias, AliasStore};
47//!
48//! // Create an alias that works across platforms
49//! let alias = DeviceAlias::new("Living Room CO2 Sensor")
50//!     .with_serial("123456")                    // Primary: serial number
51//!     .with_name("Aranet4 12345")               // Fallback: device name
52//!     .with_mac("AA:BB:CC:DD:EE:FF")            // Linux/Windows: MAC address
53//!     .with_uuid("A1B2C3D4-E5F6-...");          // macOS: CoreBluetooth UUID
54//!
55//! // Resolve the alias on the current platform
56//! let identifier = alias.resolve();  // Returns appropriate ID for this platform
57//! ```
58//!
59//! # Usage
60//!
61//! ```ignore
62//! use aranet_core::platform::{PlatformConfig, current_platform};
63//!
64//! let config = PlatformConfig::for_current_platform();
65//! let scan_options = ScanOptions::default()
66//!     .duration(config.recommended_scan_duration);
67//! ```
68
69use std::collections::HashMap;
70use std::sync::RwLock;
71use std::time::Duration;
72
73use serde::{Deserialize, Serialize};
74
75/// Platform identifier.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Platform {
78    /// macOS with CoreBluetooth
79    MacOS,
80    /// Linux with BlueZ
81    Linux,
82    /// Windows with WinRT
83    Windows,
84    /// Unknown or unsupported platform
85    Unknown,
86}
87
88impl Platform {
89    /// Detect the current platform.
90    pub fn current() -> Self {
91        #[cfg(target_os = "macos")]
92        {
93            Platform::MacOS
94        }
95        #[cfg(target_os = "linux")]
96        {
97            Platform::Linux
98        }
99        #[cfg(target_os = "windows")]
100        {
101            Platform::Windows
102        }
103        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
104        {
105            Platform::Unknown
106        }
107    }
108}
109
110/// Platform-specific BLE configuration.
111#[derive(Debug, Clone)]
112pub struct PlatformConfig {
113    /// The platform this configuration is for.
114    pub platform: Platform,
115
116    /// Recommended scan duration for device discovery.
117    ///
118    /// - macOS: Longer (8s) because advertisements can be 4+ seconds apart
119    /// - Linux: Medium (5s) with BlueZ
120    /// - Windows: Medium (5s)
121    pub recommended_scan_duration: Duration,
122
123    /// Minimum scan duration for quick scans.
124    pub minimum_scan_duration: Duration,
125
126    /// Recommended connection timeout.
127    ///
128    /// - macOS: Shorter (10s) as CoreBluetooth is generally faster
129    /// - Linux: Longer (15s) as BlueZ may have overhead
130    /// - Windows: Medium (12s)
131    pub recommended_connection_timeout: Duration,
132
133    /// Recommended read/write operation timeout.
134    pub recommended_operation_timeout: Duration,
135
136    /// Delay between consecutive BLE operations to avoid overwhelming the stack.
137    pub operation_delay: Duration,
138
139    /// Whether the platform exposes MAC addresses (false on macOS).
140    pub exposes_mac_address: bool,
141
142    /// Recommended number of scan retries.
143    pub recommended_scan_retries: u32,
144
145    /// Recommended delay between scan retries.
146    pub scan_retry_delay: Duration,
147
148    /// Maximum recommended concurrent connections.
149    ///
150    /// Most BLE adapters support 5-7 concurrent connections.
151    pub max_concurrent_connections: usize,
152}
153
154impl PlatformConfig {
155    /// Get the configuration for the current platform.
156    pub fn for_current_platform() -> Self {
157        Self::for_platform(Platform::current())
158    }
159
160    /// Get the configuration for a specific platform.
161    pub fn for_platform(platform: Platform) -> Self {
162        match platform {
163            Platform::MacOS => Self::macos(),
164            Platform::Linux => Self::linux(),
165            Platform::Windows => Self::windows(),
166            Platform::Unknown => Self::default(),
167        }
168    }
169
170    /// Configuration optimized for macOS with CoreBluetooth.
171    pub fn macos() -> Self {
172        Self {
173            platform: Platform::MacOS,
174            // Aranet devices advertise every ~4 seconds, need longer scans
175            recommended_scan_duration: Duration::from_secs(8),
176            minimum_scan_duration: Duration::from_secs(5),
177            // CoreBluetooth is generally efficient
178            recommended_connection_timeout: Duration::from_secs(10),
179            recommended_operation_timeout: Duration::from_secs(8),
180            // CoreBluetooth handles queuing well
181            operation_delay: Duration::from_millis(20),
182            // macOS uses UUIDs instead of MAC addresses
183            exposes_mac_address: false,
184            recommended_scan_retries: 3,
185            scan_retry_delay: Duration::from_millis(500),
186            // CoreBluetooth typically supports ~5 connections
187            max_concurrent_connections: 5,
188        }
189    }
190
191    /// Configuration optimized for Linux with BlueZ.
192    pub fn linux() -> Self {
193        Self {
194            platform: Platform::Linux,
195            // BlueZ can scan faster
196            recommended_scan_duration: Duration::from_secs(5),
197            minimum_scan_duration: Duration::from_secs(3),
198            // BlueZ may have more overhead
199            recommended_connection_timeout: Duration::from_secs(15),
200            recommended_operation_timeout: Duration::from_secs(10),
201            // BlueZ benefits from slightly longer delays
202            operation_delay: Duration::from_millis(30),
203            // Linux exposes MAC addresses
204            exposes_mac_address: true,
205            recommended_scan_retries: 3,
206            scan_retry_delay: Duration::from_millis(500),
207            // Linux adapters typically support ~7 connections
208            max_concurrent_connections: 7,
209        }
210    }
211
212    /// Configuration optimized for Windows with WinRT.
213    pub fn windows() -> Self {
214        Self {
215            platform: Platform::Windows,
216            recommended_scan_duration: Duration::from_secs(5),
217            minimum_scan_duration: Duration::from_secs(3),
218            recommended_connection_timeout: Duration::from_secs(12),
219            recommended_operation_timeout: Duration::from_secs(10),
220            operation_delay: Duration::from_millis(25),
221            // Windows exposes MAC addresses
222            exposes_mac_address: true,
223            recommended_scan_retries: 3,
224            scan_retry_delay: Duration::from_millis(500),
225            // Windows adapters typically support ~5-6 connections
226            max_concurrent_connections: 5,
227        }
228    }
229}
230
231impl Default for PlatformConfig {
232    /// Default configuration that works reasonably on all platforms.
233    fn default() -> Self {
234        Self {
235            platform: Platform::Unknown,
236            recommended_scan_duration: Duration::from_secs(6),
237            minimum_scan_duration: Duration::from_secs(4),
238            recommended_connection_timeout: Duration::from_secs(15),
239            recommended_operation_timeout: Duration::from_secs(10),
240            operation_delay: Duration::from_millis(30),
241            exposes_mac_address: true,
242            recommended_scan_retries: 3,
243            scan_retry_delay: Duration::from_millis(500),
244            max_concurrent_connections: 5,
245        }
246    }
247}
248
249/// Get the current platform.
250pub fn current_platform() -> Platform {
251    Platform::current()
252}
253
254/// Get platform-specific configuration for the current platform.
255pub fn platform_config() -> PlatformConfig {
256    PlatformConfig::for_current_platform()
257}
258
259// ==================== Device Aliasing System ====================
260
261/// A cross-platform device alias that can store multiple identifiers.
262///
263/// This allows identifying the same physical device across different platforms
264/// and machines, where the identifier format varies.
265///
266/// # Example
267///
268/// ```
269/// use aranet_core::platform::DeviceAlias;
270///
271/// let alias = DeviceAlias::new("Living Room")
272///     .with_serial("SN123456")
273///     .with_name("Aranet4 12345")
274///     .with_mac("AA:BB:CC:DD:EE:FF");
275///
276/// // Get the best identifier for the current platform
277/// let id = alias.resolve();
278/// ```
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct DeviceAlias {
281    /// User-friendly name for this device.
282    pub alias: String,
283    /// Device serial number (most reliable cross-platform ID).
284    pub serial: Option<String>,
285    /// Device name (e.g., "Aranet4 12345").
286    pub name: Option<String>,
287    /// Bluetooth MAC address (Linux/Windows).
288    pub mac_address: Option<String>,
289    /// CoreBluetooth UUID (macOS only).
290    pub macos_uuid: Option<String>,
291    /// Notes or description for this device.
292    pub notes: Option<String>,
293    /// When this alias was created.
294    pub created_at: Option<String>,
295    /// When this alias was last updated.
296    pub updated_at: Option<String>,
297}
298
299impl DeviceAlias {
300    /// Create a new device alias with the given user-friendly name.
301    pub fn new(alias: impl Into<String>) -> Self {
302        let now = time::OffsetDateTime::now_utc()
303            .format(&time::format_description::well_known::Rfc3339)
304            .ok();
305
306        Self {
307            alias: alias.into(),
308            serial: None,
309            name: None,
310            mac_address: None,
311            macos_uuid: None,
312            notes: None,
313            created_at: now.clone(),
314            updated_at: now,
315        }
316    }
317
318    /// Set the device serial number.
319    #[must_use]
320    pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
321        self.serial = Some(serial.into());
322        self
323    }
324
325    /// Set the device name.
326    #[must_use]
327    pub fn with_name(mut self, name: impl Into<String>) -> Self {
328        self.name = Some(name.into());
329        self
330    }
331
332    /// Set the MAC address (for Linux/Windows).
333    #[must_use]
334    pub fn with_mac(mut self, mac: impl Into<String>) -> Self {
335        self.mac_address = Some(mac.into());
336        self
337    }
338
339    /// Set the macOS UUID.
340    #[must_use]
341    pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
342        self.macos_uuid = Some(uuid.into());
343        self
344    }
345
346    /// Set notes for this device.
347    #[must_use]
348    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
349        self.notes = Some(notes.into());
350        self
351    }
352
353    /// Resolve the alias to a platform-appropriate identifier.
354    ///
355    /// Resolution order:
356    /// 1. On macOS: macos_uuid → name → serial
357    /// 2. On Linux/Windows: mac_address → name → serial
358    ///
359    /// Returns `None` if no suitable identifier is available.
360    pub fn resolve(&self) -> Option<String> {
361        let platform = Platform::current();
362
363        match platform {
364            Platform::MacOS => {
365                // On macOS, prefer UUID, then name, then serial
366                self.macos_uuid
367                    .clone()
368                    .or_else(|| self.name.clone())
369                    .or_else(|| self.serial.clone())
370            }
371            Platform::Linux | Platform::Windows => {
372                // On Linux/Windows, prefer MAC address, then name, then serial
373                self.mac_address
374                    .clone()
375                    .or_else(|| self.name.clone())
376                    .or_else(|| self.serial.clone())
377            }
378            Platform::Unknown => {
379                // Fall back to name or serial
380                self.name.clone().or_else(|| self.serial.clone())
381            }
382        }
383    }
384
385    /// Check if this alias matches a given identifier.
386    ///
387    /// This checks against all stored identifiers (serial, name, MAC, UUID).
388    pub fn matches(&self, identifier: &str) -> bool {
389        self.serial.as_deref() == Some(identifier)
390            || self.name.as_deref() == Some(identifier)
391            || self.mac_address.as_deref() == Some(identifier)
392            || self.macos_uuid.as_deref() == Some(identifier)
393    }
394
395    /// Update the platform-specific identifier.
396    ///
397    /// Call this after connecting to a device to update the alias with
398    /// the current platform's identifier.
399    pub fn update_identifier(&mut self, identifier: &str) {
400        let platform = Platform::current();
401        match platform {
402            Platform::MacOS => {
403                // On macOS, the identifier is a UUID
404                self.macos_uuid = Some(identifier.to_string());
405            }
406            Platform::Linux | Platform::Windows => {
407                // On Linux/Windows, the identifier is a MAC address
408                // (unless it looks like a UUID)
409                if identifier.contains('-') && identifier.len() > 20 {
410                    // Looks like a UUID, might be running on macOS
411                    self.macos_uuid = Some(identifier.to_string());
412                } else {
413                    self.mac_address = Some(identifier.to_string());
414                }
415            }
416            Platform::Unknown => {
417                // Store as name if we can't determine the platform
418                self.name = Some(identifier.to_string());
419            }
420        }
421
422        self.updated_at = time::OffsetDateTime::now_utc()
423            .format(&time::format_description::well_known::Rfc3339)
424            .ok();
425    }
426}
427
428/// An in-memory store for device aliases.
429///
430/// This provides a simple way to manage device aliases at runtime.
431/// For persistent storage, serialize the aliases to a file.
432///
433/// # Thread Safety
434///
435/// This store is thread-safe and can be shared across tasks.
436///
437/// # Example
438///
439/// ```
440/// use aranet_core::platform::{AliasStore, DeviceAlias};
441///
442/// let store = AliasStore::new();
443///
444/// // Add an alias
445/// let alias = DeviceAlias::new("Kitchen")
446///     .with_name("Aranet4 12345");
447/// store.add(alias);
448///
449/// // Find by alias name
450/// if let Some(alias) = store.get("Kitchen") {
451///     println!("Found: {:?}", alias.resolve());
452/// }
453/// ```
454#[derive(Debug, Default)]
455pub struct AliasStore {
456    aliases: RwLock<HashMap<String, DeviceAlias>>,
457}
458
459impl AliasStore {
460    /// Create a new empty alias store.
461    pub fn new() -> Self {
462        Self {
463            aliases: RwLock::new(HashMap::new()),
464        }
465    }
466
467    /// Add or update an alias in the store.
468    pub fn add(&self, alias: DeviceAlias) {
469        let mut aliases = self
470            .aliases
471            .write()
472            .expect("alias store lock poisoned - a thread panicked while holding the lock");
473        aliases.insert(alias.alias.clone(), alias);
474    }
475
476    /// Get an alias by its user-friendly name.
477    pub fn get(&self, alias_name: &str) -> Option<DeviceAlias> {
478        let aliases = self
479            .aliases
480            .read()
481            .expect("alias store lock poisoned - a thread panicked while holding the lock");
482        aliases.get(alias_name).cloned()
483    }
484
485    /// Remove an alias by name.
486    pub fn remove(&self, alias_name: &str) -> Option<DeviceAlias> {
487        let mut aliases = self
488            .aliases
489            .write()
490            .expect("alias store lock poisoned - a thread panicked while holding the lock");
491        aliases.remove(alias_name)
492    }
493
494    /// Find an alias by any of its identifiers.
495    pub fn find_by_identifier(&self, identifier: &str) -> Option<DeviceAlias> {
496        let aliases = self
497            .aliases
498            .read()
499            .expect("alias store lock poisoned - a thread panicked while holding the lock");
500        aliases.values().find(|a| a.matches(identifier)).cloned()
501    }
502
503    /// Get all aliases.
504    pub fn all(&self) -> Vec<DeviceAlias> {
505        let aliases = self
506            .aliases
507            .read()
508            .expect("alias store lock poisoned - a thread panicked while holding the lock");
509        aliases.values().cloned().collect()
510    }
511
512    /// Get the number of aliases.
513    pub fn len(&self) -> usize {
514        let aliases = self
515            .aliases
516            .read()
517            .expect("alias store lock poisoned - a thread panicked while holding the lock");
518        aliases.len()
519    }
520
521    /// Check if the store is empty.
522    pub fn is_empty(&self) -> bool {
523        self.len() == 0
524    }
525
526    /// Clear all aliases.
527    pub fn clear(&self) {
528        let mut aliases = self
529            .aliases
530            .write()
531            .expect("alias store lock poisoned - a thread panicked while holding the lock");
532        aliases.clear();
533    }
534
535    /// Resolve an alias name to a platform-appropriate identifier.
536    ///
537    /// If the alias is found, returns its resolved identifier.
538    /// If not found, returns the input string unchanged (it might already be an identifier).
539    pub fn resolve(&self, alias_or_identifier: &str) -> String {
540        if let Some(alias) = self.get(alias_or_identifier) {
541            alias
542                .resolve()
543                .unwrap_or_else(|| alias_or_identifier.to_string())
544        } else {
545            alias_or_identifier.to_string()
546        }
547    }
548
549    /// Export all aliases to JSON.
550    pub fn to_json(&self) -> Result<String, serde_json::Error> {
551        let aliases = self
552            .aliases
553            .read()
554            .expect("alias store lock poisoned - a thread panicked while holding the lock");
555        serde_json::to_string_pretty(&*aliases)
556    }
557
558    /// Import aliases from JSON.
559    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
560        let aliases: HashMap<String, DeviceAlias> = serde_json::from_str(json)?;
561        Ok(Self {
562            aliases: RwLock::new(aliases),
563        })
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn test_platform_detection() {
573        let platform = Platform::current();
574        // Just verify it returns a valid platform
575        assert!(matches!(
576            platform,
577            Platform::MacOS | Platform::Linux | Platform::Windows | Platform::Unknown
578        ));
579    }
580
581    #[test]
582    fn test_platform_config_macos() {
583        let config = PlatformConfig::macos();
584        assert_eq!(config.platform, Platform::MacOS);
585        assert!(!config.exposes_mac_address);
586        assert!(config.recommended_scan_duration >= Duration::from_secs(5));
587    }
588
589    #[test]
590    fn test_platform_config_linux() {
591        let config = PlatformConfig::linux();
592        assert_eq!(config.platform, Platform::Linux);
593        assert!(config.exposes_mac_address);
594    }
595
596    #[test]
597    fn test_platform_config_windows() {
598        let config = PlatformConfig::windows();
599        assert_eq!(config.platform, Platform::Windows);
600        assert!(config.exposes_mac_address);
601    }
602
603    #[test]
604    fn test_current_platform_config() {
605        let config = PlatformConfig::for_current_platform();
606        // Verify it returns sensible values
607        assert!(config.recommended_scan_duration > Duration::ZERO);
608        assert!(config.recommended_connection_timeout > Duration::ZERO);
609        assert!(config.max_concurrent_connections > 0);
610    }
611}