Skip to main content

kobo_core/device/bt/
discover.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! Bluetooth paired-device discovery: parse the bluez ObjectManager dump,
4//! cache the active target device, and resolve which device to talk to.
5
6use std::fs;
7use std::sync::Mutex;
8
9use super::{dbus_cmd, default_bt_device, DBUS_OBJECT_MANAGER};
10
11pub struct PairedDevice {
12    pub path: String,
13    pub connected: bool,
14}
15
16static CACHED_BT_DEVICE: Mutex<Option<String>> = Mutex::new(None);
17
18static LAST_OK_DEVICE: Mutex<Option<String>> = Mutex::new(None);
19
20fn get_cached_bt_device() -> Option<String> {
21    CACHED_BT_DEVICE.lock().ok().and_then(|g| g.clone())
22}
23
24pub(super) fn set_cached_bt_device(dev: &str) {
25    if let Ok(mut guard) = CACHED_BT_DEVICE.lock() {
26        *guard = Some(dev.to_string());
27    }
28}
29
30pub(super) fn clear_cached_bt_device() {
31    if let Ok(mut guard) = CACHED_BT_DEVICE.lock() {
32        *guard = None;
33    }
34}
35
36pub(super) fn set_last_ok_device(dev: &str) {
37    if let Ok(mut guard) = LAST_OK_DEVICE.lock() {
38        *guard = Some(dev.to_string());
39    }
40}
41
42fn get_last_ok_device() -> Option<String> {
43    LAST_OK_DEVICE.lock().ok().and_then(|g| g.clone())
44}
45
46fn parse_managed_objects(text: &str) -> Vec<PairedDevice> {
47    let mut result: Vec<PairedDevice> = Vec::new();
48    let mut current_path: Option<String> = None;
49    let mut in_device_iface = false;
50    let mut is_paired = false;
51    let mut is_connected = false;
52    let mut expect_paired_val = false;
53    let mut expect_connected_val = false;
54    for line in text.lines() {
55        let t = line.trim();
56        if let Some(p) = t
57            .strip_prefix("object path \"")
58            .and_then(|s| s.strip_suffix('"'))
59        {
60            if in_device_iface && is_paired {
61                if let Some(path) = current_path.take() {
62                    result.push(PairedDevice {
63                        path,
64                        connected: is_connected,
65                    });
66                }
67            }
68            current_path = Some(p.to_string());
69            in_device_iface = false;
70            is_paired = false;
71            is_connected = false;
72            expect_paired_val = false;
73            expect_connected_val = false;
74            continue;
75        }
76        if t == "string \"org.bluez.Device1\"" {
77            in_device_iface = true;
78            continue;
79        }
80        if t == "string \"Paired\"" {
81            expect_paired_val = true;
82            continue;
83        }
84        if t == "string \"Connected\"" {
85            expect_connected_val = true;
86            continue;
87        }
88        if expect_paired_val {
89            expect_paired_val = false;
90            if t.contains("boolean true") && in_device_iface {
91                is_paired = true;
92            }
93        } else if expect_connected_val {
94            expect_connected_val = false;
95            if t.contains("boolean true") && in_device_iface {
96                is_connected = true;
97            }
98        }
99    }
100    if in_device_iface && is_paired {
101        if let Some(path) = current_path.take() {
102            result.push(PairedDevice {
103                path,
104                connected: is_connected,
105            });
106        }
107    }
108    result
109}
110
111pub fn discover_paired_devices() -> Vec<PairedDevice> {
112    let out = match dbus_cmd().args(["/", DBUS_OBJECT_MANAGER]).output() {
113        Ok(o) => o,
114        Err(_) => return Vec::new(),
115    };
116    let text = String::from_utf8_lossy(&out.stdout);
117    let result = parse_managed_objects(&text);
118    result
119}
120
121pub(super) fn discover_connected_paired_device() -> Option<String> {
122    discover_paired_devices()
123        .into_iter()
124        .find(|d| d.connected)
125        .map(|d| d.path)
126}
127
128/// The BT device to connect/query: a device already connected by btservice
129/// (preferred), then the last known good device, then the configured default,
130/// then the first paired device. Cached for the session; the cache is cleared
131/// on BT power-off so the next power-on re-resolves with live connected state.
132pub fn bt_target_device() -> Option<String> {
133    if let Some(d) = get_cached_bt_device() {
134        return Some(d);
135    }
136    let devices = discover_paired_devices();
137    let found = devices
138        .iter()
139        .find(|d| d.connected)
140        .map(|d| d.path.clone())
141        .or_else(|| get_last_ok_device())
142        .or_else(|| {
143            fs::read_to_string(crate::device::paths::BT_CONFIG_FILE)
144                .ok()
145                .and_then(|c| default_bt_device(&c))
146        })
147        .or_else(|| devices.first().map(|d| d.path.clone()))?;
148    set_cached_bt_device(&found);
149    Some(found)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn parse_managed_objects_finds_paired_with_connected() {
158        let dbus_output = "\
159object path \"/org/bluez/hci0\"\n\
160   dict entry(\n\
161      string \"org.bluez.Adapter1\"\n\
162      dict entry(\n\
163         string \"Powered\"\n\
164         variant          boolean true\n\
165      )\n\
166   )\n\
167object path \"/org/bluez/hci0/dev_11_22_33_44_55_66\"\n\
168   dict entry(\n\
169      string \"org.bluez.Device1\"\n\
170      dict entry(\n\
171         string \"Paired\"\n\
172         variant          boolean true\n\
173      )\n\
174      dict entry(\n\
175         string \"Connected\"\n\
176         variant          boolean true\n\
177      )\n\
178   )\n\
179object path \"/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF\"\n\
180   dict entry(\n\
181      string \"org.bluez.Device1\"\n\
182      dict entry(\n\
183         string \"Paired\"\n\
184         variant          boolean true\n\
185      )\n\
186      dict entry(\n\
187         string \"Connected\"\n\
188         variant          boolean false\n\
189      )\n\
190   )\n";
191        let devices = parse_managed_objects(dbus_output);
192        assert_eq!(devices.len(), 2);
193        assert_eq!(devices[0].path, "/org/bluez/hci0/dev_11_22_33_44_55_66");
194        assert!(devices[0].connected);
195        assert_eq!(devices[1].path, "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF");
196        assert!(!devices[1].connected);
197    }
198
199    #[test]
200    fn parse_managed_objects_skips_unpaired() {
201        let dbus_output = "\
202object path \"/org/bluez/hci0/dev_FF_EE_DD_CC_BB_AA\"\n\
203   dict entry(\n\
204      string \"org.bluez.Device1\"\n\
205      dict entry(\n\
206         string \"Paired\"\n\
207         variant          boolean false\n\
208      )\n\
209      dict entry(\n\
210         string \"Connected\"\n\
211         variant          boolean true\n\
212      )\n\
213   )\n";
214        let devices = parse_managed_objects(dbus_output);
215        assert!(devices.is_empty());
216    }
217
218    #[test]
219    fn parse_managed_objects_empty_input() {
220        assert!(parse_managed_objects("").is_empty());
221    }
222}