Skip to main content

hive_btle/
config.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//! Configuration types for HIVE-BTLE
17//!
18//! Provides configuration structures for BLE transport, discovery,
19//! GATT, mesh, power management, and security settings.
20
21use crate::NodeId;
22
23/// BLE Physical Layer (PHY) type
24///
25/// BLE 5.0+ supports multiple PHY options with different
26/// trade-offs between range, throughput, and power consumption.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum BlePhy {
29    /// LE 1M PHY - 1 Mbps, ~100m range (default, most compatible)
30    #[default]
31    Le1M,
32    /// LE 2M PHY - 2 Mbps, ~50m range (higher throughput)
33    Le2M,
34    /// LE Coded S=2 - 500 kbps, ~200m range
35    LeCodedS2,
36    /// LE Coded S=8 - 125 kbps, ~400m range (maximum range)
37    LeCodedS8,
38}
39
40impl BlePhy {
41    /// Get the theoretical bandwidth in bytes per second
42    pub fn bandwidth_bps(&self) -> u32 {
43        match self {
44            BlePhy::Le1M => 1_000_000,
45            BlePhy::Le2M => 2_000_000,
46            BlePhy::LeCodedS2 => 500_000,
47            BlePhy::LeCodedS8 => 125_000,
48        }
49    }
50
51    /// Get the typical range in meters
52    pub fn typical_range_meters(&self) -> u32 {
53        match self {
54            BlePhy::Le1M => 100,
55            BlePhy::Le2M => 50,
56            BlePhy::LeCodedS2 => 200,
57            BlePhy::LeCodedS8 => 400,
58        }
59    }
60
61    /// Check if this PHY requires BLE 5.0+
62    pub fn requires_ble5(&self) -> bool {
63        matches!(self, BlePhy::Le2M | BlePhy::LeCodedS2 | BlePhy::LeCodedS8)
64    }
65}
66
67/// Power management profile
68///
69/// Controls radio duty cycle and timing parameters to balance
70/// responsiveness against battery consumption.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum PowerProfile {
73    /// Aggressive - ~20% duty cycle, ~6 hour watch battery
74    /// Use for high-activity scenarios
75    Aggressive,
76
77    /// Balanced - ~10% duty cycle, ~12 hour watch battery
78    #[default]
79    Balanced,
80
81    /// Low Power - ~2% duty cycle, ~20+ hour watch battery
82    /// Recommended for HIVE-Lite nodes
83    LowPower,
84
85    /// Custom power profile with explicit parameters
86    Custom {
87        /// Scan interval in milliseconds
88        scan_interval_ms: u32,
89        /// Scan window in milliseconds
90        scan_window_ms: u32,
91        /// Advertisement interval in milliseconds
92        adv_interval_ms: u32,
93        /// Connection interval in milliseconds
94        conn_interval_ms: u32,
95    },
96}
97
98impl PowerProfile {
99    /// Get scan interval in milliseconds
100    pub fn scan_interval_ms(&self) -> u32 {
101        match self {
102            PowerProfile::Aggressive => 100,
103            PowerProfile::Balanced => 500,
104            PowerProfile::LowPower => 5000,
105            PowerProfile::Custom {
106                scan_interval_ms, ..
107            } => *scan_interval_ms,
108        }
109    }
110
111    /// Get scan window in milliseconds
112    pub fn scan_window_ms(&self) -> u32 {
113        match self {
114            PowerProfile::Aggressive => 50,
115            PowerProfile::Balanced => 50,
116            PowerProfile::LowPower => 100,
117            PowerProfile::Custom { scan_window_ms, .. } => *scan_window_ms,
118        }
119    }
120
121    /// Get advertisement interval in milliseconds
122    pub fn adv_interval_ms(&self) -> u32 {
123        match self {
124            PowerProfile::Aggressive => 100,
125            PowerProfile::Balanced => 500,
126            PowerProfile::LowPower => 2000,
127            PowerProfile::Custom {
128                adv_interval_ms, ..
129            } => *adv_interval_ms,
130        }
131    }
132
133    /// Get connection interval in milliseconds
134    pub fn conn_interval_ms(&self) -> u32 {
135        match self {
136            PowerProfile::Aggressive => 15,
137            PowerProfile::Balanced => 30,
138            PowerProfile::LowPower => 100,
139            PowerProfile::Custom {
140                conn_interval_ms, ..
141            } => *conn_interval_ms,
142        }
143    }
144
145    /// Estimated radio duty cycle as percentage
146    pub fn duty_cycle_percent(&self) -> u8 {
147        match self {
148            PowerProfile::Aggressive => 20,
149            PowerProfile::Balanced => 10,
150            PowerProfile::LowPower => 2,
151            PowerProfile::Custom {
152                scan_interval_ms,
153                scan_window_ms,
154                ..
155            } => {
156                if *scan_interval_ms == 0 {
157                    0
158                } else {
159                    ((scan_window_ms * 100) / scan_interval_ms) as u8
160                }
161            }
162        }
163    }
164}
165
166/// Discovery configuration
167#[derive(Debug, Clone)]
168pub struct DiscoveryConfig {
169    /// Scan interval in milliseconds
170    pub scan_interval_ms: u32,
171    /// Scan window in milliseconds (must be <= scan_interval_ms)
172    pub scan_window_ms: u32,
173    /// Advertisement interval in milliseconds
174    pub adv_interval_ms: u32,
175    /// Transmit power in dBm (-20 to +10 typical)
176    pub tx_power_dbm: i8,
177    /// PHY for advertising
178    pub adv_phy: BlePhy,
179    /// PHY for scanning
180    pub scan_phy: BlePhy,
181    /// Enable active scanning (requests scan response)
182    pub active_scan: bool,
183    /// Filter duplicates during scan
184    pub filter_duplicates: bool,
185}
186
187impl Default for DiscoveryConfig {
188    fn default() -> Self {
189        Self {
190            scan_interval_ms: 500,
191            scan_window_ms: 50,
192            adv_interval_ms: 500,
193            tx_power_dbm: 0,
194            adv_phy: BlePhy::Le1M,
195            scan_phy: BlePhy::Le1M,
196            active_scan: true,
197            filter_duplicates: true,
198        }
199    }
200}
201
202/// GATT configuration
203#[derive(Debug, Clone)]
204pub struct GattConfig {
205    /// Preferred MTU size (23-517 bytes)
206    pub preferred_mtu: u16,
207    /// Minimum acceptable MTU
208    pub min_mtu: u16,
209    /// Enable GATT server (peripheral) mode
210    pub enable_server: bool,
211    /// Enable GATT client (central) mode
212    pub enable_client: bool,
213}
214
215impl Default for GattConfig {
216    fn default() -> Self {
217        Self {
218            preferred_mtu: 251,
219            min_mtu: 23,
220            enable_server: true,
221            enable_client: true,
222        }
223    }
224}
225
226/// Default mesh ID for demos and testing
227pub const DEFAULT_MESH_ID: &str = "DEMO";
228
229/// Mesh configuration
230#[derive(Debug, Clone)]
231pub struct MeshConfig {
232    /// Mesh identifier - nodes only auto-connect to peers with matching mesh ID
233    ///
234    /// Format: 4-character alphanumeric (e.g., "DEMO", "ALFA", "TEST")
235    /// This maps to the `app_id` concept in hive-protocol.
236    pub mesh_id: String,
237    /// Maximum number of simultaneous connections
238    pub max_connections: u8,
239    /// Maximum children for this node (0 = leaf node)
240    pub max_children: u8,
241    /// Connection supervision timeout in milliseconds
242    pub supervision_timeout_ms: u16,
243    /// Slave latency (number of connection events to skip)
244    pub slave_latency: u16,
245    /// Minimum connection interval in milliseconds
246    pub conn_interval_min_ms: u16,
247    /// Maximum connection interval in milliseconds
248    pub conn_interval_max_ms: u16,
249}
250
251impl MeshConfig {
252    /// Create a new mesh config with the given mesh ID
253    pub fn new(mesh_id: impl Into<String>) -> Self {
254        Self {
255            mesh_id: mesh_id.into(),
256            ..Default::default()
257        }
258    }
259
260    /// Generate the BLE device name for this node
261    ///
262    /// Format: `HIVE_<MESH_ID>-<NODE_ID>` (e.g., "HIVE_DEMO-12345678")
263    pub fn device_name(&self, node_id: NodeId) -> String {
264        format!("HIVE_{}-{:08X}", self.mesh_id, node_id.as_u32())
265    }
266
267    /// Parse mesh ID and node ID from a device name
268    ///
269    /// Returns `Some((mesh_id, node_id))` for valid names, `None` otherwise.
270    ///
271    /// Supports both formats:
272    /// - New: `HIVE_<MESH_ID>-<NODE_ID>` (e.g., "HIVE_DEMO-12345678")
273    /// - Legacy: `HIVE-<NODE_ID>` (e.g., "HIVE-12345678") - returns None for mesh_id
274    pub fn parse_device_name(name: &str) -> Option<(Option<String>, NodeId)> {
275        if let Some(rest) = name.strip_prefix("HIVE_") {
276            // New format: HIVE_MESHID-NODEID
277            let (mesh_id, node_id_str) = rest.split_once('-')?;
278            let node_id = u32::from_str_radix(node_id_str, 16).ok()?;
279            Some((Some(mesh_id.to_string()), NodeId::new(node_id)))
280        } else if let Some(node_id_str) = name.strip_prefix("HIVE-") {
281            // Legacy format: HIVE-NODEID (no mesh ID)
282            let node_id = u32::from_str_radix(node_id_str, 16).ok()?;
283            Some((None, NodeId::new(node_id)))
284        } else {
285            None
286        }
287    }
288
289    /// Check if a discovered device matches this mesh
290    ///
291    /// Returns true if:
292    /// - The device has the same mesh ID, OR
293    /// - The device has no mesh ID (legacy format - backwards compatible)
294    pub fn matches_mesh(&self, device_mesh_id: Option<&str>) -> bool {
295        match device_mesh_id {
296            Some(id) => id == self.mesh_id,
297            None => true, // Legacy devices match any mesh
298        }
299    }
300}
301
302impl Default for MeshConfig {
303    fn default() -> Self {
304        Self {
305            mesh_id: DEFAULT_MESH_ID.to_string(),
306            max_connections: 7,
307            max_children: 3,
308            supervision_timeout_ms: 4000,
309            slave_latency: 0,
310            conn_interval_min_ms: 30,
311            conn_interval_max_ms: 50,
312        }
313    }
314}
315
316/// PHY selection strategy
317#[derive(Debug, Clone)]
318pub enum PhyStrategy {
319    /// Use a fixed PHY
320    Fixed(BlePhy),
321    /// Adaptive PHY selection based on RSSI
322    Adaptive {
323        /// RSSI threshold to switch to high-throughput PHY (dBm)
324        rssi_high_threshold: i8,
325        /// RSSI threshold to switch to long-range PHY (dBm)
326        rssi_low_threshold: i8,
327        /// Hysteresis to prevent oscillation (dB)
328        hysteresis_db: u8,
329    },
330    /// Always use maximum range (Coded S=8)
331    MaxRange,
332    /// Always use maximum throughput (2M)
333    MaxThroughput,
334}
335
336impl Default for PhyStrategy {
337    fn default() -> Self {
338        PhyStrategy::Fixed(BlePhy::Le1M)
339    }
340}
341
342/// PHY configuration
343#[derive(Debug, Clone, Default)]
344pub struct PhyConfig {
345    /// PHY selection strategy
346    pub strategy: PhyStrategy,
347    /// Preferred PHY for connections
348    pub preferred_phy: BlePhy,
349    /// Allow PHY upgrade after connection
350    pub allow_phy_update: bool,
351}
352
353/// Security configuration
354#[derive(Debug, Clone)]
355pub struct SecurityConfig {
356    /// Require pairing before data exchange
357    pub require_pairing: bool,
358    /// Require encrypted connections
359    pub require_encryption: bool,
360    /// Enable MITM protection
361    pub require_mitm_protection: bool,
362    /// Enable Secure Connections (BLE 4.2+)
363    pub require_secure_connections: bool,
364    /// Enable application-layer encryption (in addition to BLE)
365    pub app_layer_encryption: bool,
366}
367
368impl Default for SecurityConfig {
369    fn default() -> Self {
370        Self {
371            require_pairing: false,
372            require_encryption: true,
373            require_mitm_protection: false,
374            require_secure_connections: false,
375            app_layer_encryption: false,
376        }
377    }
378}
379
380/// Main BLE transport configuration
381#[derive(Debug, Clone)]
382pub struct BleConfig {
383    /// This node's identifier
384    pub node_id: NodeId,
385    /// Node capabilities flags
386    pub capabilities: u16,
387    /// Hierarchy level (0 = platform/leaf)
388    pub hierarchy_level: u8,
389    /// Geohash for location (24-bit, 6-char precision)
390    pub geohash: u32,
391    /// Discovery configuration
392    pub discovery: DiscoveryConfig,
393    /// GATT configuration
394    pub gatt: GattConfig,
395    /// Mesh configuration
396    pub mesh: MeshConfig,
397    /// Power profile
398    pub power_profile: PowerProfile,
399    /// PHY configuration
400    pub phy: PhyConfig,
401    /// Security configuration
402    pub security: SecurityConfig,
403}
404
405impl BleConfig {
406    /// Create a new configuration with the given node ID
407    pub fn new(node_id: NodeId) -> Self {
408        Self {
409            node_id,
410            capabilities: 0,
411            hierarchy_level: 0,
412            geohash: 0,
413            discovery: DiscoveryConfig::default(),
414            gatt: GattConfig::default(),
415            mesh: MeshConfig::default(),
416            power_profile: PowerProfile::default(),
417            phy: PhyConfig::default(),
418            security: SecurityConfig::default(),
419        }
420    }
421
422    /// Create a HIVE-Lite optimized configuration
423    ///
424    /// Optimized for battery efficiency with:
425    /// - Low power profile (~2% duty cycle)
426    /// - Leaf node (no children)
427    /// - Minimal scanning
428    pub fn hive_lite(node_id: NodeId) -> Self {
429        let mut config = Self::new(node_id);
430        config.power_profile = PowerProfile::LowPower;
431        config.mesh.max_children = 0; // Leaf node
432        config.discovery.scan_interval_ms = 5000;
433        config.discovery.scan_window_ms = 100;
434        config.discovery.adv_interval_ms = 2000;
435        config
436    }
437
438    /// Apply power profile settings to discovery config
439    pub fn apply_power_profile(&mut self) {
440        self.discovery.scan_interval_ms = self.power_profile.scan_interval_ms();
441        self.discovery.scan_window_ms = self.power_profile.scan_window_ms();
442        self.discovery.adv_interval_ms = self.power_profile.adv_interval_ms();
443        self.mesh.conn_interval_min_ms = self.power_profile.conn_interval_ms() as u16;
444        self.mesh.conn_interval_max_ms = self.power_profile.conn_interval_ms() as u16 + 20;
445    }
446}
447
448impl Default for BleConfig {
449    fn default() -> Self {
450        Self::new(NodeId::default())
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_phy_properties() {
460        assert_eq!(BlePhy::Le1M.bandwidth_bps(), 1_000_000);
461        assert_eq!(BlePhy::LeCodedS8.typical_range_meters(), 400);
462        assert!(!BlePhy::Le1M.requires_ble5());
463        assert!(BlePhy::Le2M.requires_ble5());
464    }
465
466    #[test]
467    fn test_power_profile_duty_cycle() {
468        assert_eq!(PowerProfile::Aggressive.duty_cycle_percent(), 20);
469        assert_eq!(PowerProfile::Balanced.duty_cycle_percent(), 10);
470        assert_eq!(PowerProfile::LowPower.duty_cycle_percent(), 2);
471    }
472
473    #[test]
474    fn test_hive_lite_config() {
475        let config = BleConfig::hive_lite(NodeId::new(0x12345678));
476        assert_eq!(config.mesh.max_children, 0);
477        assert_eq!(config.power_profile, PowerProfile::LowPower);
478        assert_eq!(config.discovery.scan_interval_ms, 5000);
479    }
480
481    #[test]
482    fn test_apply_power_profile() {
483        let mut config = BleConfig::new(NodeId::new(0x12345678));
484        config.power_profile = PowerProfile::LowPower;
485        config.apply_power_profile();
486        assert_eq!(config.discovery.scan_interval_ms, 5000);
487        assert_eq!(config.discovery.adv_interval_ms, 2000);
488    }
489
490    #[test]
491    fn test_mesh_config_default() {
492        let config = MeshConfig::default();
493        assert_eq!(config.mesh_id, DEFAULT_MESH_ID);
494        assert_eq!(config.mesh_id, "DEMO");
495    }
496
497    #[test]
498    fn test_mesh_config_new() {
499        let config = MeshConfig::new("ALFA");
500        assert_eq!(config.mesh_id, "ALFA");
501    }
502
503    #[test]
504    fn test_device_name_generation() {
505        let config = MeshConfig::new("DEMO");
506        let name = config.device_name(NodeId::new(0x12345678));
507        assert_eq!(name, "HIVE_DEMO-12345678");
508
509        let config = MeshConfig::new("ALFA");
510        let name = config.device_name(NodeId::new(0xDEADBEEF));
511        assert_eq!(name, "HIVE_ALFA-DEADBEEF");
512    }
513
514    #[test]
515    fn test_parse_device_name_new_format() {
516        // New format: HIVE_MESHID-NODEID
517        let result = MeshConfig::parse_device_name("HIVE_DEMO-12345678");
518        assert!(result.is_some());
519        let (mesh_id, node_id) = result.unwrap();
520        assert_eq!(mesh_id, Some("DEMO".to_string()));
521        assert_eq!(node_id.as_u32(), 0x12345678);
522
523        let result = MeshConfig::parse_device_name("HIVE_ALFA-DEADBEEF");
524        assert!(result.is_some());
525        let (mesh_id, node_id) = result.unwrap();
526        assert_eq!(mesh_id, Some("ALFA".to_string()));
527        assert_eq!(node_id.as_u32(), 0xDEADBEEF);
528    }
529
530    #[test]
531    fn test_parse_device_name_legacy_format() {
532        // Legacy format: HIVE-NODEID (no mesh ID)
533        let result = MeshConfig::parse_device_name("HIVE-12345678");
534        assert!(result.is_some());
535        let (mesh_id, node_id) = result.unwrap();
536        assert_eq!(mesh_id, None);
537        assert_eq!(node_id.as_u32(), 0x12345678);
538    }
539
540    #[test]
541    fn test_parse_device_name_invalid() {
542        assert!(MeshConfig::parse_device_name("NotHIVE").is_none());
543        assert!(MeshConfig::parse_device_name("HIVE_DEMO").is_none()); // Missing node ID
544        assert!(MeshConfig::parse_device_name("").is_none());
545    }
546
547    #[test]
548    fn test_matches_mesh() {
549        let config = MeshConfig::new("DEMO");
550
551        // Same mesh ID matches
552        assert!(config.matches_mesh(Some("DEMO")));
553
554        // Different mesh ID does not match
555        assert!(!config.matches_mesh(Some("ALFA")));
556
557        // Legacy devices (no mesh ID) match any mesh for backwards compatibility
558        assert!(config.matches_mesh(None));
559    }
560}