diff --git a/src/bluez.rs b/src/bluez.rs index 1f90436..675a8af 100644 --- a/src/bluez.rs +++ b/src/bluez.rs @@ -2,10 +2,127 @@ //! Helpers for parsing BlueZ device information. +use std::borrow::Borrow; +use std::fmt; +use std::str::FromStr; + pub(crate) const STRONG_RSSI: i32 = -60; pub(crate) const GOOD_RSSI: i32 = -70; pub(crate) const FAIR_RSSI: i32 = -80; +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct Address(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AddressErrorKind { + Length, + Separator { index: usize }, + HexDigit { index: usize }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AddressError { + pub(crate) input: String, + pub(crate) kind: AddressErrorKind, +} + +impl Address { + pub(crate) fn parse(value: &str) -> Result { + let bytes = value.as_bytes(); + if bytes.len() != 17 { + return Err(AddressError { + input: value.into(), + kind: AddressErrorKind::Length, + }); + } + for (index, byte) in bytes.iter().enumerate() { + if index % 3 == 2 { + if *byte != b':' { + return Err(AddressError { + input: value.into(), + kind: AddressErrorKind::Separator { index }, + }); + } + } else if !byte.is_ascii_hexdigit() { + return Err(AddressError { + input: value.into(), + kind: AddressErrorKind::HexDigit { index }, + }); + } + } + Ok(Self(value.to_ascii_uppercase())) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Borrow for Address { + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl AsRef for Address { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::ops::Deref for Address { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for Address { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl fmt::Display for AddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let reason = match self.kind { + AddressErrorKind::Length => "must contain six hexadecimal octets", + AddressErrorKind::Separator { .. } => "must separate octets with colons", + AddressErrorKind::HexDigit { .. } => "contains a non-hexadecimal character", + }; + write!( + formatter, + "expected a Bluetooth MAC address (XX:XX:XX:XX:XX:XX): {} ({reason})", + self.input + ) + } +} + +impl std::error::Error for AddressError {} + +impl FromStr for Address { + type Err = AddressError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +#[cfg(test)] +impl From<&str> for Address { + fn from(value: &str) -> Self { + Self::parse(value).expect("test fixture must use a valid Bluetooth address") + } +} + +#[cfg(test)] +impl From for Address { + fn from(value: String) -> Self { + Self::parse(&value).expect("test fixture must use a valid Bluetooth address") + } +} + pub(crate) fn property<'a>(info: &'a str, field: &str) -> Option<&'a str> { info.lines().find_map(|line| { let (key, value) = line.trim().split_once(':')?; @@ -17,27 +134,15 @@ pub(crate) fn device_flag(info: &str, flag: &str) -> bool { property(info, flag) == Some("yes") } -pub(crate) fn normalize_address(value: &str) -> Option { - let bytes = value.as_bytes(); - if bytes.len() != 17 - || !bytes.iter().enumerate().all(|(index, byte)| { - if index % 3 == 2 { - *byte == b':' - } else { - byte.is_ascii_hexdigit() - } - }) { - return None; - } - Some(value.to_ascii_uppercase()) +pub(crate) fn normalize_address(value: &str) -> Option
{ + Address::parse(value).ok() } -pub(crate) fn parse_address(value: &str) -> Result { - normalize_address(value) - .ok_or_else(|| format!("expected a Bluetooth MAC address (XX:XX:XX:XX:XX:XX): {value}")) +pub(crate) fn parse_address(value: &str) -> Result { + Address::parse(value) } -pub(crate) fn controller_addresses(info: &str) -> std::collections::BTreeSet { +pub(crate) fn controller_addresses(info: &str) -> std::collections::BTreeSet
{ info.lines() .filter_map(|line| { let address = line @@ -118,7 +223,9 @@ mod tests { #[test] fn normalizes_only_valid_bluetooth_addresses() { assert_eq!( - normalize_address("aa:bb:cc:dd:ee:ff").as_deref(), + normalize_address("aa:bb:cc:dd:ee:ff") + .as_ref() + .map(Address::as_str), Some("AA:BB:CC:DD:EE:FF") ); for value in [ @@ -132,6 +239,21 @@ mod tests { } } + #[test] + fn address_errors_identify_the_invalid_component() { + assert_eq!( + Address::parse("AA:BB:CC:DD:EE").unwrap_err().kind, + AddressErrorKind::Length + ); + assert_eq!( + Address::parse("AA-BB:CC:DD:EE:FF").unwrap_err().kind, + AddressErrorKind::Separator { index: 2 } + ); + let error = Address::parse("AA:BB:CC:DD:EE:FG").unwrap_err(); + assert_eq!(error.kind, AddressErrorKind::HexDigit { index: 16 }); + assert!(error.to_string().contains("non-hexadecimal character")); + } + #[test] fn parses_optional_rssi() { assert_eq!(signal_strength("RSSI: -67 (0xffffffbd)\n"), Some(-67)); diff --git a/src/dashboard.rs b/src/dashboard.rs index 29fb8e8..def4261 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -9,6 +9,7 @@ use std::sync::atomic::AtomicBool; use std::sync::mpsc::Receiver; use std::time::Duration; +use crate::bluez::Address; use crate::device_status::{HeadsetStatus, StatusPresentation, StatusUpdate, start_polling}; use crate::router::Headset; use crate::routing_status::RoutingStatus; @@ -20,7 +21,7 @@ use crate::worker::Worker; /// The polling worker can be stopped explicitly with [`Dashboard::stop`]. pub struct Dashboard { receiver: Receiver, - state: BTreeMap, + state: BTreeMap, status_error: Option, worker: Option, } @@ -32,7 +33,7 @@ pub struct RunStatus { impl Dashboard { /// Starts polling each allowlisted device until shutdown or cancellation. - pub fn start(allowed: BTreeSet, stopped: Arc) -> Self { + pub fn start(allowed: BTreeSet
, stopped: Arc) -> Self { let (worker, receiver) = start_polling(allowed, false, Duration::from_secs(10), stopped); Self { receiver, @@ -56,8 +57,8 @@ impl Dashboard { /// Renders the latest Bluetooth and routing state to the supplied writer. pub fn draw( &self, - allowed: &BTreeSet, - headsets: &BTreeMap, + allowed: &BTreeSet
, + headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>, status: RunStatus, error: Option<&str>, @@ -188,7 +189,7 @@ mod tests { #[test] fn renders_unknown_status_and_observed_owned_links() { - let allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".to_string()]); + let allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".into()]); let (sender, receiver) = mpsc::channel(); drop(sender); let dashboard = Dashboard { @@ -230,10 +231,7 @@ mod tests { #[test] fn rendered_counts_match_confirmation_readiness() { - let allowed = BTreeSet::from([ - "AA:BB:CC:DD:EE:01".to_string(), - "AA:BB:CC:DD:EE:02".to_string(), - ]); + let allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".into(), "AA:BB:CC:DD:EE:02".into()]); let objects = json!([ {"type":"PipeWire:Interface:Device","id":1,"info":{"props":{"api.bluez5.address":"AA:BB:CC:DD:EE:01"}}}, {"type":"PipeWire:Interface:Node","id":2,"info":{"props":{"device.id":1,"media.class":"Audio/Source","api.bluez5.profile":"headset-head-unit"}}}, @@ -245,8 +243,9 @@ mod tests { let (headsets, _) = crate::router::topology(&objects, &allowed).unwrap(); let links = BTreeSet::from([(3, 6), (3, 99), (99, 6)]); let routes = RoutingStatus::new(&headsets, &links); - assert!(routes.has_active_source_route("AA:BB:CC:DD:EE:01")); - assert!(!routes.has_active_intercom_connection("AA:BB:CC:DD:EE:01")); + let source = Address::from("AA:BB:CC:DD:EE:01"); + assert!(routes.has_active_source_route(&source)); + assert!(!routes.has_active_intercom_connection(&source)); let (_, receiver) = mpsc::channel(); let dashboard = Dashboard { receiver, @@ -293,9 +292,9 @@ mod tests { let addresses = levels .iter() .enumerate() - .map(|(index, _)| format!("AA:BB:CC:DD:EE:{:02}", index + 1)) + .map(|(index, _)| format!("AA:BB:CC:DD:EE:{:02}", index + 1).into()) .collect::>(); - let disconnected = "AA:BB:CC:DD:EE:07".to_string(); + let disconnected = Address::from("AA:BB:CC:DD:EE:07"); let allowed = addresses .iter() .cloned() @@ -306,7 +305,7 @@ mod tests { .enumerate() .map(|(index, (rssi, _))| { ( - format!("AA:BB:CC:DD:EE:{:02}", index + 1), + format!("AA:BB:CC:DD:EE:{:02}", index + 1).into(), HeadsetStatus { name: None, connected: Some(true), @@ -353,14 +352,14 @@ mod tests { } let disconnected_row = text .lines() - .find(|line| line.contains(&disconnected)) + .find(|line| line.contains(disconnected.as_str())) .unwrap(); assert!(disconnected_row.contains("unknown")); } #[test] fn renders_bluetooth_alias_with_the_address() { - let address = "AA:BB:CC:DD:EE:01".to_string(); + let address = Address::from("AA:BB:CC:DD:EE:01"); let allowed = BTreeSet::from([address.clone()]); let (sender, receiver) = mpsc::channel(); drop(sender); diff --git a/src/device_status.rs b/src/device_status.rs index ef7c608..5ec67d9 100644 --- a/src/device_status.rs +++ b/src/device_status.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::time::Duration; +use crate::bluez::Address; use crate::bluez::{ FAIR_RSSI, GOOD_RSSI, STRONG_RSSI, bluetooth_name, device_flag, signal_strength, }; @@ -152,14 +153,14 @@ impl<'a> StatusPresentation<'a> { } pub(crate) struct StatusUpdate { - pub statuses: BTreeMap, + pub statuses: BTreeMap, pub error: Option, } /// Dashboard routing is already sampled by the session; only the standalone /// control panel needs an additional PipeWire inspection. pub(crate) fn start_polling( - allowed: BTreeSet, + allowed: BTreeSet
, include_audio: bool, interval: Duration, session_stopped: Arc, @@ -186,7 +187,7 @@ pub(crate) fn start_polling( } fn poll_status( - allowed: &BTreeSet, + allowed: &BTreeSet
, include_audio: bool, stopped: &AtomicBool, mut execute: impl FnMut(&[&str]) -> Result, @@ -286,27 +287,43 @@ mod tests { #[test] fn each_device_is_queried_once_and_failures_remain_unknown() { - let allowed = BTreeSet::from(["A".into(), "B".into()]); + let first = "AA:BB:CC:DD:EE:01"; + let second = "AA:BB:CC:DD:EE:02"; + let allowed = BTreeSet::from([first.into(), second.into()]); for include_audio in [false, true] { let mut calls = Vec::new(); let update = poll_status(&allowed, include_audio, &AtomicBool::new(false), |args| { calls.push(args.join(" ")); match args { ["pw-dump"] => Ok("[]".into()), - ["bluetoothctl", "info", "A"] => Ok("Connected: yes\nAlias: Headset".into()), + ["bluetoothctl", "info", address] if *address == first => { + Ok("Connected: yes\nAlias: Headset".into()) + } _ => Err("device unavailable".into()), } }) .unwrap(); let expected = if include_audio { - vec!["pw-dump", "bluetoothctl info A", "bluetoothctl info B"] + vec![ + "pw-dump", + "bluetoothctl info AA:BB:CC:DD:EE:01", + "bluetoothctl info AA:BB:CC:DD:EE:02", + ] } else { - vec!["bluetoothctl info A", "bluetoothctl info B"] + vec![ + "bluetoothctl info AA:BB:CC:DD:EE:01", + "bluetoothctl info AA:BB:CC:DD:EE:02", + ] }; assert_eq!(calls, expected); - assert_eq!(update.statuses["A"].connected, Some(true)); - assert_eq!(update.statuses["B"].connected, None); - assert!(update.error.unwrap().contains("B: device unavailable")); + assert_eq!(update.statuses[first].connected, Some(true)); + assert_eq!(update.statuses[second].connected, None); + assert!( + update + .error + .unwrap() + .contains(&format!("{second}: device unavailable")) + ); } } @@ -314,12 +331,19 @@ mod tests { fn cancellation_discards_partial_updates_and_stops_commands() { let stopped = AtomicBool::new(false); let mut calls = 0; + let first = "AA:BB:CC:DD:EE:01"; + let second = "AA:BB:CC:DD:EE:02"; assert!( - poll_status(&["A".into(), "B".into()].into(), false, &stopped, |_| { - calls += 1; - stopped.store(true, Ordering::SeqCst); - Ok("Connected: yes".into()) - }) + poll_status( + &[first.into(), second.into()].into(), + false, + &stopped, + |_| { + calls += 1; + stopped.store(true, Ordering::SeqCst); + Ok("Connected: yes".into()) + } + ) .is_none() ); assert_eq!(calls, 1); @@ -333,8 +357,9 @@ mod tests { #[test] fn audio_errors_do_not_hide_bluetooth_status() { + let address = "AA:BB:CC:DD:EE:01"; let update = poll_status( - &["A".into()].into(), + &[address.into()].into(), true, &AtomicBool::new(false), |args| { @@ -346,8 +371,8 @@ mod tests { }, ) .unwrap(); - assert_eq!(update.statuses["A"].connected, Some(false)); - assert!(!update.statuses["A"].duplex); + assert_eq!(update.statuses[address].connected, Some(false)); + assert!(!update.statuses[address].duplex); assert_eq!(update.error.as_deref(), Some("PipeWire unavailable")); } diff --git a/src/group_editor.rs b/src/group_editor.rs index bc1b8a3..21bd901 100644 --- a/src/group_editor.rs +++ b/src/group_editor.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use std::path::PathBuf; +use crate::bluez::Address; use crate::groups::{TalkGroup, normalize_name, save}; pub(crate) struct GroupEditor { @@ -53,7 +54,7 @@ impl GroupEditor { true } - pub(crate) fn toggle_member(&mut self, index: usize, address: String) -> bool { + pub(crate) fn toggle_member(&mut self, index: usize, address: Address) -> bool { let Some(group) = self.groups.get_mut(index) else { return false; }; diff --git a/src/groups.rs b/src/groups.rs index dd61483..deb90a2 100644 --- a/src/groups.rs +++ b/src/groups.rs @@ -8,10 +8,12 @@ use std::path::{Path, PathBuf}; use serde_json::json; +use crate::bluez::Address; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct TalkGroup { pub name: String, - pub members: BTreeSet, + pub members: BTreeSet
, } pub fn config_path(network_path: &Path) -> Result { @@ -71,12 +73,8 @@ pub fn load(path: &Path) -> Result, String> { path.display() ) })?; - crate::bluez::normalize_address(address).ok_or_else(|| { - format!( - "invalid talk-group file {}: invalid headset address {address}", - path.display() - ) - }) + crate::bluez::parse_address(address) + .map_err(|error| format!("invalid talk-group file {}: {error}", path.display())) }) .collect::, _>>()?; groups.push(TalkGroup { name, members }); @@ -90,16 +88,6 @@ pub fn save(path: &Path, groups: &[TalkGroup]) -> Result<(), String> { if group.name.trim().is_empty() || !names.insert(normalize_name(&group.name)) { return Err("talk-group names must be non-empty and unique".into()); } - if group - .members - .iter() - .any(|member| crate::bluez::normalize_address(member).is_none()) - { - return Err(format!( - "talk group {:?} contains an invalid headset address", - group.name - )); - } } let directory = path .parent() @@ -109,7 +97,7 @@ pub fn save(path: &Path, groups: &[TalkGroup]) -> Result<(), String> { let contents = json!({ "groups": groups.iter().map(|group| json!({ "name": group.name.trim(), - "members": group.members.iter().filter_map(|address| crate::bluez::normalize_address(address)).collect::>(), + "members": group.members.iter().map(Address::as_str).collect::>(), })).collect::>(), }) .to_string(); @@ -174,11 +162,7 @@ mod tests { fs::write(&path, contents).unwrap(); assert!(load(&path).is_err(), "{contents}"); } - let invalid_member = TalkGroup { - name: "Team".into(), - members: ["not-an-address".into()].into(), - }; - assert!(save(&path, &[invalid_member]).is_err()); + assert!(Address::parse("not-an-address").is_err()); assert!( save( &path, diff --git a/src/main.rs b/src/main.rs index a75d42a..df4471f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,7 @@ use std::time::Duration; #[cfg(test)] use std::time::Instant; -use bluez::{bluetooth_name, device_flag, parse_address}; +use bluez::{Address, bluetooth_name, device_flag, parse_address}; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use clap_complete::Shell; use groups::config_path as talk_groups_path; @@ -119,22 +119,22 @@ enum CliCommand { /// Pair, trust, and connect a headset. Pair { #[arg(value_parser = parse_address)] - address: String, + address: Address, }, /// Remove a headset from the saved intercom network. Remove { #[arg(value_parser = parse_address)] - address: String, + address: Address, }, /// Show Bluetooth headset audio status. Status { #[arg(required = true, value_parser = parse_address)] - addresses: Vec, + addresses: Vec
, }, /// Route configured headset microphones to one another, optionally showing the live dashboard. Run { #[arg(value_parser = parse_address)] - addresses: Vec, + addresses: Vec
, /// Routing update interval in seconds. #[arg(long, default_value_t = 2.0, value_parser = parse_interval)] interval: f64, @@ -213,7 +213,7 @@ fn parse_sco_limit(value: &str) -> Result { fn parse_ptt_binding(value: &str) -> Result { if value == ptt_discovery::AUTO { return Ok(PttButton { - address: value.into(), + address: None, path: value.into(), }); } @@ -224,17 +224,17 @@ fn parse_ptt_binding(value: &str) -> Result { return Err("--ptt requires an input device path".into()); } Ok(PttButton { - address: parse_address(device)?, + address: Some(parse_address(device).map_err(|error| error.to_string())?), path: path.into(), }) } /// Parses and validates one or more Bluetooth addresses. -fn addresses(args: &[String]) -> Result, String> { +fn addresses(args: &[Address]) -> Result, String> { if args.is_empty() { return Err("at least one Bluetooth address is required".into()); } - args.iter().map(|value| parse_address(value)).collect() + Ok(args.iter().cloned().collect()) } /// Executes one CLI action, including the main polling and routing loop. @@ -295,7 +295,7 @@ fn run(action: CliCommand) -> Result<(), String> { .ok() .and_then(|info| bluetooth_name(&info)); let identity = - name.map_or_else(|| address.clone(), |name| format!("{name} ({address})")); + name.map_or_else(|| address.to_string(), |name| format!("{name} ({address})")); match headsets.get(address) { Some(headset) => println!( "{identity}: {} ({} microphone ports, {} speaker ports)", @@ -392,16 +392,20 @@ mod tests { }; use std::ffi::OsStr; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + #[test] fn transport_disappearance_then_reconnect_requires_a_fresh_ptt_press() { - let address = "AA:BB:CC:DD:EE:01".to_owned(); + let address = address("AA:BB:CC:DD:EE:01"); for disappear_at in [2, 3] { let current = if disappear_at == 3 { 41 } else { 17 }; let connected = serde_json::json!([{ "type":"PipeWire:Interface:Device", "id":10, "info":{ "props":{ - "api.bluez5.address":address, + "api.bluez5.address":address.to_string(), "api.bluez5.path":"/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01", "object.serial":1010 }, @@ -462,8 +466,8 @@ mod tests { #[test] fn queued_transport_disappearance_is_retired_even_after_reconnection() { - let a = "AA:BB:CC:DD:EE:01".to_owned(); - let b = "AA:BB:CC:DD:EE:02".to_owned(); + let a = address("AA:BB:CC:DD:EE:01"); + let b = address("AA:BB:CC:DD:EE:02"); for disappear_at in [2, 3] { let device = |id: u64, address: &str, current: u64| { serde_json::json!({ @@ -536,7 +540,7 @@ mod tests { fn validates_address_before_invoking_commands() { assert!(parse_address("invalid; rm -rf /").is_err()); assert_eq!( - parse_address("aa:bb:cc:dd:ee:ff").unwrap(), + parse_address("aa:bb:cc:dd:ee:ff").unwrap().as_str(), "AA:BB:CC:DD:EE:FF" ); } @@ -602,13 +606,10 @@ mod tests { ]; assert_eq!( addresses(&input).unwrap(), - BTreeSet::from([ - "AA:BB:CC:DD:EE:01".to_string(), - "AA:BB:CC:DD:EE:02".to_string(), - ]) + BTreeSet::from([address("AA:BB:CC:DD:EE:01"), address("AA:BB:CC:DD:EE:02"),]) ); assert!(addresses(&[]).is_err()); - assert!(addresses(&["AA:BB:CC:DD:EE:01".into(), "invalid".into()]).is_err()); + assert!(parse_address("invalid").is_err()); } fn temporary_network_path() -> PathBuf { @@ -638,10 +639,7 @@ mod tests { #[test] fn saves_and_restores_headset_network() { let path = temporary_network_path(); - let allowed = BTreeSet::from([ - "AA:BB:CC:DD:EE:02".to_string(), - "AA:BB:CC:DD:EE:01".to_string(), - ]); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:02"), address("AA:BB:CC:DD:EE:01")]); save_headsets(&path, &allowed).unwrap(); assert_eq!(load_headsets(&path).unwrap(), allowed); assert_eq!( @@ -654,17 +652,14 @@ mod tests { #[test] fn removes_headsets_from_saved_network() { let path = temporary_network_path(); - let allowed = BTreeSet::from([ - "AA:BB:CC:DD:EE:01".to_string(), - "AA:BB:CC:DD:EE:02".to_string(), - ]); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01"), address("AA:BB:CC:DD:EE:02")]); save_headsets(&path, &allowed).unwrap(); - remove_headset(&path, "AA:BB:CC:DD:EE:01").unwrap(); + remove_headset(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); assert_eq!( load_headsets(&path).unwrap(), - BTreeSet::from(["AA:BB:CC:DD:EE:02".to_string()]) + BTreeSet::from([address("AA:BB:CC:DD:EE:02")]) ); fs::remove_dir_all(path.parent().unwrap()).unwrap(); } @@ -672,12 +667,12 @@ mod tests { #[test] fn removes_last_headset_and_rejects_unknown_address_without_changes() { let path = temporary_network_path(); - let allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".to_string()]); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01")]); save_headsets(&path, &allowed).unwrap(); - assert!(remove_headset(&path, "AA:BB:CC:DD:EE:02").is_err()); + assert!(remove_headset(&path, &address("AA:BB:CC:DD:EE:02")).is_err()); assert_eq!(load_headsets(&path).unwrap(), allowed); - remove_headset(&path, "AA:BB:CC:DD:EE:01").unwrap(); + remove_headset(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); assert!(!path.exists()); assert!(load_headsets(&path).is_err()); fs::remove_dir_all(path.parent().unwrap()).unwrap(); @@ -690,7 +685,7 @@ mod tests { fs::write(&path, "\naa:bb:cc:dd:ee:01\nAA:BB:CC:DD:EE:01\n \n").unwrap(); assert_eq!( load_headsets(&path).unwrap(), - BTreeSet::from(["AA:BB:CC:DD:EE:01".to_string()]) + BTreeSet::from([address("AA:BB:CC:DD:EE:01")]) ); fs::write(&path, "not-a-bluetooth-address\n").unwrap(); assert!(load_headsets(&path).is_err()); @@ -785,7 +780,7 @@ mod tests { #[test] fn connects_only_when_disconnected_or_info_fails() { let device = "AA:BB:CC:DD:EE:01"; - let allowed = BTreeSet::from([device.to_string()]); + let allowed = BTreeSet::from([address(device)]); for (info, should_connect) in [ (Ok("Connected: yes\n"), false), (Ok("Connected: no\n"), true), @@ -918,8 +913,8 @@ mod tests { #[test] fn parses_ptt_without_changing_default_mode() { - let address = "AA:BB:CC:DD:EE:01".to_string(); - let default = Cli::try_parse_from(["bt-intercom", "run", address.as_str()]).unwrap(); + let raw_address = "AA:BB:CC:DD:EE:01"; + let default = Cli::try_parse_from(["bt-intercom", "run", raw_address]).unwrap(); let CliCommand::Run { ptt, mode, @@ -933,13 +928,13 @@ mod tests { assert_eq!(mode, Mode::FullDuplex); assert!(!dashboard); - let binding = format!("{address}=/dev/input/event4"); + let binding = format!("{raw_address}=/dev/input/event4"); let configured = Cli::try_parse_from([ "bt-intercom", "run", "--ptt", binding.as_str(), - address.as_str(), + raw_address, "--connect", "--dashboard", ]) @@ -954,11 +949,11 @@ mod tests { else { panic!("expected run command"); }; - assert_eq!(addresses, [address]); + assert_eq!(addresses, [address(raw_address)]); assert!(connect && dashboard); assert_eq!(ptt.len(), 1); assert_eq!(ptt[0].path, "/dev/input/event4"); - assert_eq!(ptt[0].address, "AA:BB:CC:DD:EE:01"); + assert_eq!(ptt[0].address, Some(address(raw_address))); } #[test] @@ -980,7 +975,7 @@ mod tests { assert!(validate_mode(Mode::HalfDuplex, &[], &allowed).is_err()); assert!(validate_mode(Mode::HalfDuplex, &[], &BTreeSet::new()).is_err()); let buttons = [PttButton { - address: "AA:BB:CC:DD:EE:01".into(), + address: Some(address("AA:BB:CC:DD:EE:01")), path: "/dev/input/event1".into(), }]; assert!(validate_mode(Mode::HalfDuplex, &buttons, &allowed).is_ok()); @@ -1035,25 +1030,25 @@ mod tests { #[test] fn confirmations_require_a_granted_source_and_owned_live_route() { use serde_json::json; - let a = "AA:BB:CC:DD:EE:01"; - let b = "AA:BB:CC:DD:EE:02"; + let a = address("AA:BB:CC:DD:EE:01"); + let b = address("AA:BB:CC:DD:EE:02"); let mut objects = Vec::new(); - for (base, address) in [(10, a), (20, b)] { + for (base, address) in [(10, a.clone()), (20, b.clone())] { objects.extend([ - json!({"type":"PipeWire:Interface:Device","id":base,"info":{"props":{"api.bluez5.address":address}}}), + json!({"type":"PipeWire:Interface:Device","id":base,"info":{"props":{"api.bluez5.address":address.to_string()}}}), json!({"type":"PipeWire:Interface:Node","id":base+1,"info":{"props":{"device.id":base.to_string(),"media.class":"Audio/Source","api.bluez5.profile":"headset-head-unit"}}}), json!({"type":"PipeWire:Interface:Node","id":base+2,"info":{"props":{"device.id":base.to_string(),"media.class":"Audio/Sink","api.bluez5.profile":"headset-head-unit"}}}), json!({"type":"PipeWire:Interface:Port","id":base+3,"info":{"props":{"node.id":(base+1).to_string(),"port.direction":"out","audio.channel":"MONO"}}}), json!({"type":"PipeWire:Interface:Port","id":base+4,"info":{"props":{"node.id":(base+2).to_string(),"port.direction":"in","audio.channel":"MONO"}}}), ]); } - let allowed = BTreeSet::from([a.into(), b.into()]); + let allowed = BTreeSet::from([a.clone(), b.clone()]); let (headsets, _) = router::topology(&json!(objects), &allowed).unwrap(); let stopped = AtomicBool::new(false); let mut transmit = Transmit::new(Mode::HalfDuplex, allowed); let now = Instant::now(); - transmit.event(a, true, now); - transmit.event(b, true, now); + transmit.event(&a, true, now); + transmit.event(&b, true, now); let (sender, input) = mpsc::channel(); // A queued microphone's live route (or an incoming-only route to the // floor holder) must not earn a confirmation. @@ -1067,7 +1062,7 @@ mod tests { &stopped, ) .unwrap(); - assert_eq!(transmit.pending, BTreeSet::from([a.into()])); + assert_eq!(transmit.pending, BTreeSet::from([a.clone()])); confirm_transmissions( Mode::HalfDuplex, &mut transmit, @@ -1078,7 +1073,7 @@ mod tests { &stopped, ) .unwrap(); - assert_eq!(transmit.pending, BTreeSet::from([a.into()])); + assert_eq!(transmit.pending, BTreeSet::from([a.clone()])); assert_eq!( confirm_transmissions( Mode::HalfDuplex, @@ -1091,13 +1086,13 @@ mod tests { ), Err("could not play PTT confirmation for AA:BB:CC:DD:EE:01: confirmation beep is unavailable".into()) ); - assert_eq!(transmit.pending, BTreeSet::from([a.into()])); + assert_eq!(transmit.pending, BTreeSet::from([a.clone()])); // Release arriving during routing retires the old grant and defers // confirmation of the next source until its own routing update. sender - .send(Ok((a.into(), false, now + Duration::from_millis(1)))) + .send(Ok((a.clone(), false, now + Duration::from_millis(1)))) .unwrap(); - transmit.pending.insert(a.into()); + transmit.pending.insert(a.clone()); assert!( confirm_transmissions( Mode::HalfDuplex, @@ -1110,21 +1105,30 @@ mod tests { ) .unwrap() ); - assert_eq!(transmit.sources(), &BTreeSet::from([b.into()])); - assert_eq!(transmit.pending, BTreeSet::from([b.into()])); + assert_eq!(transmit.sources(), &BTreeSet::from([b.clone()])); + assert_eq!(transmit.pending, BTreeSet::from([b])); } #[test] fn draining_delayed_input_uses_capture_timestamps_not_processing_time() { - let mut transmit = Transmit::new(Mode::FullDuplex, ["a".into()]); + let input_address = address("AA:BB:CC:DD:EE:01"); + let mut transmit = Transmit::new(Mode::FullDuplex, [input_address.clone()]); let now = Instant::now(); let (sender, input) = mpsc::channel(); for ms in [0, 500, 1001] { sender - .send(Ok(("a".into(), true, now + Duration::from_millis(ms)))) + .send(Ok(( + input_address.clone(), + true, + now + Duration::from_millis(ms), + ))) .unwrap(); sender - .send(Ok(("a".into(), false, now + Duration::from_millis(ms + 1)))) + .send(Ok(( + input_address.clone(), + false, + now + Duration::from_millis(ms + 1), + ))) .unwrap(); } assert!(drain_events(&input, &mut transmit).unwrap()); @@ -1140,16 +1144,16 @@ mod tests { #[test] fn validates_ptt_bindings_against_restored_network() { - let first = "AA:BB:CC:DD:EE:01".to_string(); - let second = "AA:BB:CC:DD:EE:02".to_string(); + let first = address("AA:BB:CC:DD:EE:01"); + let second = address("AA:BB:CC:DD:EE:02"); let network = BTreeSet::from([first.clone(), second.clone()]); let buttons = vec![ PttButton { - address: first.clone(), + address: Some(first.clone()), path: "/dev/input/event4".into(), }, PttButton { - address: second.clone(), + address: Some(second.clone()), path: "/dev/input/event5".into(), }, ]; @@ -1158,7 +1162,7 @@ mod tests { let duplicate_path = vec![ buttons[0].clone(), PttButton { - address: second.clone(), + address: Some(second.clone()), path: "/dev/input/event4".into(), }, ]; @@ -1166,7 +1170,7 @@ mod tests { let duplicate_address = vec![ buttons[0].clone(), PttButton { - address: first.clone(), + address: Some(first.clone()), path: "/dev/input/event5".into(), }, ]; @@ -1216,12 +1220,13 @@ mod tests { events.extend(event(kind, key, value)); } let (sender, receiver) = mpsc::channel(); - read_input_events(std::io::Cursor::new(events), "headset".into(), sender); + let input_address = address("AA:BB:CC:DD:EE:01"); + read_input_events(std::io::Cursor::new(events), input_address.clone(), sender); let (address, pressed, press_at) = receiver.recv().unwrap().unwrap(); - assert_eq!((address, pressed), ("headset".into(), true)); + assert_eq!((address, pressed), (input_address.clone(), true)); let (address, pressed, release_at) = receiver.recv().unwrap().unwrap(); - assert_eq!((address, pressed), ("headset".into(), false)); + assert_eq!((address, pressed), (input_address.clone(), false)); assert!(release_at >= press_at); assert!(receiver.recv().unwrap().is_err()); assert!(matches!( @@ -1230,11 +1235,7 @@ mod tests { )); let (sender, receiver) = mpsc::channel(); - read_input_events( - std::io::Cursor::new(event(0, 3, 0)), - "headset".into(), - sender, - ); + read_input_events(std::io::Cursor::new(event(0, 3, 0)), input_address, sender); assert!(receiver.recv().unwrap().is_err()); } diff --git a/src/network.rs b/src/network.rs index f9f15b6..15b6a9f 100644 --- a/src/network.rs +++ b/src/network.rs @@ -8,6 +8,8 @@ use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; +use crate::bluez::Address; + pub(crate) fn headset_path() -> Result { let directory = env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -24,7 +26,7 @@ pub(crate) fn runtime_directory(xdg_runtime_dir: Option<&OsStr>) -> PathBuf { .unwrap_or_else(env::temp_dir) } -pub(crate) fn save(path: &Path, allowed: &BTreeSet) -> Result<(), String> { +pub(crate) fn save(path: &Path, allowed: &BTreeSet
) -> Result<(), String> { if allowed.is_empty() { return Err("cannot save an empty headset network".into()); } @@ -36,7 +38,11 @@ pub(crate) fn save(path: &Path, allowed: &BTreeSet) -> Result<(), String let contents = format!( "{}\n", - allowed.iter().cloned().collect::>().join("\n") + allowed + .iter() + .map(Address::as_str) + .collect::>() + .join("\n") ); crate::atomic_file::write(path, contents.as_bytes()).map_err(|error| { format!( @@ -47,7 +53,7 @@ pub(crate) fn save(path: &Path, allowed: &BTreeSet) -> Result<(), String Ok(()) } -pub(crate) fn remove(path: &Path, address: &str) -> Result<(), String> { +pub(crate) fn remove(path: &Path, address: &Address) -> Result<(), String> { let mut allowed = load(path)?; if !allowed.remove(address) { return Err(format!("{address} is not in the saved headset network")); @@ -65,7 +71,7 @@ pub(crate) fn remove(path: &Path, address: &str) -> Result<(), String> { Ok(()) } -pub(crate) fn load(path: &Path) -> Result, String> { +pub(crate) fn load(path: &Path) -> Result, String> { let contents = fs::read_to_string(path).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { format!( @@ -83,15 +89,13 @@ pub(crate) fn load(path: &Path) -> Result, String> { for (index, line) in contents.lines().enumerate() { let line = line.trim(); if !line.is_empty() { - allowed.insert( - crate::bluez::normalize_address(line).ok_or_else(|| { - format!( - "invalid headset network at {}:{}: expected a Bluetooth MAC address (XX:XX:XX:XX:XX:XX): {line}", - path.display(), - index + 1 - ) - })?, - ); + allowed.insert(crate::bluez::parse_address(line).map_err(|error| { + format!( + "invalid headset network at {}:{}: {error}", + path.display(), + index + 1 + ) + })?); } } if allowed.is_empty() { @@ -103,7 +107,7 @@ pub(crate) fn load(path: &Path) -> Result, String> { Ok(allowed) } -pub(crate) fn load_for_run(path: &Path, pair_button: bool) -> Result, String> { +pub(crate) fn load_for_run(path: &Path, pair_button: bool) -> Result, String> { if pair_button && !path.try_exists().map_err(|error| error.to_string())? { Ok(BTreeSet::new()) } else { @@ -113,8 +117,8 @@ pub(crate) fn load_for_run(path: &Path, pair_button: bool) -> Result, - device: String, + allowed: &mut BTreeSet
, + device: Address, ) -> Result<(), String> { let mut enrolled = allowed.clone(); enrolled.insert(device); diff --git a/src/pair_button.rs b/src/pair_button.rs index f317925..4e4a88f 100644 --- a/src/pair_button.rs +++ b/src/pair_button.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; use rppal::gpio::{Gpio, InputPin}; -use crate::bluez::device_flag; +use crate::bluez::{Address, device_flag}; use crate::process::COMMAND_TIMEOUT; use crate::worker::Worker; @@ -58,7 +58,7 @@ impl Button { } pub(crate) struct PairButton { - pub(crate) events: Receiver>, + pub(crate) events: Receiver>, _worker: Worker, } @@ -78,7 +78,7 @@ impl PairButton { pub(crate) fn start( pin: InputPin, - mut allowed: BTreeSet, + mut allowed: BTreeSet
, cancelled: Arc, network_path: PathBuf, ) -> Self { @@ -138,7 +138,7 @@ impl PairButton { } } -fn discovered_devices(output: &str) -> BTreeSet { +fn discovered_devices(output: &str) -> BTreeSet
{ output .lines() .filter_map(|line| { @@ -163,11 +163,11 @@ fn is_headset(info: &str) -> bool { } fn pair_headset( - allowed: &BTreeSet, + allowed: &BTreeSet
, mut execute: impl FnMut(&[&str], Duration) -> Result, mut pair: impl FnMut(&str) -> Result<(), String>, mut rollback: impl FnMut(&str) -> Result<(), String>, -) -> Result { +) -> Result { let scan = execute( &["bluetoothctl", "--timeout", "15", "scan", "bredr"], Duration::from_secs(20), @@ -410,8 +410,8 @@ mod tests { |_| panic!("successful pairing must not be rolled back"), ) .unwrap(); - assert_eq!(result, "AA:BB:CC:DD:EE:01"); - assert_eq!(paired, Some(result)); + assert_eq!(result.as_str(), "AA:BB:CC:DD:EE:01"); + assert_eq!(paired.as_deref(), Some(result.as_str())); assert_eq!(calls.len(), 6); assert!(calls[3].contains("trust")); assert!(calls[5].contains("connect")); diff --git a/src/pipewire.rs b/src/pipewire.rs index 4d63b60..8d98a85 100644 --- a/src/pipewire.rs +++ b/src/pipewire.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; +use crate::bluez::Address; use crate::transport::{Device, Transport, profile_matches}; pub(crate) const OWNER_PROPERTY: &str = "bt-intercom.owner"; @@ -43,7 +44,7 @@ impl Headset { #[derive(Debug)] struct DeviceMetadata { - address: String, + address: Address, transport: Option, } @@ -66,7 +67,7 @@ struct AudioPort { #[derive(Debug)] pub(crate) struct Snapshot { devices: BTreeMap, - device_observations: Vec<(String, Option)>, + device_observations: Vec<(Address, Option)>, nodes: BTreeMap, ports: Vec, links: BTreeSet, @@ -115,7 +116,9 @@ impl Snapshot { match object["type"].as_str() { Some("PipeWire:Interface:Device") => { if let Some(address) = text(props, "api.bluez5.address") { - let address = address.to_ascii_uppercase(); + let Some(address) = Address::parse(&address).ok() else { + continue; + }; snapshot .device_observations .push((address.clone(), text(props, "api.bluez5.path"))); @@ -171,7 +174,7 @@ impl Snapshot { Ok(snapshot) } - pub(crate) fn addresses(&self) -> BTreeSet { + pub(crate) fn addresses(&self) -> BTreeSet
{ self.devices .values() .map(|device| device.address.clone()) @@ -180,8 +183,8 @@ impl Snapshot { pub(crate) fn transport_devices( &self, - allowed: &BTreeSet, - ) -> Result, String> { + allowed: &BTreeSet
, + ) -> Result, String> { let mut devices = BTreeMap::new(); let mut observed = BTreeMap::new(); for (address, path) in &self.device_observations { @@ -219,9 +222,9 @@ impl Snapshot { pub(crate) fn topology( &self, - allowed: &BTreeSet, + allowed: &BTreeSet
, transport: Transport, - ) -> (BTreeMap, BTreeSet) { + ) -> (BTreeMap, BTreeSet) { let mut headsets: BTreeMap<_, _> = self .devices .values() @@ -293,7 +296,7 @@ mod tests { #[test] fn metadata_is_consistent_across_topology_transport_and_ownership() { let snapshot = Snapshot::parse(&fixture()).unwrap(); - let allowed = [ADDRESS.to_string()].into(); + let allowed = [Address::parse(ADDRESS).unwrap()].into(); let devices = snapshot.transport_devices(&allowed).unwrap(); let (headsets, links) = snapshot.topology(&allowed, Transport::Hfp); assert_eq!(snapshot.addresses(), allowed); @@ -315,7 +318,7 @@ mod tests { let mut value = fixture(); value[1]["info"]["props"]["device.id"] = json!(999); let snapshot = Snapshot::parse(&value).unwrap(); - let allowed = [ADDRESS.to_string()].into(); + let allowed = [Address::parse(ADDRESS).unwrap()].into(); assert!( snapshot.topology(&allowed, Transport::Hfp).0[ADDRESS] .sources diff --git a/src/ptt.rs b/src/ptt.rs index 3b25296..72a8a39 100644 --- a/src/ptt.rs +++ b/src/ptt.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use std::time::{Duration, Instant}; +use crate::bluez::Address; use crate::network::runtime_directory; use crate::process::command_cancellable; use crate::router::Headset; @@ -26,11 +27,11 @@ pub(crate) const KEY_PLAYPAUSE: u16 = 164; #[derive(Clone, Debug)] pub(crate) struct PttButton { - pub address: String, + pub address: Option
, pub path: String, } -pub(crate) type PttEvent = Result<(String, bool, Instant), String>; +pub(crate) type PttEvent = Result<(Address, bool, Instant), String>; pub(crate) struct PttBeep { path: PathBuf, @@ -82,7 +83,7 @@ pub(crate) fn confirm_transmissions( mode: Mode, transmit: &mut Transmit, input: &Receiver, - headsets: &BTreeMap, + headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>, beep: Option<&PttBeep>, stopped: &AtomicBool, @@ -131,8 +132,8 @@ pub(crate) fn confirm_transmissions( } pub(crate) fn confirm_connections( - routed_headsets: &mut BTreeSet, - headsets: &BTreeMap, + routed_headsets: &mut BTreeSet
, + headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>, beep: Option<&PttBeep>, stopped: &AtomicBool, @@ -177,7 +178,7 @@ pub(crate) fn drain_events( } #[cfg(test)] -pub(crate) fn read_input_events(input: R, address: String, sender: Sender) { +pub(crate) fn read_input_events(input: R, address: Address, sender: Sender) { let (_wake, receiver) = mpsc::channel(); read_events(input, address, sender, &AtomicBool::new(false), &receiver); } @@ -213,7 +214,10 @@ impl PreparedInputs { .map(|(button, file)| { let sender = sender.clone(); Worker::spawn(Arc::clone(&stopped), move |stopped, wake| { - read_events(file, button.address, sender, &stopped, &wake); + let Some(address) = button.address else { + return; + }; + read_events(file, address, sender, &stopped, &wake); }) }) .collect(); @@ -241,7 +245,7 @@ impl PttInput { /// Preserve the prefix of a record across short reads and WouldBlock. pub(crate) fn read_events( mut input: R, - address: String, + address: Address, sender: Sender, stopped: &AtomicBool, wake: &Receiver<()>, @@ -316,7 +320,13 @@ mod tests { input.set_nonblocking(true).unwrap(); let (sender, events) = mpsc::channel(); let worker = Worker::spawn(Arc::new(AtomicBool::new(false)), move |stopped, wake| { - read_events(input, "headset".into(), sender, &stopped, &wake); + read_events( + input, + Address::parse("AA:BB:CC:DD:EE:01").unwrap(), + sender, + &stopped, + &wake, + ); }); (output, events, worker) } @@ -363,7 +373,7 @@ mod tests { .recv_timeout(Duration::from_secs(1)) .unwrap() .unwrap(); - assert_eq!(address, "headset"); + assert_eq!(address.as_str(), "AA:BB:CC:DD:EE:01"); assert_eq!(actual, pressed); } drop(worker); diff --git a/src/ptt_discovery.rs b/src/ptt_discovery.rs index a40a002..f0798bd 100644 --- a/src/ptt_discovery.rs +++ b/src/ptt_discovery.rs @@ -8,6 +8,7 @@ use std::fs; use std::path::Path; +use crate::bluez::Address; use crate::ptt::{KEY_PLAYPAUSE, PttButton}; /// Value accepted in place of an input device path to request discovery. @@ -28,7 +29,7 @@ fn has_key(mask: &str, code: u16) -> bool { } /// Finds the event device that reports play/pause for `address` under `root`. -fn find_in(root: &Path, address: &str) -> Result { +fn find_in(root: &Path, address: &Address) -> Result { let mut matches = vec![]; let entries = fs::read_dir(root).map_err(|error| format!("{}: {error}", root.display()))?; for entry in entries.flatten() { @@ -38,7 +39,7 @@ fn find_in(root: &Path, address: &str) -> Result { } let device = entry.path().join("device"); let uniq = fs::read_to_string(device.join("uniq")).unwrap_or_default(); - if !uniq.trim().eq_ignore_ascii_case(address) { + if !uniq.trim().eq_ignore_ascii_case(address.as_str()) { continue; } let keys = fs::read_to_string(device.join("capabilities/key")).unwrap_or_default(); @@ -64,7 +65,7 @@ fn find_in(root: &Path, address: &str) -> Result { /// A bare `--ptt auto` expands to every listed headset. pub(crate) fn resolve_pending( buttons: &mut Vec, - allowed: &std::collections::BTreeSet, + allowed: &std::collections::BTreeSet
, ) -> Result<(), String> { resolve_at(Path::new(SYS_INPUT), buttons, allowed) } @@ -72,20 +73,23 @@ pub(crate) fn resolve_pending( fn resolve_at( root: &Path, buttons: &mut Vec, - allowed: &std::collections::BTreeSet, + allowed: &std::collections::BTreeSet
, ) -> Result<(), String> { - if let Some(position) = buttons.iter().position(|b| b.address == AUTO) { + if let Some(position) = buttons.iter().position(|button| button.address.is_none()) { if buttons.len() > 1 { return Err("--ptt auto cannot be combined with other --ptt mappings".into()); } buttons.remove(position); buttons.extend(allowed.iter().map(|address| PttButton { - address: address.clone(), + address: Some(address.clone()), path: AUTO.into(), })); } - for button in buttons.iter_mut().filter(|b| b.path == AUTO) { - button.path = find_in(root, &button.address)?; + for button in buttons.iter_mut().filter(|button| button.path == AUTO) { + let Some(address) = &button.address else { + return Err("--ptt auto must be resolved to a headset address".into()); + }; + button.path = find_in(root, address)?; } Ok(()) } @@ -95,6 +99,10 @@ mod tests { use super::*; use std::collections::BTreeSet; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + fn device(root: &Path, name: &str, uniq: &str, keys: &str) { let dir = root.join(name).join("device"); fs::create_dir_all(dir.join("capabilities")).unwrap(); @@ -131,16 +139,16 @@ mod tests { device(&root, "event4", "aa:bb:cc:dd:ee:02", "0"); device(&root, "event5", "aa:bb:cc:dd:ee:02", &keys); assert_eq!( - find_in(&root, "AA:BB:CC:DD:EE:01").unwrap(), + find_in(&root, &address("AA:BB:CC:DD:EE:01")).unwrap(), "/dev/input/event3" ); assert_eq!( - find_in(&root, "AA:BB:CC:DD:EE:02").unwrap(), + find_in(&root, &address("AA:BB:CC:DD:EE:02")).unwrap(), "/dev/input/event5" ); - assert!(find_in(&root, "AA:BB:CC:DD:EE:03").is_err()); + assert!(find_in(&root, &address("AA:BB:CC:DD:EE:03")).is_err()); device(&root, "event6", "aa:bb:cc:dd:ee:01", &keys); - assert!(find_in(&root, "AA:BB:CC:DD:EE:01").is_err()); + assert!(find_in(&root, &address("AA:BB:CC:DD:EE:01")).is_err()); fs::remove_dir_all(root).unwrap(); } @@ -154,9 +162,9 @@ mod tests { .to_string(); device(&root, "event1", "aa:bb:cc:dd:ee:01", &keys); device(&root, "event2", "aa:bb:cc:dd:ee:02", &keys); - let allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".to_string(), "AA:BB:CC:DD:EE:02".into()]); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01"), address("AA:BB:CC:DD:EE:02")]); let mut buttons = vec![PttButton { - address: AUTO.into(), + address: None, path: AUTO.into(), }]; resolve_at(&root, &mut buttons, &allowed).unwrap(); diff --git a/src/route_policy.rs b/src/route_policy.rs index 124c2a2..d50ba36 100644 --- a/src/route_policy.rs +++ b/src/route_policy.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use crate::bluez::Address; use crate::groups::TalkGroup; use crate::pipewire::{Headset, Link, MONO_CHANNEL, Port}; @@ -16,14 +17,14 @@ pub(crate) fn channels_compatible(output: &Port, input: &Port) -> bool { /// A headset is never linked to itself; mono ports are compatible with every /// channel on the opposite endpoint. #[cfg(test)] -pub(crate) fn desired_links(headsets: &BTreeMap) -> BTreeSet { +pub(crate) fn desired_links(headsets: &BTreeMap) -> BTreeSet { desired_links_with_policy(headsets, None, None) } fn desired_links_with_policy( - headsets: &BTreeMap, - allowed_pairs: Option<&BTreeMap>>, - active_sources: Option<&BTreeSet>, + headsets: &BTreeMap, + allowed_pairs: Option<&BTreeMap>>, + active_sources: Option<&BTreeSet
>, ) -> BTreeSet { let mut desired = BTreeSet::new(); for (source_address, source) in headsets { @@ -51,7 +52,7 @@ fn desired_links_with_policy( desired } -fn group_pairs(groups: &[TalkGroup]) -> BTreeMap> { +fn group_pairs(groups: &[TalkGroup]) -> BTreeMap> { let mut pairs = BTreeMap::new(); for group in groups { for source in &group.members { @@ -69,7 +70,7 @@ fn group_pairs(groups: &[TalkGroup]) -> BTreeMap> { /// An empty group list retains the original all-to-all behavior. #[cfg(test)] pub(crate) fn desired_links_in_groups( - headsets: &BTreeMap, + headsets: &BTreeMap, groups: &[TalkGroup], ) -> BTreeSet { if groups.is_empty() { @@ -83,8 +84,8 @@ pub(crate) fn desired_links_in_groups( /// /// With no configured groups, the `None` entry represents the shared floor. pub(crate) fn desired_links_by_group( - headsets: &BTreeMap, - group_sources: &BTreeMap, BTreeSet>, + headsets: &BTreeMap, + group_sources: &BTreeMap, BTreeSet
>, groups: &[TalkGroup], ) -> BTreeSet { if groups.is_empty() { @@ -108,6 +109,10 @@ pub(crate) fn desired_links_by_group( mod tests { use super::*; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + fn headset(source: u64, sink: u64) -> Headset { Headset { sources: vec![Port { @@ -123,18 +128,21 @@ mod tests { } } - fn headsets() -> BTreeMap { + fn headsets() -> BTreeMap { BTreeMap::from([ - ("A".into(), headset(1, 2)), - ("B".into(), headset(3, 4)), - ("C".into(), headset(5, 6)), + (address("AA:BB:CC:DD:EE:01"), headset(1, 2)), + (address("AA:BB:CC:DD:EE:02"), headset(3, 4)), + (address("AA:BB:CC:DD:EE:03"), headset(5, 6)), ]) } fn group(name: &str, members: &[&str]) -> TalkGroup { TalkGroup { name: name.into(), - members: members.iter().map(|member| (*member).into()).collect(), + members: members + .iter() + .map(|member| crate::bluez::parse_address(member).unwrap()) + .collect(), } } @@ -169,15 +177,18 @@ mod tests { #[test] fn group_policy_limits_peers_and_active_sources_per_group() { let headsets = headsets(); - let groups = [group("North", &["A", "B"]), group("South", &["B", "C"])]; + let groups = [ + group("North", &["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]), + group("South", &["AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03"]), + ]; assert_eq!( desired_links_in_groups(&headsets, &groups), [(1, 4), (3, 2), (3, 6), (5, 4)].into() ); let floors = BTreeMap::from([ - (Some("North".into()), ["A".into()].into()), - (Some("South".into()), ["C".into()].into()), + (Some("North".into()), [address("AA:BB:CC:DD:EE:01")].into()), + (Some("South".into()), [address("AA:BB:CC:DD:EE:03")].into()), ]); assert_eq!( desired_links_by_group(&headsets, &floors, &groups), @@ -186,7 +197,7 @@ mod tests { assert_eq!( desired_links_by_group( &headsets, - &BTreeMap::from([(None, ["A".into()].into())]), + &BTreeMap::from([(None, [address("AA:BB:CC:DD:EE:01")].into())]), &[] ), [(1, 4), (1, 6)].into() diff --git a/src/router.rs b/src/router.rs index 7708024..8aabce5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(test)] use serde_json::Value; +use crate::bluez::Address; use crate::groups::TalkGroup; pub use crate::pipewire::Headset; use crate::pipewire::{Link, OWNER_PROPERTY, Snapshot}; @@ -60,16 +61,16 @@ type StartLink = Box Result, String>>; #[cfg(test)] pub fn topology( objects: &Value, - allowed: &BTreeSet, -) -> Result<(BTreeMap, BTreeSet), String> { + allowed: &BTreeSet
, +) -> Result<(BTreeMap, BTreeSet), String> { Ok(Snapshot::parse(objects)?.topology(allowed, Transport::Hfp)) } /// Returns whether a headset's microphone has a live route to another headset. #[cfg(test)] pub fn has_active_source_route( - address: &str, - headsets: &BTreeMap, + address: &Address, + headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>, ) -> bool { RoutingStatus::new(headsets, links).has_active_source_route(address) @@ -78,8 +79,8 @@ pub fn has_active_source_route( /// Returns whether a headset has live microphone and speaker routes with a peer. #[cfg(test)] pub fn has_active_intercom_connection( - address: &str, - headsets: &BTreeMap, + address: &Address, + headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>, ) -> bool { RoutingStatus::new(headsets, links).has_active_intercom_connection(address) @@ -88,7 +89,7 @@ pub fn has_active_intercom_connection( /// Reconciles desired headset routes with PipeWire while tracking owned links. pub struct Router Result> { /// Bluetooth addresses that may be discovered or routed. - pub allowed: BTreeSet, + pub allowed: BTreeSet
, owner: String, execute: F, start_link: StartLink, @@ -116,7 +117,7 @@ fn start_link(args: &[&str]) -> Result, String> { impl Router { /// Creates a router using the system `pw-dump` and `pw-cli` commands. - pub fn new(allowed: BTreeSet) -> Self { + pub fn new(allowed: BTreeSet
) -> Self { Self::with_executor(allowed, default_command) } } @@ -126,11 +127,11 @@ impl Result> Router { /// /// The executor is useful for tests and must return the command's stdout /// or an error. Link creation still uses the production `pw-cli` backend. - pub fn with_executor(allowed: BTreeSet, execute: F) -> Self { + pub fn with_executor(allowed: BTreeSet
, execute: F) -> Self { Self::with_backend(allowed, execute, Box::new(start_link)) } - fn with_backend(allowed: BTreeSet, execute: F, start_link: StartLink) -> Self { + fn with_backend(allowed: BTreeSet
, execute: F, start_link: StartLink) -> Self { Self { allowed, owner: format!( @@ -160,13 +161,13 @@ impl Result> Router { self.coordinator.set_sco_limit(limit); } - pub fn transport_devices(&mut self) -> Result, String> { + pub fn transport_devices(&mut self) -> Result, String> { let snapshot = self.snapshot()?; snapshot.transport_devices(&self.allowed) } /// Consume observed allowlisted device disappearances, even after reconnect. - pub fn take_transport_disconnects(&mut self) -> BTreeSet { + pub fn take_transport_disconnects(&mut self) -> BTreeSet
{ self.coordinator.take_disconnects() } @@ -174,7 +175,7 @@ impl Result> Router { /// Commands are asynchronous; callers must retry until profiles and ports /// are observed ready, with a 30-second deadline for the entire transition. /// Last selected profiles are intentionally left on exit. - pub fn prepare_transport(&mut self, sources: &BTreeSet) -> Result { + pub fn prepare_transport(&mut self, sources: &BTreeSet
) -> Result { self.coordinator .prepare(sources, &self.allowed, &mut self.execute, || { self.owned.clear() @@ -183,7 +184,7 @@ impl Result> Router { /// Routes every allowlisted microphone when transmitting, or removes all /// owned routes when muted. - pub fn update(&mut self, transmitting: bool) -> Result, String> { + pub fn update(&mut self, transmitting: bool) -> Result, String> { let sources = if transmitting { self.allowed.clone() } else { @@ -198,17 +199,17 @@ impl Result> Router { /// from link ownership checks and link creation. pub fn update_sources( &mut self, - sources: &BTreeSet, - ) -> Result, String> { + sources: &BTreeSet
, + ) -> Result, String> { self.update_sources_in_groups(sources, &[]) } /// Reconciles routes from the specified microphones within shared groups. pub fn update_sources_in_groups( &mut self, - sources: &BTreeSet, + sources: &BTreeSet
, groups: &[TalkGroup], - ) -> Result, String> { + ) -> Result, String> { let group_sources = if groups.is_empty() { BTreeMap::from([(None, sources.clone())]) } else { @@ -223,9 +224,9 @@ impl Result> Router { /// Reconciles routes for sources granted a floor in each talk group. pub fn update_group_sources_in_groups( &mut self, - group_sources: &BTreeMap, BTreeSet>, + group_sources: &BTreeMap, BTreeSet
>, groups: &[TalkGroup], - ) -> Result, String> { + ) -> Result, String> { let sources = group_sources.values().flatten().cloned().collect(); if !self.prepare_transport(&sources)? { log::debug!("Transport not ready for sources {sources:?}; releasing owned links"); @@ -307,13 +308,15 @@ impl Result> Router { } /// Reads the current allowlisted headset topology and all observed links. - pub fn inspect(&mut self) -> Result<(BTreeMap, BTreeSet), String> { + pub fn inspect(&mut self) -> Result<(BTreeMap, BTreeSet), String> { let snapshot = self.snapshot()?; Ok(snapshot.topology(&self.allowed, self.coordinator.transport)) } /// Reads headset topology and only links owned by this router instance. - pub fn inspect_owned(&mut self) -> Result<(BTreeMap, BTreeSet), String> { + pub fn inspect_owned( + &mut self, + ) -> Result<(BTreeMap, BTreeSet), String> { let snapshot = self.snapshot()?; let (headsets, _) = snapshot.topology(&self.allowed, self.coordinator.transport); Ok((headsets, snapshot.owned_links(&self.owner))) @@ -340,6 +343,10 @@ mod tests { const B: &str = "AA:BB:CC:DD:EE:02"; const C: &str = "AA:BB:CC:DD:EE:03"; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + fn headset(base: u64, address: &str) -> Vec { vec![ json!({"type":"PipeWire:Interface:Device","id":base,"info":{"props":{"api.bluez5.address":address}}}), @@ -355,8 +362,8 @@ mod tests { [headset(10, A), headset(20, B)].concat() } - fn allowed() -> BTreeSet { - [A.to_string(), B.to_string()].into() + fn allowed() -> BTreeSet
{ + [address(A), address(B)].into() } fn transport_fixture() -> Vec { @@ -412,8 +419,8 @@ mod tests { *server.objects.borrow_mut() = transport_fixture(); let mut router = server.router(); router.set_transport(Transport::ScoA2dp); - let a = [A.to_string()].into(); - let b = [B.to_string()].into(); + let a = [address(A)].into(); + let b = [address(B)].into(); assert!(!router.prepare_transport(&a).unwrap()); assert!(!router.prepare_transport(&a).unwrap()); let changes = || { @@ -464,8 +471,8 @@ mod tests { observe_profile(&server, 20, false); let mut router = server.router(); router.set_transport(Transport::ScoA2dp); - assert!(!router.prepare_transport(&[A.to_string()].into()).unwrap()); - assert!(!router.prepare_transport(&[B.to_string()].into()).unwrap()); + assert!(!router.prepare_transport(&[address(A)].into()).unwrap()); + assert!(!router.prepare_transport(&[address(B)].into()).unwrap()); let changes = || { server .calls @@ -477,7 +484,7 @@ mod tests { }; assert_eq!(changes().len(), 1); observe_profile(&server, 10, true); - assert!(!router.prepare_transport(&[B.to_string()].into()).unwrap()); + assert!(!router.prepare_transport(&[address(B)].into()).unwrap()); assert_eq!(changes().len(), 2); assert_eq!(changes()[1][2], "10"); assert_eq!(changes()[1][4], "{ index: 41, save: false }"); @@ -504,7 +511,7 @@ mod tests { json!("no"); assert!( router - .prepare_transport(&[A.to_string()].into()) + .prepare_transport(&[address(A)].into()) .unwrap_err() .contains("no available advertised A2DP SBC") ); @@ -516,7 +523,7 @@ mod tests { json!("no"); assert!( router - .prepare_transport(&[A.to_string()].into()) + .prepare_transport(&[address(A)].into()) .unwrap_err() .contains("no available advertised headset-head-unit") ); @@ -525,7 +532,7 @@ mod tests { json!("no"); assert!( router - .prepare_transport(&[A.to_string()].into()) + .prepare_transport(&[address(A)].into()) .unwrap_err() .contains("no available advertised A2DP SBC") ); @@ -542,7 +549,7 @@ mod tests { }); router.set_transport(Transport::ScoA2dp); assert!(!router.transport_devices().unwrap().contains_key(B)); - assert!(router.prepare_transport(&[A.to_string()].into()).is_err()); + assert!(router.prepare_transport(&[address(A)].into()).is_err()); let mut calls = 0; let mut router = Router::with_executor(allowed(), |args: &[&str]| { @@ -557,7 +564,7 @@ mod tests { router.set_transport(Transport::ScoA2dp); assert!( router - .prepare_transport(&[A.to_string()].into()) + .prepare_transport(&[address(A)].into()) .unwrap_err() .contains("identity changed") ); @@ -575,11 +582,11 @@ mod tests { *server.objects.borrow_mut() = objects; let mut router = server.router(); router.set_transport(Transport::ScoA2dp); - assert!(router.prepare_transport(&[A.to_string()].into()).unwrap()); - assert!(!router.prepare_transport(&[B.to_string()].into()).unwrap()); + assert!(router.prepare_transport(&[address(A)].into()).unwrap()); + assert!(!router.prepare_transport(&[address(B)].into()).unwrap()); assert!( router - .prepare_transport(&[C.to_string()].into()) + .prepare_transport(&[address(C)].into()) .unwrap_err() .contains("not allowlisted") ); @@ -599,7 +606,7 @@ mod tests { Ok(json!(objects).to_string()) }); router.set_transport(Transport::ScoA2dp); - assert!(!router.prepare_transport(&[A.to_string()].into()).unwrap()); + assert!(!router.prepare_transport(&[address(A)].into()).unwrap()); } #[test] @@ -618,7 +625,7 @@ mod tests { Ok(json!(*server.objects.borrow()).to_string()) }); router.set_transport(Transport::ScoA2dp); - assert!(!router.prepare_transport(&[A.to_string()].into()).unwrap()); + assert!(!router.prepare_transport(&[address(A)].into()).unwrap()); } #[test] @@ -632,7 +639,7 @@ mod tests { .retain(|object| object["id"] != 13); let mut router = server.router(); router.set_transport(Transport::ScoA2dp); - let sources = [A.to_string()].into(); + let sources = [address(A)].into(); assert!(!router.prepare_transport(&sources).unwrap()); assert!(router.coordinator.profile_changes.is_empty()); let started = router.coordinator.transition_started; @@ -685,7 +692,7 @@ mod tests { *server.objects.borrow_mut() = transport_fixture(); let mut router = server.router(); router.set_transport(Transport::ScoA2dp); - let sources = [A.to_string()].into(); + let sources = [address(A)].into(); assert!(!router.prepare_transport(&sources).unwrap()); server.objects.borrow_mut()[6]["info"]["params"]["Profile"] = json!([{"index":41}]); assert!(!router.prepare_transport(&sources).unwrap()); @@ -720,7 +727,7 @@ mod tests { router.set_transport(Transport::ScoA2dp); assert!( router - .prepare_transport(&[A.to_string()].into()) + .prepare_transport(&[address(A)].into()) .unwrap_err() .contains("multiple live PipeWire Device") ); @@ -752,9 +759,9 @@ mod tests { router.update(true).unwrap(); router.set_transport(Transport::ScoA2dp); assert_eq!(server.links(), 1); - assert!(!router.prepare_transport(&[A.to_string()].into()).unwrap()); + assert!(!router.prepare_transport(&[address(A)].into()).unwrap()); observe_profile(&server, 20, false); - assert!(router.prepare_transport(&[A.to_string()].into()).unwrap()); + assert!(router.prepare_transport(&[address(A)].into()).unwrap()); router.close(); assert_eq!(server.links(), 1); assert_eq!( @@ -773,7 +780,7 @@ mod tests { desired_links(&headsets), [(13, 24), (13, 25), (23, 14), (23, 15)].into() ); - let (headsets, _) = topology(&json!(fixture()), &[A.to_string()].into()).unwrap(); + let (headsets, _) = topology(&json!(fixture()), &[address(A)].into()).unwrap(); assert!(desired_links(&headsets).is_empty()); } @@ -786,11 +793,11 @@ mod tests { #[test] fn routes_only_headsets_sharing_a_talk_group() { let objects = [fixture(), headset(30, C)].concat(); - let allowed = [A.to_string(), B.to_string(), C.to_string()].into(); + let allowed = [address(A), address(B), address(C)].into(); let (headsets, _) = topology(&json!(objects), &allowed).unwrap(); let groups = [TalkGroup { name: "Team".into(), - members: [A.to_string(), C.to_string()].into(), + members: [address(A), address(C)].into(), }]; assert_eq!( desired_links_in_groups(&headsets, &groups), @@ -805,21 +812,21 @@ mod tests { #[test] fn half_duplex_routes_each_source_only_in_groups_where_it_holds_the_floor() { let objects = [fixture(), headset(30, C)].concat(); - let allowed = [A.to_string(), B.to_string(), C.to_string()].into(); + let allowed = [address(A), address(B), address(C)].into(); let (headsets, _) = topology(&json!(objects), &allowed).unwrap(); let groups = [ TalkGroup { name: "North".into(), - members: [A.to_string(), B.to_string()].into(), + members: [address(A), address(B)].into(), }, TalkGroup { name: "South".into(), - members: [A.to_string(), B.to_string(), C.to_string()].into(), + members: [address(A), address(B), address(C)].into(), }, ]; let sources = BTreeMap::from([ - (Some("North".into()), [A.to_string()].into()), - (Some("South".into()), [C.to_string()].into()), + (Some("North".into()), [address(A)].into()), + (Some("South".into()), [address(C)].into()), ]); assert_eq!( desired_links_by_group(&headsets, &sources, &groups), @@ -831,27 +838,47 @@ mod tests { fn detects_live_routes_from_each_headset_microphone() { let (headsets, _) = topology(&json!(fixture()), &allowed()).unwrap(); let links = desired_links(&headsets); - assert!(has_active_source_route(A, &headsets, &links)); - assert!(has_active_source_route(B, &headsets, &links)); + assert!(has_active_source_route(&address(A), &headsets, &links)); + assert!(has_active_source_route(&address(B), &headsets, &links)); let links = links .into_iter() .filter(|(output, _)| *output != 13) .collect(); - assert!(!has_active_source_route(A, &headsets, &links)); - assert!(has_active_source_route(B, &headsets, &links)); + assert!(!has_active_source_route(&address(A), &headsets, &links)); + assert!(has_active_source_route(&address(B), &headsets, &links)); } #[test] fn detects_bidirectional_intercom_connections() { let (headsets, _) = topology(&json!(fixture()), &allowed()).unwrap(); let links = desired_links(&headsets); - assert!(has_active_intercom_connection(A, &headsets, &links)); - assert!(has_active_intercom_connection(B, &headsets, &links)); + assert!(has_active_intercom_connection( + &address(A), + &headsets, + &links + )); + assert!(has_active_intercom_connection( + &address(B), + &headsets, + &links + )); let one_way = [(13, 24), (13, 25)].into(); - assert!(!has_active_intercom_connection(A, &headsets, &one_way)); - assert!(!has_active_intercom_connection(B, &headsets, &one_way)); - let (single, _) = topology(&json!(headset(10, A)), &[A.to_string()].into()).unwrap(); - assert!(!has_active_intercom_connection(A, &single, &links)); + assert!(!has_active_intercom_connection( + &address(A), + &headsets, + &one_way + )); + assert!(!has_active_intercom_connection( + &address(B), + &headsets, + &one_way + )); + let (single, _) = topology(&json!(headset(10, A)), &[address(A)].into()).unwrap(); + assert!(!has_active_intercom_connection( + &address(A), + &single, + &links + )); } #[test] @@ -861,7 +888,7 @@ mod tests { let mut sources = BTreeSet::new(); router.update_sources(&sources).unwrap(); assert_eq!(server.links(), 0); - sources.insert(A.to_string()); + sources.insert(address(A)); router.update_sources(&sources).unwrap(); let (_, links) = router.inspect_owned().unwrap(); assert_eq!(links, [(13, 24), (13, 25)].into()); diff --git a/src/routing_status.rs b/src/routing_status.rs index 3ffdb36..2440d16 100644 --- a/src/routing_status.rs +++ b/src/routing_status.rs @@ -2,17 +2,18 @@ use std::collections::{BTreeMap, BTreeSet}; +use crate::bluez::Address; use crate::pipewire::Headset; use crate::route_policy::channels_compatible; pub struct RoutingStatus { - counts: BTreeMap, - peers: BTreeSet<(String, String)>, + counts: BTreeMap, + peers: BTreeSet<(Address, Address)>, total: usize, } impl RoutingStatus { - pub fn new(headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>) -> Self { + pub fn new(headsets: &BTreeMap, links: &BTreeSet<(u64, u64)>) -> Self { let mut status = Self { counts: BTreeMap::new(), peers: BTreeSet::new(), @@ -47,7 +48,7 @@ impl RoutingStatus { } /// Observed outgoing and incoming channel-link counts, respectively. - pub fn counts(&self, address: &str) -> (usize, usize) { + pub fn counts(&self, address: &Address) -> (usize, usize) { self.counts.get(address).copied().unwrap_or_default() } @@ -55,11 +56,11 @@ impl RoutingStatus { self.total } - pub fn has_active_source_route(&self, address: &str) -> bool { + pub fn has_active_source_route(&self, address: &Address) -> bool { self.counts(address).0 > 0 } - pub fn has_active_intercom_connection(&self, address: &str) -> bool { + pub fn has_active_intercom_connection(&self, address: &Address) -> bool { self.peers.iter().any(|(source, sink)| { source == address && self.peers.contains(&(sink.clone(), source.clone())) }) @@ -71,6 +72,10 @@ mod tests { use super::*; use crate::pipewire::Port; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + fn port(id: u64, channel: &str) -> Port { Port { id, @@ -79,24 +84,24 @@ mod tests { } } - fn headsets() -> BTreeMap { + fn headsets() -> BTreeMap { BTreeMap::from([ ( - "A".into(), + address("AA:BB:CC:DD:EE:01"), Headset { sources: vec![port(1, "FL")], sinks: vec![port(2, "FL")], }, ), ( - "B".into(), + address("AA:BB:CC:DD:EE:02"), Headset { sources: vec![port(3, "MONO")], sinks: vec![port(4, "FL"), port(5, "FR")], }, ), ( - "C".into(), + address("AA:BB:CC:DD:EE:03"), Headset { sources: vec![port(6, "MONO")], sinks: vec![port(7, "MONO")], @@ -112,21 +117,21 @@ mod tests { &[(1, 2), (1, 5), (1, 99), (99, 2), (1, 4), (3, 2)].into(), ); assert_eq!(status.total_links(), 2); - assert_eq!(status.counts("A"), (1, 1)); - assert_eq!(status.counts("B"), (1, 1)); - assert_eq!(status.counts("missing"), (0, 0)); - assert!(status.has_active_source_route("A")); - assert!(status.has_active_intercom_connection("A")); - assert!(!status.has_active_source_route("C")); - assert!(!status.has_active_intercom_connection("missing")); + assert_eq!(status.counts(&address("AA:BB:CC:DD:EE:01")), (1, 1)); + assert_eq!(status.counts(&address("AA:BB:CC:DD:EE:02")), (1, 1)); + assert_eq!(status.counts(&address("AA:BB:CC:DD:EE:04")), (0, 0)); + assert!(status.has_active_source_route(&address("AA:BB:CC:DD:EE:01"))); + assert!(status.has_active_intercom_connection(&address("AA:BB:CC:DD:EE:01"))); + assert!(!status.has_active_source_route(&address("AA:BB:CC:DD:EE:03"))); + assert!(!status.has_active_intercom_connection(&address("AA:BB:CC:DD:EE:04"))); } #[test] fn duplex_readiness_requires_a_common_peer_and_observed_links() { let status = RoutingStatus::new(&headsets(), &[(1, 4), (6, 2)].into()); - assert_eq!(status.counts("A"), (1, 1)); - assert!(status.has_active_source_route("A")); - assert!(!status.has_active_intercom_connection("A")); + assert_eq!(status.counts(&address("AA:BB:CC:DD:EE:01")), (1, 1)); + assert!(status.has_active_source_route(&address("AA:BB:CC:DD:EE:01"))); + assert!(!status.has_active_intercom_connection(&address("AA:BB:CC:DD:EE:01"))); assert_eq!( RoutingStatus::new(&headsets(), &BTreeSet::new()).total_links(), 0 @@ -136,9 +141,13 @@ mod tests { #[test] fn repeated_port_metadata_does_not_inflate_counts() { let mut headsets = headsets(); - headsets.get_mut("A").unwrap().sources.push(port(1, "FL")); + headsets + .get_mut(&address("AA:BB:CC:DD:EE:01")) + .unwrap() + .sources + .push(port(1, "FL")); let status = RoutingStatus::new(&headsets, &[(1, 4)].into()); assert_eq!(status.total_links(), 1); - assert_eq!(status.counts("A"), (1, 0)); + assert_eq!(status.counts(&address("AA:BB:CC:DD:EE:01")), (1, 0)); } } diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 88f5b25..bda7527 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -6,6 +6,7 @@ use std::collections::BTreeSet; use std::path::PathBuf; use std::time::Duration; +use crate::bluez::Address; use crate::groups::{self, TalkGroup}; use crate::network; use crate::ptt::PttButton; @@ -27,13 +28,16 @@ pub(crate) fn parse_interval(value: &str) -> Result { pub(crate) fn validate_ptt( buttons: &[PttButton], - allowed: &BTreeSet, + allowed: &BTreeSet
, ) -> Result<(), String> { let mut configured = BTreeSet::new(); let mut paths = BTreeSet::new(); for button in buttons { - if (!allowed.is_empty() && !allowed.contains(&button.address)) - || !configured.insert(button.address.clone()) + let Some(address) = &button.address else { + return Err("--ptt auto must be resolved before validating headset mappings".into()); + }; + if (!allowed.is_empty() && !allowed.contains(address)) + || !configured.insert(address.clone()) { return Err("--ptt requires one unique button mapping per listed headset".into()); } @@ -50,7 +54,7 @@ pub(crate) fn validate_ptt( pub(crate) fn validate_mode( mode: Mode, buttons: &[PttButton], - allowed: &BTreeSet, + allowed: &BTreeSet
, ) -> Result<(), String> { validate_ptt(buttons, allowed)?; if mode == Mode::HalfDuplex && buttons.is_empty() { @@ -60,7 +64,7 @@ pub(crate) fn validate_mode( } pub(crate) struct RunOptions { - pub addresses: Vec, + pub addresses: Vec
, pub interval: f64, pub connect: bool, pub sco_limit: usize, @@ -72,7 +76,7 @@ pub(crate) struct RunOptions { } pub(crate) struct RunConfig { - pub allowed: BTreeSet, + pub allowed: BTreeSet
, pub interval: Duration, pub connect: bool, pub sco_limit: usize, @@ -109,7 +113,7 @@ impl RunConfig { mut options: RunOptions, interactive: bool, network_path: PathBuf, - resolve_ptt: impl FnOnce(&mut Vec, &BTreeSet) -> Result<(), String>, + resolve_ptt: impl FnOnce(&mut Vec, &BTreeSet
) -> Result<(), String>, ) -> Result { transport::validate_transport(options.mode, options.transport)?; if options.sco_limit == 0 { @@ -125,23 +129,18 @@ impl RunConfig { } let explicit_network = !options.addresses.is_empty(); let allowed = if explicit_network { - options - .addresses - .iter() - .map(|value| crate::bluez::parse_address(value)) - .collect::>()? + options.addresses.iter().cloned().collect() } else { network::load_for_run(&network_path, options.pair_button)? }; if !options.pair_button && options.buttons.is_empty() { options.buttons.push(PttButton { - address: crate::ptt_discovery::AUTO.into(), + address: None, path: crate::ptt_discovery::AUTO.into(), }); } resolve_ptt(&mut options.buttons, &allowed)?; - for button in &mut options.buttons { - button.address = crate::bluez::parse_address(&button.address)?; + for button in &options.buttons { if button.path.is_empty() { return Err("--ptt requires an input device path".into()); } @@ -209,7 +208,7 @@ mod tests { sco_limit: 1, pair_button: false, buttons: vec![PttButton { - address: A.into(), + address: Some(A.into()), path: "input".into(), }], mode: Mode::FullDuplex, @@ -236,21 +235,25 @@ mod tests { options.dashboard = true; } 3 => options.interval = f64::NAN, - 4 => options.addresses = vec!["invalid".into()], + 4 => { + options.addresses.clear(); + options.buttons.clear(); + options.mode = Mode::HalfDuplex; + } 5 => { options.pair_button = true; options.mode = Mode::HalfDuplex; } 6 => { options.buttons = vec![PttButton { - address: B.into(), + address: Some(B.into()), path: "input".into(), }] } 7 => { options.pair_button = true; options.buttons = vec![PttButton { - address: A.into(), + address: Some(A.into()), path: "input".into(), }]; } @@ -282,7 +285,10 @@ mod tests { let fixture = Fixture::new(); let path = fixture.network(); let mut explicit = options(); - explicit.addresses = vec![A.to_lowercase(), A.into()]; + explicit.addresses = vec![ + Address::parse(&A.to_lowercase()).unwrap(), + Address::parse(A).unwrap(), + ]; let config = RunConfig::resolve_at(explicit, true, path.clone()).unwrap(); assert_eq!(config.allowed, [A.into()].into()); assert!(config.explicit_network); @@ -293,7 +299,7 @@ mod tests { saved.mode = Mode::HalfDuplex; saved.transport = Transport::ScoA2dp; saved.buttons = vec![PttButton { - address: B.into(), + address: Some(B.into()), path: "input".into(), }]; let config = RunConfig::resolve_at(saved, true, path.clone()).unwrap(); @@ -322,7 +328,7 @@ mod tests { let mut saved = options(); saved.addresses.clear(); saved.buttons = vec![PttButton { - address: A.into(), + address: Some(A.into()), path: "input".into(), }]; assert!(RunConfig::resolve_at(saved, true, path.clone()).is_err()); @@ -338,7 +344,7 @@ mod tests { let config = RunConfig::resolve_at_with_ptt(options, true, fixture.network(), |buttons, allowed| { assert_eq!(buttons.len(), 1); - assert_eq!(buttons[0].address, crate::ptt_discovery::AUTO); + assert_eq!(buttons[0].address, None); assert_eq!(buttons[0].path, crate::ptt_discovery::AUTO); buttons.clear(); buttons.extend( @@ -346,7 +352,7 @@ mod tests { .iter() .enumerate() .map(|(index, address)| PttButton { - address: address.clone(), + address: Some(address.clone()), path: format!("input{index}"), }), ); diff --git a/src/session.rs b/src/session.rs index 90d2248..990d57f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,6 +15,7 @@ use crossterm::execute; use crossterm::terminal::{Clear, ClearType}; use crate::bluez; +use crate::bluez::Address; use crate::dashboard::{Dashboard, RunStatus}; use crate::groups; use crate::network; @@ -36,6 +37,14 @@ struct InspectionOutcome { error: Option, } +struct RouteUpdateOutcome { + update: Result, String>, + config_error: Option, + inspect_error: Option, + changed: bool, + invalidated: bool, +} + pub(crate) fn guard_half_update( mode: Mode, update: &Result, @@ -54,7 +63,7 @@ pub(crate) fn guard_half_update( /// Connects allowlisted devices that are currently disconnected or unavailable. pub(crate) fn connect_disconnected( - allowed: &BTreeSet, + allowed: &BTreeSet
, stopped: &AtomicBool, mut execute: impl FnMut(&[&str], Duration) -> Result, ) { @@ -77,7 +86,7 @@ pub(crate) fn connect_disconnected( /// Repeats connection checks until cancelled or explicitly woken for shutdown. pub(crate) fn reconnect_worker( - allowed: Arc>>, + allowed: Arc>>, stopped: Arc, shutdown: Receiver<()>, retry: Duration, @@ -101,23 +110,236 @@ pub(crate) fn reconnect_worker( pub(crate) struct Session { config: RunConfig, stopped: Arc, - router: Router, + routing: RoutingCoordinator, transmit: Transmit, input: Option, pairing: Option, dashboard: Option, connector: Option, - reconnect_devices: Arc>>, + reconnect_devices: Arc>>, beep: Option, - headsets: BTreeMap, - links: BTreeSet<(u64, u64)>, - connected_headsets: BTreeSet, - reported_controller_warnings: BTreeSet, last_error: Option, last_update: Option, redraw: bool, } +struct RoutingCoordinator { + router: Router, + headsets: BTreeMap, + links: BTreeSet<(u64, u64)>, + connected_headsets: BTreeSet
, + reported_controller_warnings: BTreeSet, +} + +impl RoutingCoordinator { + fn new(config: &RunConfig, stopped: &Arc) -> Self { + let cancellation = Arc::clone(stopped); + let execute: Execute = + Box::new(move |args| command_cancellable(args, COMMAND_TIMEOUT, Some(&cancellation))); + let mut router = Router::with_executor(config.allowed.clone(), execute); + router.set_transport(config.transport); + router.set_sco_limit(config.sco_limit); + Self { + router, + headsets: BTreeMap::new(), + links: BTreeSet::new(), + connected_headsets: BTreeSet::new(), + reported_controller_warnings: BTreeSet::new(), + } + } + + fn update( + &mut self, + config: &mut RunConfig, + transmit: &mut Transmit, + input: Option<&PttInput>, + beep: Option<&PttBeep>, + stopped: &AtomicBool, + ) -> Result { + let config_error = self.reload_groups(config, transmit); + self.refresh_topology(config, transmit, input)?; + let sources = self.routing_sources(input, transmit); + let prepared = self.router.prepare_transport(&sources); + guard_half_update(config.mode, &prepared, || self.router.close())?; + let transport_ready = prepared?; + if let Some(input) = input + && drain_events(&input.events, transmit)? + { + self.router.close(); + self.links.clear(); + return Ok(RouteUpdateOutcome { + update: Ok(BTreeMap::new()), + config_error, + inspect_error: None, + changed: true, + invalidated: true, + }); + } + let update = self.update_route_set(config, input, &sources, transport_ready, transmit); + guard_half_update(config.mode, &update, || self.router.close())?; + let inspection = self.inspect_and_confirm(config, transmit, input, beep, stopped)?; + if inspection.changed { + self.router.close(); + self.links.clear(); + } + Ok(RouteUpdateOutcome { + update, + config_error, + inspect_error: inspection.error, + changed: inspection.changed, + invalidated: false, + }) + } + + fn reload_groups(&mut self, config: &mut RunConfig, transmit: &mut Transmit) -> Option { + match groups::load(&config.groups_path) { + Ok(groups) => { + transmit.set_groups(&groups); + config.groups = groups; + None + } + Err(error) => Some(error), + } + } + + fn refresh_topology( + &mut self, + config: &RunConfig, + transmit: &mut Transmit, + input: Option<&PttInput>, + ) -> Result<(), String> { + if config.transport == Transport::ScoA2dp { + let devices = self.router.transport_devices(); + guard_half_update(config.mode, &devices, || self.router.close())?; + let devices = devices?; + transmit.set_controllers( + &devices + .iter() + .map(|(address, device)| (address.clone(), device.controller.clone())) + .collect(), + ); + transmit.topology(&devices.keys().cloned().collect()); + } else { + if let Ok(devices) = self.router.transport_devices() { + for warning in transport::controller_warnings(&devices, config.sco_limit) { + if self.reported_controller_warnings.insert(warning.clone()) { + log::warn!("{warning}"); + } + } + } + if let Some(input) = input { + let snapshot = self.router.inspect_owned(); + drain_events(&input.events, transmit)?; + if let Ok((current, _)) = snapshot { + let available = current + .iter() + .filter(|(_, headset)| headset.has_duplex_audio()) + .map(|(address, _)| address.clone()) + .collect(); + transmit.topology(&available); + } + } + } + Ok(()) + } + + fn routing_sources(&self, input: Option<&PttInput>, transmit: &Transmit) -> BTreeSet
{ + if input.is_some() { + transmit.sources().clone() + } else { + self.router.allowed.clone() + } + } + + fn update_route_set( + &mut self, + config: &RunConfig, + input: Option<&PttInput>, + sources: &BTreeSet
, + transport_ready: bool, + transmit: &Transmit, + ) -> Result, String> { + if !transport_ready { + self.router.inspect().map(|(headsets, _)| headsets) + } else if config.groups.is_empty() && input.is_none() { + self.router.update(true) + } else if config.mode == Mode::HalfDuplex && !config.groups.is_empty() { + self.router + .update_group_sources_in_groups(transmit.group_sources(), &config.groups) + } else { + self.router + .update_sources_in_groups(sources, &config.groups) + } + } + + fn inspect_and_confirm( + &mut self, + config: &RunConfig, + transmit: &mut Transmit, + input: Option<&PttInput>, + beep: Option<&PttBeep>, + stopped: &AtomicBool, + ) -> Result { + let (current, active) = match self.router.inspect_owned() { + Ok(snapshot) => snapshot, + Err(error) => { + self.headsets.clear(); + self.links.clear(); + return Ok(InspectionOutcome { + changed: false, + error: Some(error), + }); + } + }; + self.headsets = current; + self.links = active; + let mut changed = false; + if let Some(input) = input { + changed |= drain_events(&input.events, transmit)?; + if config.transport == Transport::ScoA2dp { + let devices = self.router.transport_devices(); + guard_half_update(config.mode, &devices, || self.router.close())?; + changed |= transport::refresh_transmit_requests( + transmit, + &devices?, + &self.router.take_transport_disconnects(), + ); + } else { + let available = self + .headsets + .iter() + .filter(|(_, headset)| headset.has_duplex_audio()) + .map(|(address, _)| address.clone()) + .collect(); + changed |= transmit.topology(&available); + } + if !changed { + changed |= confirm_transmissions( + config.mode, + transmit, + &input.events, + &self.headsets, + &self.links, + beep, + stopped, + )?; + } + } else { + confirm_connections( + &mut self.connected_headsets, + &self.headsets, + &self.links, + beep, + stopped, + ); + } + Ok(InspectionOutcome { + changed, + error: None, + }) + } +} + impl Session { pub fn start(config: RunConfig) -> Result { let mut session = Self::new(config, Arc::new(AtomicBool::new(false))); @@ -126,15 +348,13 @@ impl Session { } fn new(config: RunConfig, stopped: Arc) -> Self { - let cancellation = Arc::clone(&stopped); - let execute: Execute = - Box::new(move |args| command_cancellable(args, COMMAND_TIMEOUT, Some(&cancellation))); - let mut router = Router::with_executor(config.allowed.clone(), execute); - router.set_transport(config.transport); - router.set_sco_limit(config.sco_limit); + let routing = RoutingCoordinator::new(&config, &stopped); let mut transmit = Transmit::new( config.mode, - config.buttons.iter().map(|button| button.address.clone()), + config + .buttons + .iter() + .filter_map(|button| button.address.clone()), ); transmit.set_sco_limit(config.sco_limit); transmit.set_groups(&config.groups); @@ -142,7 +362,7 @@ impl Session { Self { config, stopped, - router, + routing, transmit, input: None, pairing: None, @@ -150,10 +370,6 @@ impl Session { connector: None, reconnect_devices, beep: None, - headsets: BTreeMap::new(), - links: BTreeSet::new(), - connected_headsets: BTreeSet::new(), - reported_controller_warnings: BTreeSet::new(), last_error: None, last_update: None, redraw: true, @@ -287,12 +503,13 @@ impl Session { while let Ok(event) = pairing.events.try_recv() { match event { Ok(device) => { - self.router.allowed.insert(device.clone()); - *self.reconnect_devices.lock().unwrap() = self.router.allowed.clone(); + self.routing.router.allowed.insert(device.clone()); + *self.reconnect_devices.lock().unwrap() = + self.routing.router.allowed.clone(); if let Some(current) = &mut self.dashboard { current.stop(); self.dashboard = Some(Dashboard::start( - self.router.allowed.clone(), + self.routing.router.allowed.clone(), Arc::clone(&self.stopped), )); } @@ -313,170 +530,26 @@ impl Session { Ok(()) } - fn refresh_topology(&mut self) -> Result<(), String> { - if self.config.transport == Transport::ScoA2dp { - let devices = self.router.transport_devices(); - guard_half_update(self.config.mode, &devices, || self.router.close())?; - let devices = devices?; - self.transmit.set_controllers( - &devices - .iter() - .map(|(address, device)| (address.clone(), device.controller.clone())) - .collect(), - ); - self.transmit.topology(&devices.keys().cloned().collect()); - } else { - if let Ok(devices) = self.router.transport_devices() { - for warning in transport::controller_warnings(&devices, self.config.sco_limit) { - if self.reported_controller_warnings.insert(warning.clone()) { - log::warn!("{warning}"); - } - } - } - // Profile transitions remove microphones; only HFP treats this as a disconnect. - if let Some(input) = &self.input { - let snapshot = self.router.inspect_owned(); - drain_events(&input.events, &mut self.transmit)?; - if let Ok((current, _)) = snapshot { - let available = current - .iter() - .filter(|(_, headset)| headset.has_duplex_audio()) - .map(|(address, _)| address.clone()) - .collect(); - self.transmit.topology(&available); - } - } - } - Ok(()) - } - fn update_routes(&mut self) -> Result<(), String> { - let config_error = self.reload_groups(); - self.refresh_topology()?; - let sources = self.routing_sources(); - let prepared = self.router.prepare_transport(&sources); - guard_half_update(self.config.mode, &prepared, || self.router.close())?; - let transport_ready = prepared?; - if let Some(input) = &self.input - && drain_events(&input.events, &mut self.transmit)? - { - self.router.close(); - self.links.clear(); + let outcome = self.routing.update( + &mut self.config, + &mut self.transmit, + self.input.as_ref(), + self.beep.as_ref(), + &self.stopped, + )?; + if outcome.invalidated { self.last_update = None; return Ok(()); } - let update = self.update_route_set(&sources, transport_ready); - guard_half_update(self.config.mode, &update, || self.router.close())?; - let inspection = self.inspect_and_confirm()?; - if inspection.changed { - self.router.close(); - self.links.clear(); - } - self.report_update(update, config_error, inspection.error); - self.last_update = (!inspection.changed).then(Instant::now); + self.report_update(outcome.update, outcome.config_error, outcome.inspect_error); + self.last_update = (!outcome.changed).then(Instant::now); Ok(()) } - fn reload_groups(&mut self) -> Option { - match groups::load(&self.config.groups_path) { - Ok(groups) => { - self.transmit.set_groups(&groups); - self.config.groups = groups; - None - } - Err(error) => Some(error), - } - } - - fn routing_sources(&self) -> BTreeSet { - if self.input.is_some() { - self.transmit.sources().clone() - } else { - self.router.allowed.clone() - } - } - - fn update_route_set( - &mut self, - sources: &BTreeSet, - transport_ready: bool, - ) -> Result, String> { - if !transport_ready { - self.router.inspect().map(|(headsets, _)| headsets) - } else if self.config.groups.is_empty() && self.input.is_none() { - self.router.update(true) - } else if self.config.mode == Mode::HalfDuplex && !self.config.groups.is_empty() { - self.router - .update_group_sources_in_groups(self.transmit.group_sources(), &self.config.groups) - } else { - self.router - .update_sources_in_groups(sources, &self.config.groups) - } - } - - fn inspect_and_confirm(&mut self) -> Result { - let (current, active) = match self.router.inspect_owned() { - Ok(snapshot) => snapshot, - Err(error) => { - self.headsets.clear(); - self.links.clear(); - return Ok(InspectionOutcome { - changed: false, - error: Some(error), - }); - } - }; - self.headsets = current; - self.links = active; - let mut changed = false; - if let Some(input) = &self.input { - changed |= drain_events(&input.events, &mut self.transmit)?; - if self.config.transport == Transport::ScoA2dp { - let devices = self.router.transport_devices(); - guard_half_update(self.config.mode, &devices, || self.router.close())?; - changed |= transport::refresh_transmit_requests( - &mut self.transmit, - &devices?, - &self.router.take_transport_disconnects(), - ); - } else { - let available = self - .headsets - .iter() - .filter(|(_, headset)| headset.has_duplex_audio()) - .map(|(address, _)| address.clone()) - .collect(); - changed |= self.transmit.topology(&available); - } - if !changed { - changed |= confirm_transmissions( - self.config.mode, - &mut self.transmit, - &input.events, - &self.headsets, - &self.links, - self.beep.as_ref(), - &self.stopped, - )?; - } - } else { - confirm_connections( - &mut self.connected_headsets, - &self.headsets, - &self.links, - self.beep.as_ref(), - &self.stopped, - ); - } - Ok(InspectionOutcome { - changed, - error: None, - }) - } - fn report_update( &mut self, - update: Result, String>, + update: Result, String>, config_error: Option, inspect_error: Option, ) { @@ -498,7 +571,7 @@ impl Session { .count(); log::info!( "{active}/{} headsets with duplex audio", - self.router.allowed.len() + self.routing.router.allowed.len() ); } Err(error) => log::warn!("Routing update failed: {error}"), @@ -521,9 +594,9 @@ impl Session { } dashboard .draw( - &self.router.allowed, - &self.headsets, - &self.links, + &self.routing.router.allowed, + &self.routing.headsets, + &self.routing.links, RunStatus { mode: self.config.mode, transmitting: self.input.is_none() || !self.transmit.sources().is_empty(), @@ -541,7 +614,7 @@ impl Session { impl Drop for Session { fn drop(&mut self) { self.stopped.store(true, Ordering::SeqCst); - self.router.close(); + self.routing.router.close(); self.pairing.take(); if let Some(input) = &mut self.input { input.stop(); @@ -570,7 +643,7 @@ mod tests { connect: false, pair_button: false, buttons: vec![PttButton { - address: "AA:BB:CC:DD:EE:01".into(), + address: Some(Address::parse("AA:BB:CC:DD:EE:01").unwrap()), path: "injected-input".into(), }], mode: Mode::HalfDuplex, @@ -589,7 +662,7 @@ mod tests { let stopped = Arc::new(AtomicBool::new(false)); let mut session = Session::new(config(), Arc::clone(&stopped)); let execute: Execute = Box::new(|_| Err("injected snapshot failure".into())); - session.router = Router::with_executor(session.config.allowed.clone(), execute); + session.routing.router = Router::with_executor(session.config.allowed.clone(), execute); let (done, completed) = mpsc::channel(); session.connector = Some(Worker::spawn(Arc::clone(&stopped), move |_, wake| { let _ = wake.recv(); @@ -609,7 +682,7 @@ mod tests { cancellation.store(true, Ordering::SeqCst); Err("cancelled".into()) }); - session.router = Router::with_executor(session.config.allowed.clone(), execute); + session.routing.router = Router::with_executor(session.config.allowed.clone(), execute); assert!(session.run().is_ok()); assert!(stopped.load(Ordering::SeqCst)); } @@ -636,7 +709,11 @@ mod tests { let mut session = Session::new(config(), Arc::new(AtomicBool::new(false))); session.input = Some(PttInput::from_events(receiver)); session.transmit.topology(&[address.into()].into()); - assert!(session.transmit.event(address, true, Instant::now())); + assert!( + session + .transmit + .event(&Address::parse(address).unwrap(), true, Instant::now()) + ); session.last_update = Some(Instant::now()); let mut snapshots = 0; let execute: Execute = Box::new(move |args| { @@ -649,11 +726,11 @@ mod tests { } Ok(snapshot.clone()) }); - session.router = Router::with_executor(session.config.allowed.clone(), execute); + session.routing.router = Router::with_executor(session.config.allowed.clone(), execute); session.update_routes().unwrap(); assert!(session.transmit.sources().is_empty()); assert!(session.transmit.pending.is_empty()); - assert!(session.links.is_empty()); + assert!(session.routing.links.is_empty()); assert!(session.last_update.is_none()); } @@ -661,7 +738,7 @@ mod tests { fn input_open_failure_does_not_save_network_or_start_workers() { let mut config = config(); config.buttons = vec![PttButton { - address: "AA:BB:CC:DD:EE:01".into(), + address: Some(Address::parse("AA:BB:CC:DD:EE:01").unwrap()), path: config .network_path .join("missing-input") diff --git a/src/transmit.rs b/src/transmit.rs index 64fe125..b0af19f 100644 --- a/src/transmit.rs +++ b/src/transmit.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::time::{Duration, Instant}; +use crate::bluez::Address; use crate::groups::TalkGroup; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] @@ -26,24 +27,24 @@ struct Button { pub struct Transmit { mode: Mode, - buttons: BTreeMap, + buttons: BTreeMap, groups: Vec, - queues: BTreeMap, VecDeque>, - group_sources: BTreeMap, BTreeSet>, - active: BTreeSet, - controllers: Option>, + queues: BTreeMap, VecDeque
>, + group_sources: BTreeMap, BTreeSet
>, + active: BTreeSet
, + controllers: Option>, sco_limit: usize, - request_order: VecDeque, - pub pending: BTreeSet, + request_order: VecDeque
, + pub pending: BTreeSet
, } -type GroupSources = BTreeMap, BTreeSet>; +type GroupSources = BTreeMap, BTreeSet
>; fn allocate_floors( candidates: &GroupSources, previous: &GroupSources, - request_order: &VecDeque, - controllers: Option<&BTreeMap>, + request_order: &VecDeque
, + controllers: Option<&BTreeMap>, sco_limit: usize, ) -> GroupSources { let Some(controllers) = controllers else { @@ -51,7 +52,7 @@ fn allocate_floors( }; let mut group_sources = GroupSources::new(); - let mut reserved: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut reserved: BTreeMap<&str, BTreeSet<&Address>> = BTreeMap::new(); for address in request_order { let Some(controller) = controllers.get(address).map(String::as_str) else { @@ -72,7 +73,7 @@ fn allocate_floors( } let controller_sources = reserved.entry(controller).or_default(); - if !controller_sources.contains(address.as_str()) && controller_sources.len() >= sco_limit { + if !controller_sources.contains(address) && controller_sources.len() >= sco_limit { continue; } controller_sources.insert(address); @@ -97,7 +98,7 @@ fn allocate_floors( } let controller_sources = reserved.entry(controller).or_default(); - if !controller_sources.contains(address.as_str()) && controller_sources.len() >= sco_limit { + if !controller_sources.contains(address) && controller_sources.len() >= sco_limit { continue; } for group in candidate_groups { @@ -113,7 +114,7 @@ fn allocate_floors( } impl Transmit { - pub fn new(mode: Mode, mapped: impl IntoIterator) -> Self { + pub fn new(mode: Mode, mapped: impl IntoIterator) -> Self { Self { mode, buttons: mapped.into_iter().map(|a| (a, Button::default())).collect(), @@ -130,7 +131,7 @@ impl Transmit { /// Apply a per-controller SCO source limit, independently of talk groups. /// Missing mappings cannot receive a grant, but held requests remain queued. - pub fn set_controllers(&mut self, controllers: &BTreeMap) { + pub fn set_controllers(&mut self, controllers: &BTreeMap) { self.controllers = Some(controllers.clone()); self.refresh(); } @@ -160,7 +161,7 @@ impl Transmit { } /// Returns whether a genuine transition was processed. - pub fn event(&mut self, address: &str, pressed: bool, at: Instant) -> bool { + pub fn event(&mut self, address: &Address, pressed: bool, at: Instant) -> bool { let memberships = self.memberships(address); let Some(button) = self.buttons.get_mut(address) else { return false; @@ -207,7 +208,7 @@ impl Transmit { /// Drop disconnected requests, including the floor. Reconnection alone must /// not restart a held or always-open mapped microphone: require a new press. /// Preserve the physical pressed bit so duplicate presses cannot re-arm it. - pub fn topology(&mut self, available: &BTreeSet) -> bool { + pub fn topology(&mut self, available: &BTreeSet
) -> bool { let before = self.active.clone(); for (address, button) in &mut self.buttons { if !available.contains(address) { @@ -261,7 +262,7 @@ impl Transmit { self.group_sources = group_sources; } - fn memberships(&self, address: &str) -> Vec> { + fn memberships(&self, address: &Address) -> Vec> { if self.groups.is_empty() { return vec![None]; } @@ -272,11 +273,11 @@ impl Transmit { .collect() } - pub fn sources(&self) -> &BTreeSet { + pub fn sources(&self) -> &BTreeSet
{ &self.active } - pub fn group_sources(&self) -> &BTreeMap, BTreeSet> { + pub fn group_sources(&self) -> &BTreeMap, BTreeSet
> { &self.group_sources } } @@ -286,15 +287,19 @@ mod tests { use super::*; use crate::groups::TalkGroup; + fn address(value: &str) -> Address { + Address::parse(value).unwrap_or_else(|_| { + let suffix = value.bytes().fold(0u8, u8::wrapping_add); + Address::parse(&format!("00:00:00:00:00:{suffix:02X}")).unwrap() + }) + } + fn setup(mode: Mode) -> (Transmit, Instant) { - ( - Transmit::new(mode, ["a".into(), "b".into(), "c".into()]), - Instant::now(), - ) + (Transmit::new(mode, set(&["a", "b", "c"])), Instant::now()) } - fn set(addresses: &[&str]) -> BTreeSet { - addresses.iter().map(|a| (*a).into()).collect() + fn set(addresses: &[&str]) -> BTreeSet
{ + addresses.iter().map(|value| address(value)).collect() } fn group(name: &str, members: &[&str]) -> TalkGroup { @@ -305,11 +310,11 @@ mod tests { } fn press(t: &mut Transmit, a: &str, base: Instant, ms: u64) { - t.event(a, true, base + Duration::from_millis(ms)); + t.event(&address(a), true, base + Duration::from_millis(ms)); } fn release(t: &mut Transmit, a: &str, base: Instant, ms: u64) { - t.event(a, false, base + Duration::from_millis(ms)); + t.event(&address(a), false, base + Duration::from_millis(ms)); } #[test] @@ -320,11 +325,11 @@ mod tests { (Some("Middle".into()), set(&["b"])), ]); let previous = BTreeMap::from([(Some("Zulu".into()), set(&["c"]))]); - let request_order = VecDeque::from(["a".into(), "b".into(), "c".into()]); + let request_order = VecDeque::from([address("a"), address("b"), address("c")]); let controllers = BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci0".into()), - ("c".into(), "hci0".into()), + (address("a"), "hci0".into()), + (address("b"), "hci0".into()), + (address("c"), "hci0".into()), ]); assert_eq!( @@ -358,8 +363,8 @@ mod tests { (Some("Missing".into()), set(&["m"])), (Some("Mapped".into()), set(&["a"])), ]); - let request_order = VecDeque::from(["m".into(), "a".into()]); - let controllers = BTreeMap::from([("a".into(), "hci0".into())]); + let request_order = VecDeque::from([address("m"), address("a")]); + let controllers = BTreeMap::from([(address("a"), "hci0".into())]); assert_eq!( allocate_floors( @@ -396,8 +401,8 @@ mod tests { press(&mut t, "c", now, 0); press(&mut t, "b", now, 1); press(&mut t, "a", now, 2); - assert!(!t.event("b", true, now)); - assert!(!t.event("unknown", true, now)); + assert!(!t.event(&address("b"), true, now)); + assert!(!t.event(&address("unknown"), true, now)); release(&mut t, "c", now, 3); assert_eq!(t.sources(), &set(&["b"])); release(&mut t, "b", now, 4); @@ -433,9 +438,9 @@ mod tests { group("Middle", &["c"]), ]); t.set_controllers(&BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci0".into()), - ("c".into(), "hci0".into()), + (address("a"), "hci0".into()), + (address("b"), "hci0".into()), + (address("c"), "hci0".into()), ])); press(&mut t, "a", now, 0); press(&mut t, "c", now, 1); @@ -457,9 +462,9 @@ mod tests { ]); t.set_sco_limit(2); t.set_controllers(&BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci0".into()), - ("c".into(), "hci0".into()), + (address("a"), "hci0".into()), + (address("b"), "hci0".into()), + (address("c"), "hci0".into()), ])); press(&mut t, "a", now, 0); press(&mut t, "c", now, 1); @@ -478,15 +483,15 @@ mod tests { press(&mut t, "b", now, 1); assert!(t.sources().is_empty()); t.set_controllers(&BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci1".into()), + (address("a"), "hci0".into()), + (address("b"), "hci1".into()), ])); assert_eq!(t.sources(), &set(&["a", "b"])); - t.set_controllers(&BTreeMap::from([("b".into(), "hci1".into())])); + t.set_controllers(&BTreeMap::from([(address("b"), "hci1".into())])); assert_eq!(t.sources(), &set(&["b"])); t.set_controllers(&BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci1".into()), + (address("a"), "hci0".into()), + (address("b"), "hci1".into()), ])); assert_eq!(t.sources(), &set(&["a", "b"])); } @@ -499,8 +504,8 @@ mod tests { press(&mut t, "b", now, 1); assert_eq!(t.sources(), &set(&["a", "b"])); t.set_controllers(&BTreeMap::from([ - ("a".into(), "hci0".into()), - ("b".into(), "hci0".into()), + (address("a"), "hci0".into()), + (address("b"), "hci0".into()), ])); assert_eq!(t.sources(), &set(&["a"])); } @@ -522,7 +527,7 @@ mod tests { press(&mut t, "b", now, 1); assert!(t.set_groups(&[group("Team", &["b", "c"])])); assert!(t.sources().is_empty()); - assert!(!t.event("a", true, now + Duration::from_millis(2))); + assert!(!t.event(&address("a"), true, now + Duration::from_millis(2))); release(&mut t, "b", now, 3); press(&mut t, "b", now, 4); assert_eq!(t.sources(), &set(&["b"])); @@ -567,9 +572,9 @@ mod tests { let (mut t, now) = setup(Mode::FullDuplex); for ms in [0, 400, 1001] { press(&mut t, "a", now, ms); - assert!(!t.event("a", true, now + Duration::from_millis(ms))); + assert!(!t.event(&address("a"), true, now + Duration::from_millis(ms))); release(&mut t, "a", now, ms + 1); - assert!(!t.event("a", false, now + Duration::from_millis(ms + 2))); + assert!(!t.event(&address("a"), false, now + Duration::from_millis(ms + 2))); } assert!(t.sources().is_empty()); // Sliding window: the 400, 1001, 1200 presses now constitute a triple. @@ -598,7 +603,7 @@ mod tests { assert_eq!(t.sources(), &set(&["b"])); assert_eq!(t.pending, set(&["b"])); t.topology(&set(&["a", "b", "c"])); - assert!(!t.event("a", true, now)); + assert!(!t.event(&address("a"), true, now)); release(&mut t, "b", now, 2); assert!(t.sources().is_empty()); release(&mut t, "a", now, 3); diff --git a/src/transport.rs b/src/transport.rs index aad36fe..455f3f9 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; use serde_json::Value; +use crate::bluez::Address; use crate::pipewire::{HEADSET_PROFILE, Snapshot}; use crate::transmit::{Mode, Transmit}; @@ -39,7 +40,7 @@ pub(crate) fn validate_transport(mode: Mode, transport: Transport) -> Result<(), } pub(crate) fn controller_warnings( - devices: &BTreeMap, + devices: &BTreeMap, sco_limit: usize, ) -> Vec { addresses_by_controller(devices.iter()) @@ -49,7 +50,11 @@ pub(crate) fn controller_warnings( format!( "{} headsets share {controller} ({}), exceeding --sco-limit {sco_limit}. HFP listeners also need SCO/eSCO; verify controller capacity or use --mode half-duplex --transport sco-a2dp with PTT when sharing a radio.", addresses.len(), - addresses.join(", ") + addresses + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") ) }) .collect() @@ -57,8 +62,8 @@ pub(crate) fn controller_warnings( pub(crate) fn refresh_transmit_requests( transmit: &mut Transmit, - devices: &BTreeMap, - disconnected: &BTreeSet, + devices: &BTreeMap, + disconnected: &BTreeSet
, ) -> bool { let before = transmit.sources().clone(); let available: BTreeSet<_> = devices.keys().cloned().collect(); @@ -136,14 +141,14 @@ impl Device { } fn addresses_by_controller<'a>( - devices: impl IntoIterator, -) -> BTreeMap<&'a str, Vec<&'a str>> { + devices: impl IntoIterator, +) -> BTreeMap<&'a str, Vec<&'a Address>> { let mut controllers = BTreeMap::new(); for (address, device) in devices { controllers .entry(device.controller.as_str()) .or_insert_with(Vec::new) - .push(address.as_str()); + .push(address); } controllers } @@ -159,10 +164,10 @@ fn identifier(value: &Value) -> Option { pub(crate) struct Coordinator { pub(crate) transport: Transport, sco_limit: usize, - sources: Option>, - pub(crate) profile_changes: BTreeMap, + sources: Option>, + pub(crate) profile_changes: BTreeMap, pub(crate) transition_started: Option, - disconnects: BTreeSet, + disconnects: BTreeSet
, } impl Default for Coordinator { @@ -179,7 +184,7 @@ impl Default for Coordinator { } struct TransitionIo<'a, F, C> { - allowed: &'a BTreeSet, + allowed: &'a BTreeSet
, execute: &'a mut F, close_routes: C, } @@ -196,13 +201,13 @@ impl Coordinator { self.sco_limit = limit; } - pub(crate) fn take_disconnects(&mut self) -> BTreeSet { + pub(crate) fn take_disconnects(&mut self) -> BTreeSet
{ std::mem::take(&mut self.disconnects) } pub(crate) fn snapshot( &mut self, - allowed: &BTreeSet, + allowed: &BTreeSet
, execute: &mut impl FnMut(&[&str]) -> Result, ) -> Result { let snapshot = Snapshot::fetch(execute)?; @@ -218,8 +223,8 @@ impl Coordinator { pub(crate) fn prepare( &mut self, - sources: &BTreeSet, - allowed: &BTreeSet, + sources: &BTreeSet
, + allowed: &BTreeSet
, execute: &mut impl FnMut(&[&str]) -> Result, close_routes: impl FnMut(), ) -> Result { @@ -239,7 +244,7 @@ impl Coordinator { fn prepare_inner( &mut self, - sources: &BTreeSet, + sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, ) -> Result where @@ -273,7 +278,7 @@ impl Coordinator { fn prepare_snapshot( &mut self, - sources: &BTreeSet, + sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, snapshot: Snapshot, ) -> Result @@ -323,10 +328,10 @@ impl Coordinator { fn demote_receivers( &mut self, - sources: &BTreeSet, + sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, snapshot: &Snapshot, - devices: &BTreeMap, + devices: &BTreeMap, ) -> Result where F: FnMut(&[&str]) -> Result, @@ -348,9 +353,9 @@ impl Coordinator { fn promote_sources( &mut self, - sources: &BTreeSet, + sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, - devices: &BTreeMap, + devices: &BTreeMap, ) -> Result<(), String> where F: FnMut(&[&str]) -> Result, @@ -367,9 +372,9 @@ impl Coordinator { fn sources_ready( &self, - sources: &BTreeSet, - headsets: &BTreeMap, - devices: &BTreeMap, + sources: &BTreeSet
, + headsets: &BTreeMap, + devices: &BTreeMap, ) -> bool { devices.iter().all(|(address, device)| { headsets.get(address).is_some_and(|headset| { @@ -384,10 +389,10 @@ impl Coordinator { fn change_profile( &mut self, - address: &str, + address: &Address, expected: &Device, family: &str, - sources: &BTreeSet, + sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, ) -> Result<(), String> where @@ -461,14 +466,14 @@ impl Coordinator { ]) .map_err(|error| format!("{address}: could not select {family}: {error}"))?; self.profile_changes - .insert(address.into(), (current.clone(), index, Instant::now())); + .insert(address.clone(), (current.clone(), index, Instant::now())); Ok(()) } } fn validate_source_capacity( - sources: &BTreeSet, - devices: &BTreeMap, + sources: &BTreeSet
, + devices: &BTreeMap, sco_limit: usize, ) -> Result<(), String> { for (controller, addresses) in addresses_by_controller( @@ -480,14 +485,18 @@ fn validate_source_capacity( return Err(format!( "SCO capacity conflict on {controller}: {} sources ({}) exceed configured --sco-limit {sco_limit}", addresses.len(), - addresses.join(", ") + addresses + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") )); } } Ok(()) } -fn validate_mixed_devices(devices: &BTreeMap) -> Result<(), String> { +fn validate_mixed_devices(devices: &BTreeMap) -> Result<(), String> { for (address, device) in devices { for family in [HEADSET_PROFILE, "a2dp-sink"] { if device.index(family).is_none() { @@ -507,14 +516,14 @@ fn validate_mixed_devices(devices: &BTreeMap) -> Result<(), Stri } impl Device { - pub(crate) fn parse(id: u64, address: &str, info: &Value) -> Option { + pub(crate) fn parse(id: u64, address: &Address, info: &Value) -> Option { let props = &info["props"]; let path = props["api.bluez5.path"].as_str()?; let (controller, device_path) = path.rsplit_once('/')?; let hci = controller.strip_prefix("/org/bluez/hci")?; if hci.is_empty() || !hci.bytes().all(|byte| byte.is_ascii_digit()) - || device_path != format!("dev_{}", address.replace(':', "_")) + || device_path != format!("dev_{}", address.as_str().replace(':', "_")) { return None; } @@ -590,13 +599,17 @@ mod tests { const ADDRESS: &str = "AA:BB:CC:DD:EE:01"; + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + fn device(profiles: Value, current: u64) -> Device { Device::parse( 10, - ADDRESS, + &address(ADDRESS), &json!({ "props": { - "api.bluez5.path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01", + "api.bluez5.path": format!("/org/bluez/hci0/dev_{}", ADDRESS.replace(':', "_")), "object.serial": 1000 }, "params": { @@ -608,7 +621,7 @@ mod tests { .unwrap() } - fn device_on_controller(id: u64, address: &str, controller: &str) -> Device { + fn device_on_controller(id: u64, address: &Address, controller: &str) -> Device { Device::parse( id, address, @@ -616,7 +629,7 @@ mod tests { "props": { "api.bluez5.path": format!( "/org/bluez/{controller}/dev_{}", - address.replace(':', "_") + address.as_str().replace(':', "_") ), "object.serial": id + 1000 }, @@ -629,28 +642,22 @@ mod tests { #[test] fn source_capacity_obeys_per_controller_limit() { let addresses = [ - "AA:BB:CC:DD:EE:01", - "AA:BB:CC:DD:EE:02", - "AA:BB:CC:DD:EE:03", + address("AA:BB:CC:DD:EE:01"), + address("AA:BB:CC:DD:EE:02"), + address("AA:BB:CC:DD:EE:03"), ]; let devices = addresses .iter() .enumerate() .map(|(index, address)| { ( - (*address).to_owned(), + address.clone(), device_on_controller(index as u64, address, "hci0"), ) }) .collect::>(); - let two = addresses[..2] - .iter() - .map(|address| (*address).to_owned()) - .collect(); - let three = addresses - .iter() - .map(|address| (*address).to_owned()) - .collect(); + let two = addresses[..2].iter().cloned().collect(); + let three = addresses.iter().cloned().collect(); assert!( validate_source_capacity(&two, &devices, 1) diff --git a/src/tui.rs b/src/tui.rs index 472c45c..9da7e57 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -17,6 +17,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table}; use ratatui::{DefaultTerminal, Frame}; +use crate::bluez::Address; use crate::device_status::{ ConnectionState, HeadsetStatus, SignalQuality, StatusPresentation, StatusUpdate, start_polling, }; @@ -31,9 +32,9 @@ enum Focus { } struct ControlPanel { - allowed: BTreeSet, + allowed: BTreeSet
, group_editor: GroupEditor, - status: BTreeMap, + status: BTreeMap, selected_group: Option, selected_headset: usize, focus: Focus, @@ -44,7 +45,7 @@ struct ControlPanel { } impl ControlPanel { - fn new(allowed: BTreeSet, groups_path: PathBuf) -> Result { + fn new(allowed: BTreeSet
, groups_path: PathBuf) -> Result { let groups = load(&groups_path)?; let selected_group = (!groups.is_empty()).then_some(0); Ok(Self { @@ -188,7 +189,7 @@ impl ControlPanel { Row::new([ Cell::from(presentation.name_or("—").to_string()), Cell::from(Span::styled( - address.clone(), + address.as_str(), self.palette.style(Meaning::Info), )), Cell::from(Span::styled( @@ -391,7 +392,7 @@ impl Drop for TerminalRestore { } } -pub fn run(allowed: BTreeSet, groups_path: PathBuf) -> Result<(), String> { +pub fn run(allowed: BTreeSet
, groups_path: PathBuf) -> Result<(), String> { if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { return Err("the control panel requires an interactive terminal".into()); } @@ -442,7 +443,7 @@ mod tests { .join(format!("bt-intercom-tui-{}", std::process::id())) .join("talk-groups.json"); let _ = std::fs::remove_dir_all(path.parent().unwrap()); - let address = "AA:BB:CC:DD:EE:01".to_string(); + let address = Address::parse("AA:BB:CC:DD:EE:01").unwrap(); let mut panel = ControlPanel::new([address.clone()].into(), path.clone()).unwrap(); assert!(!panel.handle_key(KeyCode::Char('n')));