Skip to main content

kobo_core/device/bt/
mod.rs

1//! Bluetooth: adapter power, A2DP device connect/disconnect, paired-device
2//! discovery, and the friendly name read-out for the panel pill. All bluez /
3//! mtk.bluedroid DBus interaction lives here.
4
5use std::fs;
6use std::process::{Command, Stdio};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::OnceLock;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use log::{debug, info, warn};
12
13use crate::device::config::SocFamily;
14
15mod discover;
16
17pub use discover::bt_target_device;
18use discover::{
19    clear_cached_bt_device, discover_connected_paired_device, discover_paired_devices,
20    set_cached_bt_device,
21};
22
23/// Wall-clock millis of the last user-initiated BT toggle. The UI status refresh
24/// uses `bt_toggle_age_ms` to avoid reverting the pill to "off" while an async
25/// connect (which can take several seconds + retries) is still in flight.
26static LAST_BT_TOGGLE_MS: AtomicU64 = AtomicU64::new(0);
27
28fn now_ms() -> u64 {
29    SystemTime::now()
30        .duration_since(UNIX_EPOCH)
31        .map(|d| d.as_millis() as u64)
32        .unwrap_or(0)
33}
34
35/// Millis since the last BT toggle (u64::MAX if never toggled).
36pub fn bt_toggle_age_ms() -> u64 {
37    let t = LAST_BT_TOGGLE_MS.load(Ordering::Relaxed);
38    if t == 0 {
39        u64::MAX
40    } else {
41        now_ms().saturating_sub(t)
42    }
43}
44
45/// The Bluetooth DBus bus name, fixed once at startup from the device's SoC.
46/// MTK Kobos run `com.kobo.mtk.bluedroid`; NXP/sunxi run the standard `org.bluez`.
47/// This is set explicitly (not probed) because a runtime probe that calls
48/// `dbus_cmd` would recurse `bt_bus -> dbus_cmd -> bt_bus` and deadlock, and a
49/// probe against the wrong object path returned the wrong bus on MTK (breaking
50/// every BT operation). Matching develop's hard-coded per-platform value is both
51/// correct and proven.
52static BT_BUS: OnceLock<&'static str> = OnceLock::new();
53const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
54const BT_BUS_BLUEZ: &str = "org.bluez";
55
56/// Set the BT bus once from the detected SoC family. Called at startup.
57pub fn set_bt_bus(soc: SocFamily) {
58    let bus = match soc {
59        SocFamily::Mtk => BT_BUS_MTK,
60        SocFamily::Nxp | SocFamily::Sunxi => BT_BUS_BLUEZ,
61    };
62    let _ = BT_BUS.set(bus);
63    info!("bt: bus = {} ({:?})", bus, soc);
64}
65
66/// Returns the active BT bus name. Falls back to the MTK bus if startup hasn't
67/// set it yet (all currently-shipping colour/MTK devices).
68pub fn bt_bus() -> &'static str {
69    BT_BUS.get().copied().unwrap_or(BT_BUS_MTK)
70}
71
72/// One-time BT diagnostic at startup: log the bus, whether nickel's BT config
73/// exists, and the paired default audio device. This pins down whether a failed
74/// toggle is "no paired device configured" vs "connect call failing".
75pub fn log_bt_diagnostics() {
76    let cfg = fs::read_to_string(crate::device::paths::BT_CONFIG_FILE).unwrap_or_default();
77    let cfg_dev = default_bt_device(&cfg);
78    let devices = discover_paired_devices();
79    debug!(
80        "bt diag: bus={}, config_default={:?}, paired={:?}, target={:?}",
81        bt_bus(),
82        cfg_dev,
83        devices
84            .iter()
85            .map(|d| format!("{}(connected={})", d.path, d.connected))
86            .collect::<Vec<_>>(),
87        bt_target_device()
88    );
89}
90
91const DBUS_DEVICE1_IFACE: &str = "string:org.bluez.Device1";
92const DBUS_ADAPTER1_IFACE: &str = "string:org.bluez.Adapter1";
93const DBUS_PROPS_GET: &str = "org.freedesktop.DBus.Properties.Get";
94const DBUS_PROPS_SET: &str = "org.freedesktop.DBus.Properties.Set";
95const DBUS_DEVICE1_PATH: &str = "/org/bluez/hci0";
96pub(super) const DBUS_OBJECT_MANAGER: &str = "org.freedesktop.DBus.ObjectManager.GetManagedObjects";
97const DBUS_DEVICE1_CONNECT: &str = "org.bluez.Device1.Connect";
98const DBUS_DEVICE1_DISCONNECT: &str = "org.bluez.Device1.Disconnect";
99
100/// Returns a `dbus-send` Command pre-configured with the detected BT bus name.
101pub(super) fn dbus_cmd() -> Command {
102    let dest = format!("--dest={}", bt_bus());
103    let mut cmd = Command::new("dbus-send");
104    cmd.args(["--system", "--print-reply", "--type=method_call"]);
105    cmd.arg(dest);
106    cmd
107}
108
109/// True when the given bluez Device1 path reports `Connected` (the ACL link is
110/// up). Shared by [`bt_status`] and [`bt_name`] so both agree on connection.
111fn device_connected(dev: &str) -> bool {
112    let out = dbus_cmd()
113        .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Connected"])
114        .output();
115    match out {
116        Ok(o) if o.status.success() => dbus_connected(&String::from_utf8_lossy(&o.stdout)),
117        _ => false,
118    }
119}
120
121pub fn bt_status() -> bool {
122    let dev = match bt_target_device() {
123        Some(d) => d,
124        None => return false,
125    };
126    if device_connected(&dev) {
127        return true;
128    }
129    if let Some(connected) = discover_connected_paired_device() {
130        if connected != dev {
131            info!(
132                "bt: switching target {} -> {} (already connected by btservice)",
133                dev, connected
134            );
135            set_cached_bt_device(&connected);
136            return true;
137        }
138    }
139    false
140}
141
142pub fn default_bt_device(content: &str) -> Option<String> {
143    content
144        .lines()
145        .find_map(|l| {
146            l.strip_prefix("DefaultAudioDevice=")
147                .map(|s| s.trim().to_string())
148        })
149        .filter(|d| !d.is_empty())
150}
151
152pub fn dbus_connected(text: &str) -> bool {
153    text.contains("boolean true")
154}
155
156pub fn bt_toggle(on: bool) {
157    // Stamp the toggle so the UI status refresh doesn't revert the pill while
158    // the (async, multi-retry) connect is still settling.
159    LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
160    if on {
161        // Power the adapter up SYNCHRONOUSLY (matches develop exactly). The Set
162        // Powered call is fast and never hangs, and doing it on the caller's
163        // thread means the adapter is already on before reconnect runs - which is
164        // what made develop's single ON tap connect reliably.
165        if let Err(e) = dbus_cmd()
166            .args([
167                DBUS_DEVICE1_PATH,
168                DBUS_PROPS_SET,
169                DBUS_ADAPTER1_IFACE,
170                "string:Powered",
171                "variant:boolean:true",
172            ])
173            .status()
174        {
175            warn!("bt: adapter power-on failed: {e}");
176        }
177        info!("bt: adapter powered on (bus={})", bt_bus());
178        // Reconnect (Connect can retry for several seconds) off the main loop.
179        let _ = std::thread::spawn(reconnect_bt);
180        info!("bt: turned ON + reconnecting");
181    } else {
182        // Power-down path: a Device1.Disconnect can block indefinitely when the
183        // configured speaker isn't actually linked, and callers (sleep entry,
184        // panel toggle) run on the main loop - so run the whole thing off-thread
185        // to never freeze the UI on any device.
186        std::thread::spawn(move || {
187            if let Some(dev) = bt_target_device() {
188                if let Err(e) = dbus_cmd()
189                    .args([&dev, DBUS_DEVICE1_DISCONNECT])
190                    .stdout(Stdio::null())
191                    .stderr(Stdio::null())
192                    .status()
193                {
194                    warn!("bt: disconnect {dev} failed: {e}");
195                }
196                debug!("bt: disconnecting {dev}");
197            }
198            clear_cached_bt_device();
199            if let Err(e) = dbus_cmd()
200                .args([
201                    DBUS_DEVICE1_PATH,
202                    DBUS_PROPS_SET,
203                    DBUS_ADAPTER1_IFACE,
204                    "string:Powered",
205                    "variant:boolean:false",
206                ])
207                .status()
208            {
209                warn!("bt: adapter power-off failed: {e}");
210            }
211            info!("bt: turned OFF (adapter powered down)");
212        });
213    }
214}
215
216pub fn reconnect_bt() {
217    // Resolve the target device: configured default, else a paired device
218    // discovered on the adapter (the config key is absent on newer firmware /
219    // after a factory reset). Without this, KoThok had nothing to connect to.
220    let dev = match bt_target_device() {
221        Some(d) => d,
222        None => {
223            info!("reconnect_bt: no paired device found on adapter, nothing to connect");
224            return;
225        }
226    };
227    info!("reconnect_bt: connecting {dev} on bus={}", bt_bus());
228    // The adapter was just powered on; the first Connect issued immediately
229    // after power-up frequently fails (the stack needs a moment to settle). A
230    // short lead delay plus a bounded retry loop makes a single ON tap reliably
231    // connect, instead of forcing the user to tap repeatedly.
232    std::thread::sleep(std::time::Duration::from_millis(800));
233    for attempt in 1..=6 {
234        let rc = dbus_cmd()
235            .args([&dev, DBUS_DEVICE1_CONNECT])
236            .stdout(Stdio::null())
237            .stderr(Stdio::null())
238            .status();
239        let connected = bt_status();
240        debug!(
241            "reconnect_bt: attempt {attempt} rc={:?} connected={}",
242            rc.as_ref().ok().and_then(|s| s.code()),
243            connected
244        );
245        if connected {
246            return;
247        }
248        std::thread::sleep(std::time::Duration::from_millis(1500));
249    }
250    info!("reconnect_bt: gave up after 6 attempts ({dev})");
251}
252
253pub fn bt_name() -> Option<String> {
254    if !bt_status() {
255        return None;
256    }
257    let dev = bt_target_device()?;
258    let out = dbus_cmd()
259        .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
260        .output()
261        .ok()?;
262    let text = String::from_utf8_lossy(&out.stdout);
263    parse_dbus_string(&text).or_else(|| {
264        // Some stacks expose "Name" rather than "Alias".
265        let out2 = dbus_cmd()
266            .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
267            .output()
268            .ok()?;
269        parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
270    })
271}
272
273pub fn parse_dbus_string(text: &str) -> Option<String> {
274    let idx = text.find("string \"")?;
275    let rest = &text[idx + 8..];
276    let end = rest.find('"')?;
277    let name = &rest[..end];
278    (!name.is_empty()).then(|| name.to_string())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn default_bt_device_extracts_first_entry() {
287        let cfg = "[Audio]\nDefaultAudioDevice=/org/bluez/hci0/dev_AA_BB\nFoo=bar\n";
288        assert_eq!(
289            default_bt_device(cfg).as_deref(),
290            Some("/org/bluez/hci0/dev_AA_BB")
291        );
292    }
293
294    #[test]
295    fn default_bt_device_ignores_empty_value() {
296        assert_eq!(default_bt_device("DefaultAudioDevice=\n"), None);
297        assert_eq!(default_bt_device("DefaultAudioDevice=   \n"), None);
298        assert_eq!(default_bt_device("[Section]\n"), None);
299    }
300
301    #[test]
302    fn dbus_connected_detects_true() {
303        assert!(dbus_connected("   variant   boolean true\n"));
304        assert!(!dbus_connected("   variant   boolean false\n"));
305        assert!(!dbus_connected(""));
306    }
307
308    #[test]
309    fn parse_dbus_string_extracts_value() {
310        let out = "   variant       string \"JBL Flip\"\n";
311        assert_eq!(parse_dbus_string(out).as_deref(), Some("JBL Flip"));
312    }
313
314    #[test]
315    fn parse_dbus_string_none_when_empty_or_absent() {
316        assert_eq!(parse_dbus_string("string \"\"\n"), None);
317        assert_eq!(parse_dbus_string("no string field"), None);
318    }
319}