kobo_core/device/bt/
mod.rs1use 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
23static 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
35pub 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
45static BT_BUS: OnceLock<&'static str> = OnceLock::new();
53const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
54const BT_BUS_BLUEZ: &str = "org.bluez";
55
56pub 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
66pub fn bt_bus() -> &'static str {
69 BT_BUS.get().copied().unwrap_or(BT_BUS_MTK)
70}
71
72pub 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
100pub(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
109fn 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 LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
160 if on {
161 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 let _ = std::thread::spawn(reconnect_bt);
180 info!("bt: turned ON + reconnecting");
181 } else {
182 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 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 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 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}