kobo_core/device/bt/
mod.rs1use std::process::{Command, Stdio};
8use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
9use std::sync::OnceLock;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use log::{info, warn};
13
14use crate::device::config::SocFamily;
15
16mod discover;
17
18pub use discover::bt_target_device;
19pub use discover::PairedDevice;
20use discover::{
21 clear_cached_bt_device, discover_connected_paired_device, discover_paired_devices,
22 set_cached_bt_device, set_last_ok_device,
23};
24
25static LAST_BT_TOGGLE_MS: AtomicU64 = AtomicU64::new(0);
29
30static RECONNECT_BUSY: AtomicU8 = AtomicU8::new(0);
35
36static BT_RECONNECT_RESULT: AtomicU8 = AtomicU8::new(0);
41
42pub fn bt_take_reconnect_result() -> u8 {
45 BT_RECONNECT_RESULT.swap(0, Ordering::Relaxed)
46}
47
48pub fn bt_reconnect_busy() -> bool {
55 RECONNECT_BUSY.load(Ordering::SeqCst) != 0
56}
57
58fn now_ms() -> u64 {
59 SystemTime::now()
60 .duration_since(UNIX_EPOCH)
61 .map(|d| d.as_millis() as u64)
62 .unwrap_or(0)
63}
64
65pub fn bt_toggle_age_ms() -> u64 {
67 let t = LAST_BT_TOGGLE_MS.load(Ordering::Relaxed);
68 if t == 0 {
69 u64::MAX
70 } else {
71 now_ms().saturating_sub(t)
72 }
73}
74
75static BT_BUS: OnceLock<&'static str> = OnceLock::new();
83const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
84const BT_BUS_BLUEZ: &str = "org.bluez";
85
86pub fn set_bt_bus(soc: SocFamily) {
88 let bus = match soc {
89 SocFamily::Mtk => BT_BUS_MTK,
90 SocFamily::Nxp | SocFamily::Sunxi => BT_BUS_BLUEZ,
91 };
92 let _ = BT_BUS.set(bus);
94 info!("bt: bus = {} ({:?})", bus, soc);
95}
96
97pub fn bt_bus() -> &'static str {
100 BT_BUS.get().copied().unwrap_or(BT_BUS_MTK)
101}
102
103const DBUS_DEVICE1_IFACE: &str = "string:org.bluez.Device1";
104const DBUS_ADAPTER1_IFACE: &str = "string:org.bluez.Adapter1";
105const DBUS_PROPS_GET: &str = "org.freedesktop.DBus.Properties.Get";
106const DBUS_PROPS_SET: &str = "org.freedesktop.DBus.Properties.Set";
107const DBUS_DEVICE1_PATH: &str = "/org/bluez/hci0";
108pub(super) const DBUS_OBJECT_MANAGER: &str = "org.freedesktop.DBus.ObjectManager.GetManagedObjects";
109const DBUS_DEVICE1_CONNECT: &str = "org.bluez.Device1.Connect";
110const DBUS_DEVICE1_DISCONNECT: &str = "org.bluez.Device1.Disconnect";
111
112pub(super) fn dbus_cmd() -> Command {
114 let dest = format!("--dest={}", bt_bus());
115 let mut cmd = Command::new("dbus-send");
116 cmd.args(["--system", "--print-reply", "--type=method_call"]);
117 cmd.arg(dest);
118 cmd
119}
120
121fn device_connected(dev: &str) -> bool {
124 let out = dbus_cmd()
125 .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Connected"])
126 .output();
127 match out {
128 Ok(o) if o.status.success() => dbus_connected(&String::from_utf8_lossy(&o.stdout)),
129 _ => false,
130 }
131}
132
133pub fn bt_status() -> bool {
134 let dev = match bt_target_device() {
135 Some(d) => d,
136 None => return false,
137 };
138 if device_connected(&dev) {
139 set_last_ok_device(&dev);
140 return true;
141 }
142 if let Some(connected) = discover_connected_paired_device() {
143 if connected != dev {
144 info!(
145 "bt: switching target {} -> {} (already connected by btservice)",
146 dev, connected
147 );
148 set_cached_bt_device(&connected);
149 set_last_ok_device(&connected);
150 return true;
151 }
152 set_last_ok_device(&connected);
153 }
154 false
155}
156
157pub fn default_bt_device(content: &str) -> Option<String> {
158 content
159 .lines()
160 .find_map(|l| {
161 l.strip_prefix("DefaultAudioDevice=")
162 .map(|s| s.trim().to_string())
163 })
164 .filter(|d| !d.is_empty())
165}
166
167pub fn dbus_connected(text: &str) -> bool {
168 text.contains("boolean true")
169}
170
171pub fn bt_toggle(on: bool) {
172 LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
175 if on {
176 if let Err(e) = dbus_cmd()
181 .args([
182 DBUS_DEVICE1_PATH,
183 DBUS_PROPS_SET,
184 DBUS_ADAPTER1_IFACE,
185 "string:Powered",
186 "variant:boolean:true",
187 ])
188 .status()
189 {
190 warn!("bt: adapter power-on failed: {e}");
191 }
192 info!("bt: adapter powered on (bus={})", bt_bus());
193 let _ = std::thread::spawn(reconnect_bt);
196 info!("bt: turned ON + reconnecting");
197 } else {
198 std::thread::spawn(move || {
203 if let Some(dev) = bt_target_device() {
204 if let Err(e) = dbus_cmd()
205 .args([&dev, DBUS_DEVICE1_DISCONNECT])
206 .stdout(Stdio::null())
207 .stderr(Stdio::null())
208 .status()
209 {
210 warn!("bt: disconnect {dev} failed: {e}");
211 }
212 }
213 clear_cached_bt_device();
214 if let Err(e) = dbus_cmd()
215 .args([
216 DBUS_DEVICE1_PATH,
217 DBUS_PROPS_SET,
218 DBUS_ADAPTER1_IFACE,
219 "string:Powered",
220 "variant:boolean:false",
221 ])
222 .status()
223 {
224 warn!("bt: adapter power-off failed: {e}");
225 }
226 info!("bt: turned OFF (adapter powered down)");
227 });
228 }
229}
230
231pub fn reconnect_bt() {
232 if RECONNECT_BUSY.swap(1, Ordering::SeqCst) != 0 {
233 info!("reconnect_bt: already running, skipping");
234 return;
235 }
236 let mut dev: Option<String> = None;
237 info!("reconnect_bt: connecting on bus={}", bt_bus());
238 for attempt in 1..=15 {
239 if dev.is_none() {
240 dev = bt_target_device();
241 if dev.is_none() {
242 std::thread::sleep(std::time::Duration::from_millis(400));
243 continue;
244 }
245 }
246 let dev_str = dev.as_ref().unwrap();
247 let _ = dbus_cmd()
248 .args([dev_str, DBUS_DEVICE1_CONNECT])
249 .stdout(Stdio::null())
250 .stderr(Stdio::null())
251 .status();
252 let connected = bt_status();
253 if connected {
254 info!("reconnect_bt: connected on attempt {attempt}");
255 BT_RECONNECT_RESULT.store(1, Ordering::Relaxed);
256 RECONNECT_BUSY.store(0, Ordering::SeqCst);
257 return;
258 }
259 if attempt == 5 {
260 if let Some(d) = bt_target_device() {
261 if Some(&d) != dev.as_ref() {
262 info!("reconnect_bt: switching target {:?} -> {d}", dev);
263 dev = Some(d);
264 }
265 }
266 }
267 std::thread::sleep(std::time::Duration::from_millis(400));
268 }
269 info!("reconnect_bt: gave up after 15 attempts");
270 BT_RECONNECT_RESULT.store(2, Ordering::Relaxed);
271 RECONNECT_BUSY.store(0, Ordering::SeqCst);
272}
273
274pub fn bt_name() -> Option<String> {
275 if !bt_status() {
276 return None;
277 }
278 let dev = bt_target_device()?;
279 let out = dbus_cmd()
280 .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
281 .output()
282 .ok()?;
283 let text = String::from_utf8_lossy(&out.stdout);
284 parse_dbus_string(&text).or_else(|| {
285 let out2 = dbus_cmd()
287 .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
288 .output()
289 .ok()?;
290 parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
291 })
292}
293
294pub fn parse_dbus_string(text: &str) -> Option<String> {
295 let idx = text.find("string \"")?;
296 let rest = &text[idx + 8..];
297 let end = rest.find('"')?;
298 let name = &rest[..end];
299 (!name.is_empty()).then(|| name.to_string())
300}
301
302pub struct BtDeviceInfo {
303 pub path: String,
304 pub name: String,
305 pub connected: bool,
306}
307
308fn device_alias(dev: &str) -> Option<String> {
309 let out = dbus_cmd()
310 .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
311 .output()
312 .ok()?;
313 let text = String::from_utf8_lossy(&out.stdout);
314 parse_dbus_string(&text).or_else(|| {
315 let out2 = dbus_cmd()
316 .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
317 .output()
318 .ok()?;
319 parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
320 })
321}
322
323pub fn bt_list_devices() -> Vec<BtDeviceInfo> {
324 let paired = discover_paired_devices();
325 paired
326 .into_iter()
327 .map(|d| {
328 let name = device_alias(&d.path).unwrap_or_else(|| {
329 d.path
330 .rsplit('/')
331 .next()
332 .unwrap_or("Unknown")
333 .replace('_', ":")
334 });
335 BtDeviceInfo {
336 name,
337 connected: d.connected,
338 path: d.path,
339 }
340 })
341 .collect()
342}
343
344pub fn bt_set_target_device(path: &str) {
352 set_cached_bt_device(path);
353}
354
355pub fn bt_connect_device(path: &str) {
356 info!("bt: switching to device {path}");
357 if let Some(current) = bt_target_device() {
358 if current != path {
359 let _ = dbus_cmd()
361 .args([¤t, DBUS_DEVICE1_DISCONNECT])
362 .stdout(Stdio::null())
363 .stderr(Stdio::null())
364 .status();
365 }
366 }
367 set_cached_bt_device(path);
368 let path = path.to_string();
369 std::thread::spawn(move || {
370 std::thread::sleep(std::time::Duration::from_millis(500));
371 for _ in 1..=4 {
372 let _ = dbus_cmd()
373 .args([&path, DBUS_DEVICE1_CONNECT])
374 .stdout(Stdio::null())
375 .stderr(Stdio::null())
376 .status();
377 let connected = device_connected(&path);
378 if connected {
379 return;
380 }
381 std::thread::sleep(std::time::Duration::from_millis(1500));
382 }
383 info!("bt_connect_device: gave up after 4 attempts ({path})");
384 });
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn default_bt_device_extracts_first_entry() {
393 let cfg = "[Audio]\nDefaultAudioDevice=/org/bluez/hci0/dev_AA_BB\nFoo=bar\n";
394 assert_eq!(
395 default_bt_device(cfg).as_deref(),
396 Some("/org/bluez/hci0/dev_AA_BB")
397 );
398 }
399
400 #[test]
401 fn default_bt_device_ignores_empty_value() {
402 assert_eq!(default_bt_device("DefaultAudioDevice=\n"), None);
403 assert_eq!(default_bt_device("DefaultAudioDevice= \n"), None);
404 assert_eq!(default_bt_device("[Section]\n"), None);
405 }
406
407 #[test]
408 fn dbus_connected_detects_true() {
409 assert!(dbus_connected(" variant boolean true\n"));
410 assert!(!dbus_connected(" variant boolean false\n"));
411 assert!(!dbus_connected(""));
412 }
413
414 #[test]
415 fn parse_dbus_string_extracts_value() {
416 let out = " variant string \"JBL Flip\"\n";
417 assert_eq!(parse_dbus_string(out).as_deref(), Some("JBL Flip"));
418 }
419
420 #[test]
421 fn parse_dbus_string_none_when_empty_or_absent() {
422 assert_eq!(parse_dbus_string("string \"\"\n"), None);
423 assert_eq!(parse_dbus_string("no string field"), None);
424 }
425}