Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 140 additions & 18 deletions src/bluez.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, AddressError> {
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<str> for Address {
fn borrow(&self) -> &str {
self.as_str()
}
}

impl AsRef<str> 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, Self::Err> {
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<String> 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(':')?;
Expand All @@ -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<String> {
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> {
Address::parse(value).ok()
}

pub(crate) fn parse_address(value: &str) -> Result<String, String> {
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, AddressError> {
Address::parse(value)
}

pub(crate) fn controller_addresses(info: &str) -> std::collections::BTreeSet<String> {
pub(crate) fn controller_addresses(info: &str) -> std::collections::BTreeSet<Address> {
info.lines()
.filter_map(|line| {
let address = line
Expand Down Expand Up @@ -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 [
Expand All @@ -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));
Expand Down
31 changes: 15 additions & 16 deletions src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,7 +21,7 @@ use crate::worker::Worker;
/// The polling worker can be stopped explicitly with [`Dashboard::stop`].
pub struct Dashboard {
receiver: Receiver<StatusUpdate>,
state: BTreeMap<String, HeadsetStatus>,
state: BTreeMap<Address, HeadsetStatus>,
status_error: Option<String>,
worker: Option<Worker>,
}
Expand All @@ -32,7 +33,7 @@ pub struct RunStatus {

impl Dashboard {
/// Starts polling each allowlisted device until shutdown or cancellation.
pub fn start(allowed: BTreeSet<String>, stopped: Arc<AtomicBool>) -> Self {
pub fn start(allowed: BTreeSet<Address>, stopped: Arc<AtomicBool>) -> Self {
let (worker, receiver) = start_polling(allowed, false, Duration::from_secs(10), stopped);
Self {
receiver,
Expand All @@ -56,8 +57,8 @@ impl Dashboard {
/// Renders the latest Bluetooth and routing state to the supplied writer.
pub fn draw(
&self,
allowed: &BTreeSet<String>,
headsets: &BTreeMap<String, Headset>,
allowed: &BTreeSet<Address>,
headsets: &BTreeMap<Address, Headset>,
links: &BTreeSet<(u64, u64)>,
status: RunStatus,
error: Option<&str>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"}}},
Expand All @@ -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,
Expand Down Expand Up @@ -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::<BTreeSet<_>>();
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()
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
Loading