Krolyxon
Commit 66d7a70

Branches

Refs →

budctlcratesbudctl-coresrclib.rs

184 lines · 6.6 KB · Rust

1//! budctl-core: control wireless earbuds over Bluetooth, with device support as plugins.
2//!
3//! A front end (CLI, GUI) calls [`scan`], picks a device with [`select`], opens a session with
4//! [`connect`] and then reads or changes settings through [`devices::Session`].
5//!
6//! No telemetry: the only sockets are Bluetooth sockets and the local system D-Bus (BlueZ).
7
8pub mod bluez;
9pub mod bt;
10pub mod debug;
11pub mod devices;
12mod error;
13mod reader;
14pub mod status;
15pub mod transport;
16
17pub use error::{Error, Kind, Result};
18
19use bluez::PairedDevice;
20use bt::BdAddr;
21use devices::{Device, Session};
22use transport::rfcomm::Rfcomm;
23use transport::{Hexdump, Transport, sdp};
24
25/// A paired device and the model budctl recognized it as, if any.
26pub struct Found {
27 pub device: PairedDevice,
28 pub model: Option<&'static dyn Device>,
29}
30
31/// Every paired device BlueZ knows, each with its model when budctl supports it.
32pub fn scan() -> Result<Vec<Found>> {
33 Ok(bluez::paired_devices()?
34 .into_iter()
35 .map(|device| Found {
36 model: devices::identify(&device),
37 device,
38 })
39 .collect())
40}
41
42/// Pick the device to talk to: the one at `address`, or else the only connected supported one.
43pub fn select(found: Vec<Found>, address: Option<BdAddr>) -> Result<(PairedDevice, &'static dyn Device)> {
44 if let Some(address) = address {
45 let found = found
46 .into_iter()
47 .find(|f| f.device.address == address)
48 .ok_or_else(|| Error::NoDevice(format!("no paired device has the address {address}")))?;
49 let model = found.model.ok_or_else(|| {
50 Error::NoDevice(format!(
51 "{} ({address}) is not a model budctl supports",
52 found.device.alias
53 ))
54 })?;
55 return Ok((found.device, model));
56 }
57
58 let supported: Vec<(PairedDevice, &'static dyn Device)> = found
59 .into_iter()
60 .filter_map(|f| f.model.map(|model| (f.device, model)))
61 .collect();
62 let (connected, idle): (Vec<_>, Vec<_>) = supported.into_iter().partition(|(d, _)| d.connected);
63 let mut connected = connected.into_iter();
64 match (connected.next(), connected.next()) {
65 (Some(only), None) => Ok(only),
66 (Some(first), Some(second)) => {
67 let addresses: Vec<String> = [first, second]
68 .into_iter()
69 .chain(connected)
70 .map(|(d, _)| format!("{} ({})", d.address, d.alias))
71 .collect();
72 Err(Error::Usage(format!(
73 "more than one supported device is connected; pick one with --device: {}",
74 addresses.join(", ")
75 )))
76 }
77 (None, _) => {
78 let mut idle = idle.into_iter();
79 match (idle.next(), idle.next()) {
80 // One paired pair of buds: let `connect` say plainly that it is not connected.
81 (Some(only), None) => Ok(only),
82 (Some(_), Some(_)) => Err(Error::NotConnected(
83 "none of your supported buds is connected; connect them in your Bluetooth settings first"
84 .to_owned(),
85 )),
86 (None, _) => Err(Error::NoDevice(
87 "no paired device budctl supports (pair the buds in your Bluetooth settings first)".to_owned(),
88 )),
89 }
90 }
91 }
92}
93
94/// Open a control session: find the control service with SDP, connect RFCOMM, run the model's
95/// init sequence.
96pub fn connect(device: &PairedDevice, model: &'static dyn Device) -> Result<Box<dyn Session>> {
97 if !device.connected {
98 return Err(Error::NotConnected(format!(
99 "{} ({}) is paired but not connected; connect it in your Bluetooth settings first",
100 device.alias, device.address
101 )));
102 }
103 for &service in model.rfcomm_uuids() {
104 let Some(channel) = sdp::rfcomm_channel(device.address, service)? else {
105 debug::note(|| format!("SDP: {} has no service {service}", device.address));
106 continue;
107 };
108 debug::note(|| format!("SDP: service {service} is on RFCOMM channel {channel}"));
109 let rfcomm = Box::new(Rfcomm::connect(device.address, channel)?);
110 let link: Box<dyn Transport> = if debug::enabled() {
111 Box::new(Hexdump::new(rfcomm))
112 } else {
113 rfcomm
114 };
115 return model.open(link);
116 }
117 Err(Error::NoService(device.address.to_string()))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn found(address: &str, name: &str, connected: bool) -> Found {
125 let device = PairedDevice {
126 address: address.parse().unwrap(),
127 name: name.to_owned(),
128 alias: name.to_owned(),
129 connected,
130 uuids: Vec::new(),
131 };
132 Found {
133 model: devices::identify(&device),
134 device,
135 }
136 }
137
138 const T310: &str = "realme Buds T310";
139
140 #[test]
141 fn picks_the_only_connected_supported_device() {
142 let list = vec![
143 found("00:00:00:00:00:01", "Some Speaker", true),
144 found("00:00:00:00:00:02", T310, true),
145 found("00:00:00:00:00:03", T310, false),
146 ];
147 let (device, model) = select(list, None).unwrap();
148 assert_eq!(device.address.to_string(), "00:00:00:00:00:02");
149 assert_eq!(model.name(), T310);
150 }
151
152 #[test]
153 fn several_connected_buds_need_a_choice() {
154 let list = || {
155 vec![
156 found("00:00:00:00:00:02", T310, true),
157 found("00:00:00:00:00:03", T310, true),
158 ]
159 };
160 assert_eq!(select(list(), None).err().map(|e| e.kind()), Some(Kind::Usage));
161 let (device, _) = select(list(), Some("00:00:00:00:00:03".parse().unwrap())).unwrap();
162 assert_eq!(device.address.to_string(), "00:00:00:00:00:03");
163 }
164
165 #[test]
166 fn nothing_supported_or_unknown_address_is_not_found() {
167 let list = || vec![found("00:00:00:00:00:01", "Some Speaker", true)];
168 assert!(matches!(select(list(), None), Err(Error::NoDevice(_))));
169 assert!(matches!(
170 select(list(), Some("00:00:00:00:00:01".parse().unwrap())),
171 Err(Error::NoDevice(_))
172 ));
173 assert!(matches!(
174 select(list(), Some("00:00:00:00:00:09".parse().unwrap())),
175 Err(Error::NoDevice(_))
176 ));
177 }
178
179 #[test]
180 fn one_idle_pair_is_selected_so_connect_can_explain() {
181 let (device, model) = select(vec![found("00:00:00:00:00:02", T310, false)], None).unwrap();
182 assert!(matches!(connect(&device, model), Err(Error::NotConnected(_))));
183 }
184}