Krolyxon
Commit 8ec1f82

Branches

Refs →

budctlcratesbudctl-coresrclib.rs

205 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;
19
20pub use error::{Error, Kind, Result};
21
22use bt::BdAddr;
23use devices::Device;
24#[cfg(target_os = "linux")]
25use devices::Session;
26#[cfg(target_os = "linux")]
27use transport::rfcomm::Rfcomm;
28#[cfg(target_os = "linux")]
29use transport::{Hexdump, Transport, sdp};
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 if !device.connected {
119 return Err(Error::NotConnected(format!(
120 "{} ({}) is paired but not connected; connect it in your Bluetooth settings first",
121 device.alias, device.address
122 )));
123 }
124 for &service in model.rfcomm_uuids() {
125 let Some(channel) = sdp::rfcomm_channel(device.address, service)? else {
126 debug::note(|| format!("SDP: {} has no service {service}", device.address));
127 continue;
128 };
129 debug::note(|| format!("SDP: service {service} is on RFCOMM channel {channel}"));
130 let rfcomm = Box::new(Rfcomm::connect(device.address, channel)?);
131 let link: Box<dyn Transport> = if debug::enabled() {
132 Box::new(Hexdump::new(rfcomm))
133 } else {
134 rfcomm
135 };
136 return model.open(link);
137 }
138 Err(Error::NoService(device.address.to_string()))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn found(address: &str, name: &str, connected: bool) -> Found {
146 let device = PairedDevice {
147 address: address.parse().unwrap(),
148 name: name.to_owned(),
149 alias: name.to_owned(),
150 connected,
151 uuids: Vec::new(),
152 };
153 Found {
154 model: devices::identify(&device),
155 device,
156 }
157 }
158
159 const T310: &str = "realme Buds T310";
160
161 #[test]
162 fn picks_the_only_connected_supported_device() {
163 let list = vec![
164 found("00:00:00:00:00:01", "Some Speaker", true),
165 found("00:00:00:00:00:02", T310, true),
166 found("00:00:00:00:00:03", T310, false),
167 ];
168 let (device, model) = select(list, None).unwrap();
169 assert_eq!(device.address.to_string(), "00:00:00:00:00:02");
170 assert_eq!(model.name(), T310);
171 }
172
173 #[test]
174 fn several_connected_buds_need_a_choice() {
175 let list = || {
176 vec![
177 found("00:00:00:00:00:02", T310, true),
178 found("00:00:00:00:00:03", T310, true),
179 ]
180 };
181 assert_eq!(select(list(), None).err().map(|e| e.kind()), Some(Kind::Usage));
182 let (device, _) = select(list(), Some("00:00:00:00:00:03".parse().unwrap())).unwrap();
183 assert_eq!(device.address.to_string(), "00:00:00:00:00:03");
184 }
185
186 #[test]
187 fn nothing_supported_or_unknown_address_is_not_found() {
188 let list = || vec![found("00:00:00:00:00:01", "Some Speaker", true)];
189 assert!(matches!(select(list(), None), Err(Error::NoDevice(_))));
190 assert!(matches!(
191 select(list(), Some("00:00:00:00:00:01".parse().unwrap())),
192 Err(Error::NoDevice(_))
193 ));
194 assert!(matches!(
195 select(list(), Some("00:00:00:00:00:09".parse().unwrap())),
196 Err(Error::NoDevice(_))
197 ));
198 }
199
200 #[test]
201 fn one_idle_pair_is_selected_so_connect_can_explain() {
202 let (device, model) = select(vec![found("00:00:00:00:00:02", T310, false)], None).unwrap();
203 assert!(matches!(connect(&device, model), Err(Error::NotConnected(_))));
204 }
205}