Krolyxon
Commit 684a13f

Branches

Refs →

budctlcratesbudctl-coresrclib.rs

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