use crate::NodeId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlePhy {
#[default]
Le1M,
Le2M,
LeCodedS2,
LeCodedS8,
}
impl BlePhy {
pub fn bandwidth_bps(&self) -> u32 {
match self {
BlePhy::Le1M => 1_000_000,
BlePhy::Le2M => 2_000_000,
BlePhy::LeCodedS2 => 500_000,
BlePhy::LeCodedS8 => 125_000,
}
}
pub fn typical_range_meters(&self) -> u32 {
match self {
BlePhy::Le1M => 100,
BlePhy::Le2M => 50,
BlePhy::LeCodedS2 => 200,
BlePhy::LeCodedS8 => 400,
}
}
pub fn requires_ble5(&self) -> bool {
matches!(self, BlePhy::Le2M | BlePhy::LeCodedS2 | BlePhy::LeCodedS8)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PowerProfile {
Aggressive,
#[default]
Balanced,
LowPower,
Custom {
scan_interval_ms: u32,
scan_window_ms: u32,
adv_interval_ms: u32,
conn_interval_ms: u32,
},
}
impl PowerProfile {
pub fn scan_interval_ms(&self) -> u32 {
match self {
PowerProfile::Aggressive => 100,
PowerProfile::Balanced => 500,
PowerProfile::LowPower => 5000,
PowerProfile::Custom {
scan_interval_ms, ..
} => *scan_interval_ms,
}
}
pub fn scan_window_ms(&self) -> u32 {
match self {
PowerProfile::Aggressive => 50,
PowerProfile::Balanced => 50,
PowerProfile::LowPower => 100,
PowerProfile::Custom { scan_window_ms, .. } => *scan_window_ms,
}
}
pub fn adv_interval_ms(&self) -> u32 {
match self {
PowerProfile::Aggressive => 100,
PowerProfile::Balanced => 500,
PowerProfile::LowPower => 2000,
PowerProfile::Custom {
adv_interval_ms, ..
} => *adv_interval_ms,
}
}
pub fn conn_interval_ms(&self) -> u32 {
match self {
PowerProfile::Aggressive => 15,
PowerProfile::Balanced => 30,
PowerProfile::LowPower => 100,
PowerProfile::Custom {
conn_interval_ms, ..
} => *conn_interval_ms,
}
}
pub fn duty_cycle_percent(&self) -> u8 {
match self {
PowerProfile::Aggressive => 20,
PowerProfile::Balanced => 10,
PowerProfile::LowPower => 2,
PowerProfile::Custom {
scan_interval_ms,
scan_window_ms,
..
} => {
if *scan_interval_ms == 0 {
0
} else {
((scan_window_ms * 100) / scan_interval_ms) as u8
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveryConfig {
pub scan_interval_ms: u32,
pub scan_window_ms: u32,
pub adv_interval_ms: u32,
pub tx_power_dbm: i8,
pub adv_phy: BlePhy,
pub scan_phy: BlePhy,
pub active_scan: bool,
pub filter_duplicates: bool,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
scan_interval_ms: 500,
scan_window_ms: 50,
adv_interval_ms: 500,
tx_power_dbm: 0,
adv_phy: BlePhy::Le1M,
scan_phy: BlePhy::Le1M,
active_scan: true,
filter_duplicates: true,
}
}
}
#[derive(Debug, Clone)]
pub struct GattConfig {
pub preferred_mtu: u16,
pub min_mtu: u16,
pub enable_server: bool,
pub enable_client: bool,
}
impl Default for GattConfig {
fn default() -> Self {
Self {
preferred_mtu: 251,
min_mtu: 23,
enable_server: true,
enable_client: true,
}
}
}
pub const DEFAULT_MESH_ID: &str = "DEMO";
#[derive(Debug, Clone)]
pub struct MeshConfig {
pub mesh_id: String,
pub max_connections: u8,
pub max_children: u8,
pub supervision_timeout_ms: u16,
pub slave_latency: u16,
pub conn_interval_min_ms: u16,
pub conn_interval_max_ms: u16,
}
impl MeshConfig {
pub fn new(mesh_id: impl Into<String>) -> Self {
Self {
mesh_id: mesh_id.into(),
..Default::default()
}
}
pub fn device_name(&self, node_id: NodeId) -> String {
format!("PEAT_{}-{:08X}", self.mesh_id, node_id.as_u32())
}
pub fn parse_device_name(name: &str) -> Option<(Option<String>, NodeId)> {
if let Some(rest) = name.strip_prefix("PEAT_") {
let (mesh_id, node_id_str) = rest.split_once('-')?;
let node_id = u32::from_str_radix(node_id_str, 16).ok()?;
Some((Some(mesh_id.to_string()), NodeId::new(node_id)))
} else if let Some(node_id_str) = name.strip_prefix("PEAT-") {
let node_id = u32::from_str_radix(node_id_str, 16).ok()?;
Some((None, NodeId::new(node_id)))
} else {
None
}
}
pub fn matches_mesh(&self, device_mesh_id: Option<&str>) -> bool {
match device_mesh_id {
Some(id) => id == self.mesh_id,
None => true, }
}
}
impl Default for MeshConfig {
fn default() -> Self {
Self {
mesh_id: DEFAULT_MESH_ID.to_string(),
max_connections: 7,
max_children: 3,
supervision_timeout_ms: 4000,
slave_latency: 0,
conn_interval_min_ms: 30,
conn_interval_max_ms: 50,
}
}
}
#[derive(Debug, Clone)]
pub enum PhyStrategy {
Fixed(BlePhy),
Adaptive {
rssi_high_threshold: i8,
rssi_low_threshold: i8,
hysteresis_db: u8,
},
MaxRange,
MaxThroughput,
}
impl Default for PhyStrategy {
fn default() -> Self {
PhyStrategy::Fixed(BlePhy::Le1M)
}
}
#[derive(Debug, Clone, Default)]
pub struct PhyConfig {
pub strategy: PhyStrategy,
pub preferred_phy: BlePhy,
pub allow_phy_update: bool,
}
#[derive(Debug, Clone)]
pub struct SecurityConfig {
pub require_pairing: bool,
pub require_encryption: bool,
pub require_mitm_protection: bool,
pub require_secure_connections: bool,
pub app_layer_encryption: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
require_pairing: false,
require_encryption: true,
require_mitm_protection: false,
require_secure_connections: false,
app_layer_encryption: false,
}
}
}
#[derive(Debug, Clone)]
pub struct BleConfig {
pub node_id: NodeId,
pub capabilities: u16,
pub hierarchy_level: u8,
pub geohash: u32,
pub discovery: DiscoveryConfig,
pub gatt: GattConfig,
pub mesh: MeshConfig,
pub power_profile: PowerProfile,
pub phy: PhyConfig,
pub security: SecurityConfig,
}
impl BleConfig {
pub fn new(node_id: NodeId) -> Self {
Self {
node_id,
capabilities: 0,
hierarchy_level: 0,
geohash: 0,
discovery: DiscoveryConfig::default(),
gatt: GattConfig::default(),
mesh: MeshConfig::default(),
power_profile: PowerProfile::default(),
phy: PhyConfig::default(),
security: SecurityConfig::default(),
}
}
pub fn peat_lite(node_id: NodeId) -> Self {
let mut config = Self::new(node_id);
config.power_profile = PowerProfile::LowPower;
config.mesh.max_children = 0; config.discovery.scan_interval_ms = 5000;
config.discovery.scan_window_ms = 100;
config.discovery.adv_interval_ms = 2000;
config
}
pub fn apply_power_profile(&mut self) {
self.discovery.scan_interval_ms = self.power_profile.scan_interval_ms();
self.discovery.scan_window_ms = self.power_profile.scan_window_ms();
self.discovery.adv_interval_ms = self.power_profile.adv_interval_ms();
self.mesh.conn_interval_min_ms = self.power_profile.conn_interval_ms() as u16;
self.mesh.conn_interval_max_ms = self.power_profile.conn_interval_ms() as u16 + 20;
}
}
impl Default for BleConfig {
fn default() -> Self {
Self::new(NodeId::default())
}
}
pub fn embedded_encryption_secret() -> Option<[u8; 32]> {
option_env!("PEAT_ENCRYPTION_SECRET").and_then(parse_hex_secret)
}
pub fn embedded_mesh_id() -> Option<&'static str> {
option_env!("PEAT_MESH_ID")
}
pub fn has_embedded_encryption_secret() -> bool {
option_env!("PEAT_ENCRYPTION_SECRET").is_some()
}
fn parse_hex_secret(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut result = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
if i >= 32 {
return None;
}
let high = hex_digit(chunk[0])?;
let low = hex_digit(chunk[1])?;
result[i] = (high << 4) | low;
}
Some(result)
}
fn hex_digit(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_phy_properties() {
assert_eq!(BlePhy::Le1M.bandwidth_bps(), 1_000_000);
assert_eq!(BlePhy::LeCodedS8.typical_range_meters(), 400);
assert!(!BlePhy::Le1M.requires_ble5());
assert!(BlePhy::Le2M.requires_ble5());
}
#[test]
fn test_power_profile_duty_cycle() {
assert_eq!(PowerProfile::Aggressive.duty_cycle_percent(), 20);
assert_eq!(PowerProfile::Balanced.duty_cycle_percent(), 10);
assert_eq!(PowerProfile::LowPower.duty_cycle_percent(), 2);
}
#[test]
fn test_peat_lite_config() {
let config = BleConfig::peat_lite(NodeId::new(0x12345678));
assert_eq!(config.mesh.max_children, 0);
assert_eq!(config.power_profile, PowerProfile::LowPower);
assert_eq!(config.discovery.scan_interval_ms, 5000);
}
#[test]
fn test_apply_power_profile() {
let mut config = BleConfig::new(NodeId::new(0x12345678));
config.power_profile = PowerProfile::LowPower;
config.apply_power_profile();
assert_eq!(config.discovery.scan_interval_ms, 5000);
assert_eq!(config.discovery.adv_interval_ms, 2000);
}
#[test]
fn test_mesh_config_default() {
let config = MeshConfig::default();
assert_eq!(config.mesh_id, DEFAULT_MESH_ID);
assert_eq!(config.mesh_id, "DEMO");
}
#[test]
fn test_mesh_config_new() {
let config = MeshConfig::new("ALFA");
assert_eq!(config.mesh_id, "ALFA");
}
#[test]
fn test_device_name_generation() {
let config = MeshConfig::new("DEMO");
let name = config.device_name(NodeId::new(0x12345678));
assert_eq!(name, "PEAT_DEMO-12345678");
let config = MeshConfig::new("ALFA");
let name = config.device_name(NodeId::new(0xDEADBEEF));
assert_eq!(name, "PEAT_ALFA-DEADBEEF");
}
#[test]
fn test_parse_device_name_new_format() {
let result = MeshConfig::parse_device_name("PEAT_DEMO-12345678");
assert!(result.is_some());
let (mesh_id, node_id) = result.unwrap();
assert_eq!(mesh_id, Some("DEMO".to_string()));
assert_eq!(node_id.as_u32(), 0x12345678);
let result = MeshConfig::parse_device_name("PEAT_ALFA-DEADBEEF");
assert!(result.is_some());
let (mesh_id, node_id) = result.unwrap();
assert_eq!(mesh_id, Some("ALFA".to_string()));
assert_eq!(node_id.as_u32(), 0xDEADBEEF);
}
#[test]
fn test_parse_device_name_legacy_format() {
let result = MeshConfig::parse_device_name("PEAT-12345678");
assert!(result.is_some());
let (mesh_id, node_id) = result.unwrap();
assert_eq!(mesh_id, None);
assert_eq!(node_id.as_u32(), 0x12345678);
}
#[test]
fn test_parse_device_name_invalid() {
assert!(MeshConfig::parse_device_name("NotPEAT").is_none());
assert!(MeshConfig::parse_device_name("PEAT_DEMO").is_none()); assert!(MeshConfig::parse_device_name("").is_none());
}
#[test]
fn test_matches_mesh() {
let config = MeshConfig::new("DEMO");
assert!(config.matches_mesh(Some("DEMO")));
assert!(!config.matches_mesh(Some("ALFA")));
assert!(config.matches_mesh(None));
}
#[test]
fn test_parse_hex_secret() {
let hex = "0102030405060708091011121314151617181920212223242526272829303132";
let result = parse_hex_secret(hex);
assert!(result.is_some());
let secret = result.unwrap();
assert_eq!(secret[0], 0x01);
assert_eq!(secret[1], 0x02);
assert_eq!(secret[31], 0x32);
let hex = "AABBCCDD01020304050607080910111213141516171819202122232425262728";
let result = parse_hex_secret(hex);
assert!(result.is_some());
let secret = result.unwrap();
assert_eq!(secret[0], 0xAA);
assert_eq!(secret[1], 0xBB);
}
#[test]
fn test_parse_hex_secret_invalid() {
assert!(parse_hex_secret("0102030405").is_none());
assert!(parse_hex_secret(
"01020304050607080910111213141516171819202122232425262728293031323334"
)
.is_none());
assert!(
parse_hex_secret("GGHHIIJJ0102030405060708091011121314151617181920212223242526")
.is_none()
);
assert!(parse_hex_secret("").is_none());
}
#[test]
fn test_embedded_functions_exist() {
let _ = embedded_encryption_secret();
let _ = embedded_mesh_id();
let _ = has_embedded_encryption_secret();
}
}