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}