Krolyxon
Commit 6d9ef16

Branches

Refs →

budctlcratesbudctl-coresrclib.rs

206 lines · 7.3 KB · Rust

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