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// ============================================================================
455// Build-time Embedded Secrets
456// ============================================================================
457
458/// Get the compile-time embedded encryption secret, if set.
459///
460/// Set the `HIVE_ENCRYPTION_SECRET` environment variable during build to embed
461/// a 64-character hex string (32 bytes) as the default mesh encryption secret.
462///
463/// # Example
464///
465/// Build with embedded secret:
466/// ```bash
467/// HIVE_ENCRYPTION_SECRET=0102030405060708091011121314151617181920212223242526272829303132 \
468///   cargo build --release
469/// ```
470///
471/// Use in code:
472/// ```ignore
473/// use hive_btle::config::embedded_encryption_secret;
474/// use hive_btle::hive_mesh::HiveMeshConfig;
475///
476/// let config = if let Some(secret) = embedded_encryption_secret() {
477///     HiveMeshConfig::new(node_id, "CALL", "MESH").with_encryption(secret)
478/// } else {
479///     HiveMeshConfig::new(node_id, "CALL", "MESH")
480/// };
481/// ```
482///
483/// # Security Note
484///
485/// The embedded secret is compiled into the binary. This is suitable for:
486/// - Development/testing with a fixed secret
487/// - Closed deployments where binaries are distributed securely
488///
489/// For dynamic secret management, use `MeshGenesis` or runtime configuration.
490pub fn embedded_encryption_secret() -> Option<[u8; 32]> {
491    // Read at compile time - returns None if not set
492    option_env!("HIVE_ENCRYPTION_SECRET").and_then(parse_hex_secret)
493}
494
495/// Get the compile-time embedded mesh ID, if set.
496///
497/// Set the `HIVE_MESH_ID` environment variable during build to embed
498/// a default mesh ID.
499///
500/// # Example
501///
502/// ```bash
503/// HIVE_MESH_ID=ALPHA cargo build --release
504/// ```
505pub fn embedded_mesh_id() -> Option<&'static str> {
506    option_env!("HIVE_MESH_ID")
507}
508
509/// Check if a compile-time encryption secret was embedded.
510pub fn has_embedded_encryption_secret() -> bool {
511    option_env!("HIVE_ENCRYPTION_SECRET").is_some()
512}
513
514/// Parse a 64-character hex string into a 32-byte array.
515fn parse_hex_secret(hex: &str) -> Option<[u8; 32]> {
516    if hex.len() != 64 {
517        return None;
518    }
519
520    let mut result = [0u8; 32];
521    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
522        if i >= 32 {
523            return None;
524        }
525        let high = hex_digit(chunk[0])?;
526        let low = hex_digit(chunk[1])?;
527        result[i] = (high << 4) | low;
528    }
529    Some(result)
530}
531
532/// Convert a hex character to its numeric value.
533fn hex_digit(c: u8) -> Option<u8> {
534    match c {
535        b'0'..=b'9' => Some(c - b'0'),
536        b'a'..=b'f' => Some(c - b'a' + 10),
537        b'A'..=b'F' => Some(c - b'A' + 10),
538        _ => None,
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_phy_properties() {
548        assert_eq!(BlePhy::Le1M.bandwidth_bps(), 1_000_000);
549        assert_eq!(BlePhy::LeCodedS8.typical_range_meters(), 400);
550        assert!(!BlePhy::Le1M.requires_ble5());
551        assert!(BlePhy::Le2M.requires_ble5());
552    }
553
554    #[test]
555    fn test_power_profile_duty_cycle() {
556        assert_eq!(PowerProfile::Aggressive.duty_cycle_percent(), 20);
557        assert_eq!(PowerProfile::Balanced.duty_cycle_percent(), 10);
558        assert_eq!(PowerProfile::LowPower.duty_cycle_percent(), 2);
559    }
560
561    #[test]
562    fn test_hive_lite_config() {
563        let config = BleConfig::hive_lite(NodeId::new(0x12345678));
564        assert_eq!(config.mesh.max_children, 0);
565        assert_eq!(config.power_profile, PowerProfile::LowPower);
566        assert_eq!(config.discovery.scan_interval_ms, 5000);
567    }
568
569    #[test]
570    fn test_apply_power_profile() {
571        let mut config = BleConfig::new(NodeId::new(0x12345678));
572        config.power_profile = PowerProfile::LowPower;
573        config.apply_power_profile();
574        assert_eq!(config.discovery.scan_interval_ms, 5000);
575        assert_eq!(config.discovery.adv_interval_ms, 2000);
576    }
577
578    #[test]
579    fn test_mesh_config_default() {
580        let config = MeshConfig::default();
581        assert_eq!(config.mesh_id, DEFAULT_MESH_ID);
582        assert_eq!(config.mesh_id, "DEMO");
583    }
584
585    #[test]
586    fn test_mesh_config_new() {
587        let config = MeshConfig::new("ALFA");
588        assert_eq!(config.mesh_id, "ALFA");
589    }
590
591    #[test]
592    fn test_device_name_generation() {
593        let config = MeshConfig::new("DEMO");
594        let name = config.device_name(NodeId::new(0x12345678));
595        assert_eq!(name, "HIVE_DEMO-12345678");
596
597        let config = MeshConfig::new("ALFA");
598        let name = config.device_name(NodeId::new(0xDEADBEEF));
599        assert_eq!(name, "HIVE_ALFA-DEADBEEF");
600    }
601
602    #[test]
603    fn test_parse_device_name_new_format() {
604        // New format: HIVE_MESHID-NODEID
605        let result = MeshConfig::parse_device_name("HIVE_DEMO-12345678");
606        assert!(result.is_some());
607        let (mesh_id, node_id) = result.unwrap();
608        assert_eq!(mesh_id, Some("DEMO".to_string()));
609        assert_eq!(node_id.as_u32(), 0x12345678);
610
611        let result = MeshConfig::parse_device_name("HIVE_ALFA-DEADBEEF");
612        assert!(result.is_some());
613        let (mesh_id, node_id) = result.unwrap();
614        assert_eq!(mesh_id, Some("ALFA".to_string()));
615        assert_eq!(node_id.as_u32(), 0xDEADBEEF);
616    }
617
618    #[test]
619    fn test_parse_device_name_legacy_format() {
620        // Legacy format: HIVE-NODEID (no mesh ID)
621        let result = MeshConfig::parse_device_name("HIVE-12345678");
622        assert!(result.is_some());
623        let (mesh_id, node_id) = result.unwrap();
624        assert_eq!(mesh_id, None);
625        assert_eq!(node_id.as_u32(), 0x12345678);
626    }
627
628    #[test]
629    fn test_parse_device_name_invalid() {
630        assert!(MeshConfig::parse_device_name("NotHIVE").is_none());
631        assert!(MeshConfig::parse_device_name("HIVE_DEMO").is_none()); // Missing node ID
632        assert!(MeshConfig::parse_device_name("").is_none());
633    }
634
635    #[test]
636    fn test_matches_mesh() {
637        let config = MeshConfig::new("DEMO");
638
639        // Same mesh ID matches
640        assert!(config.matches_mesh(Some("DEMO")));
641
642        // Different mesh ID does not match
643        assert!(!config.matches_mesh(Some("ALFA")));
644
645        // Legacy devices (no mesh ID) match any mesh for backwards compatibility
646        assert!(config.matches_mesh(None));
647    }
648
649    #[test]
650    fn test_parse_hex_secret() {
651        // Valid 64-char hex
652        let hex = "0102030405060708091011121314151617181920212223242526272829303132";
653        let result = parse_hex_secret(hex);
654        assert!(result.is_some());
655        let secret = result.unwrap();
656        assert_eq!(secret[0], 0x01);
657        assert_eq!(secret[1], 0x02);
658        assert_eq!(secret[31], 0x32);
659
660        // Mixed case hex (64 chars = 32 bytes)
661        let hex = "AABBCCDD01020304050607080910111213141516171819202122232425262728";
662        let result = parse_hex_secret(hex);
663        assert!(result.is_some());
664        let secret = result.unwrap();
665        assert_eq!(secret[0], 0xAA);
666        assert_eq!(secret[1], 0xBB);
667    }
668
669    #[test]
670    fn test_parse_hex_secret_invalid() {
671        // Too short
672        assert!(parse_hex_secret("0102030405").is_none());
673
674        // Too long
675        assert!(parse_hex_secret(
676            "01020304050607080910111213141516171819202122232425262728293031323334"
677        )
678        .is_none());
679
680        // Invalid characters
681        assert!(
682            parse_hex_secret("GGHHIIJJ0102030405060708091011121314151617181920212223242526")
683                .is_none()
684        );
685
686        // Empty
687        assert!(parse_hex_secret("").is_none());
688    }
689
690    #[test]
691    fn test_embedded_functions_exist() {
692        // These just verify the functions compile and can be called
693        // The actual values depend on build-time env vars
694        let _ = embedded_encryption_secret();
695        let _ = embedded_mesh_id();
696        let _ = has_embedded_encryption_secret();
697    }
698}