Skip to main content

kobo_core/device/bt/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! Bluetooth: adapter power, A2DP device connect/disconnect, paired-device
4//! discovery, and the friendly name read-out for the panel pill. All bluez /
5//! mtk.bluedroid DBus interaction lives here.
6
7use 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
25/// Wall-clock millis of the last user-initiated BT toggle. The UI status refresh
26/// uses `bt_toggle_age_ms` to avoid reverting the pill to "off" while an async
27/// connect (which can take several seconds + retries) is still in flight.
28static LAST_BT_TOGGLE_MS: AtomicU64 = AtomicU64::new(0);
29
30/// Prevents multiple reconnect_bt threads from running concurrently. Each
31/// bt_toggle(true) spawns a new thread; without this guard, repeated toggles
32/// (wake + mode switch + panel taps) launch parallel Connect calls to
33/// different devices, which confuses the BT stack.
34static RECONNECT_BUSY: AtomicU8 = AtomicU8::new(0);
35
36/// Tri-state result from the last reconnect_bt run. Consumed by the main loop
37/// so `bt_on` reflects the outcome immediately on both success and failure,
38/// instead of waiting for the grace period to expire.
39/// 0 = idle, 1 = connected, 2 = failed
40static BT_RECONNECT_RESULT: AtomicU8 = AtomicU8::new(0);
41
42/// Returns the reconnect result since the last call, then resets to idle.
43/// 0 = nothing happened, 1 = connected, 2 = gave up
44pub fn bt_take_reconnect_result() -> u8 {
45    BT_RECONNECT_RESULT.swap(0, Ordering::Relaxed)
46}
47
48/// True while a `reconnect_bt` thread is actively retrying. Status refreshes
49/// key off this (not a fixed timeout) so the pill never flickers back to
50/// "off" mid-connect no matter how long the real handshake takes -- a single
51/// Device1.Connect attempt blocks for as long as the BT stack needs, so a
52/// fixed grace window can expire while a connect is still genuinely in
53/// flight.
54pub 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
65/// Millis since the last BT toggle (u64::MAX if never toggled).
66pub 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
75/// The Bluetooth DBus bus name, fixed once at startup from the device's SoC.
76/// MTK Kobos run `com.kobo.mtk.bluedroid`; NXP/sunxi run the standard `org.bluez`.
77/// This is set explicitly (not probed) because a runtime probe that calls
78/// `dbus_cmd` would recurse `bt_bus -> dbus_cmd -> bt_bus` and deadlock, and a
79/// probe against the wrong object path returned the wrong bus on MTK (breaking
80/// every BT operation). Matching develop's hard-coded per-platform value is both
81/// correct and proven.
82static BT_BUS: OnceLock<&'static str> = OnceLock::new();
83const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
84const BT_BUS_BLUEZ: &str = "org.bluez";
85
86/// Set the BT bus once from the detected SoC family. Called at startup.
87pub 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    // best-effort: OnceLock::set only fails if already set (first call wins)
93    let _ = BT_BUS.set(bus);
94    info!("bt: bus = {} ({:?})", bus, soc);
95}
96
97/// Returns the active BT bus name. Falls back to the MTK bus if startup hasn't
98/// set it yet (all currently-shipping colour/MTK devices).
99pub 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
112/// Returns a `dbus-send` Command pre-configured with the detected BT bus name.
113pub(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
121/// True when the given bluez Device1 path reports `Connected` (the ACL link is
122/// up). Shared by [`bt_status`] and [`bt_name`] so both agree on connection.
123fn 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    // Stamp the toggle so the UI status refresh doesn't revert the pill while
173    // the (async, multi-retry) connect is still settling.
174    LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
175    if on {
176        // Power the adapter up SYNCHRONOUSLY (matches develop exactly). The Set
177        // Powered call is fast and never hangs, and doing it on the caller's
178        // thread means the adapter is already on before reconnect runs - which is
179        // what made develop's single ON tap connect reliably.
180        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        // Reconnect (Connect can retry for several seconds) off the main loop.
194        // best-effort: the handle is dropped so the thread runs detached
195        let _ = std::thread::spawn(reconnect_bt);
196        info!("bt: turned ON + reconnecting");
197    } else {
198        // Power-down path: a Device1.Disconnect can block indefinitely when the
199        // configured speaker isn't actually linked, and callers (sleep entry,
200        // panel toggle) run on the main loop - so run the whole thing off-thread
201        // to never freeze the UI on any device.
202        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        // Some stacks expose "Name" rather than "Alias".
286        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
344/// Pins the BT target device without spawning a connect thread. Callers that
345/// are about to trigger `bt_toggle(true)` (which spawns `reconnect_bt`) use
346/// this to steer that reconnect at a specific device instead of letting it
347/// fall back to cached/last-known/default -- avoids running a second,
348/// unguarded connect loop (`bt_connect_device`) in parallel with
349/// `reconnect_bt`, which was hammering the same device with concurrent
350/// Connect calls.
351pub 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            // best-effort: a failed disconnect doesn't block the connect that follows
360            let _ = dbus_cmd()
361                .args([&current, 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}