Krolyxon
Commit c98214a

Branches

Refs →

budctlcratesbudctl-coresrclib.rs

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