kobo-core 0.4.0

Kobo e-reader device SDK: device database, sysfs/ioctl, rendering, audio pipeline, trait surface
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Nayeem Bin Ahsan
//! Bluetooth: adapter power, A2DP device connect/disconnect, paired-device
//! discovery, and the friendly name read-out for the panel pill. All bluez /
//! mtk.bluedroid DBus interaction lives here.

use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};

use log::{info, warn};

use crate::device::config::SocFamily;

mod discover;

pub use discover::bt_target_device;
pub use discover::PairedDevice;
use discover::{
    clear_cached_bt_device, discover_connected_paired_device, discover_paired_devices,
    set_cached_bt_device, set_last_ok_device,
};

/// Wall-clock millis of the last user-initiated BT toggle. The UI status refresh
/// uses `bt_toggle_age_ms` to avoid reverting the pill to "off" while an async
/// connect (which can take several seconds + retries) is still in flight.
static LAST_BT_TOGGLE_MS: AtomicU64 = AtomicU64::new(0);

/// Prevents multiple reconnect_bt threads from running concurrently. Each
/// bt_toggle(true) spawns a new thread; without this guard, repeated toggles
/// (wake + mode switch + panel taps) launch parallel Connect calls to
/// different devices, which confuses the BT stack.
static RECONNECT_BUSY: AtomicU8 = AtomicU8::new(0);

/// Tri-state result from the last reconnect_bt run. Consumed by the main loop
/// so `bt_on` reflects the outcome immediately on both success and failure,
/// instead of waiting for the grace period to expire.
/// 0 = idle, 1 = connected, 2 = failed
static BT_RECONNECT_RESULT: AtomicU8 = AtomicU8::new(0);

/// Returns the reconnect result since the last call, then resets to idle.
/// 0 = nothing happened, 1 = connected, 2 = gave up
pub fn bt_take_reconnect_result() -> u8 {
    BT_RECONNECT_RESULT.swap(0, Ordering::Relaxed)
}

/// True while a `reconnect_bt` thread is actively retrying. Status refreshes
/// key off this (not a fixed timeout) so the pill never flickers back to
/// "off" mid-connect no matter how long the real handshake takes -- a single
/// Device1.Connect attempt blocks for as long as the BT stack needs, so a
/// fixed grace window can expire while a connect is still genuinely in
/// flight.
pub fn bt_reconnect_busy() -> bool {
    RECONNECT_BUSY.load(Ordering::SeqCst) != 0
}

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Millis since the last BT toggle (u64::MAX if never toggled).
pub fn bt_toggle_age_ms() -> u64 {
    let t = LAST_BT_TOGGLE_MS.load(Ordering::Relaxed);
    if t == 0 {
        u64::MAX
    } else {
        now_ms().saturating_sub(t)
    }
}

/// The Bluetooth DBus bus name, fixed once at startup from the device's SoC.
/// MTK Kobos run `com.kobo.mtk.bluedroid`; NXP/sunxi run the standard `org.bluez`.
/// This is set explicitly (not probed) because a runtime probe that calls
/// `dbus_cmd` would recurse `bt_bus -> dbus_cmd -> bt_bus` and deadlock, and a
/// probe against the wrong object path returned the wrong bus on MTK (breaking
/// every BT operation). Matching develop's hard-coded per-platform value is both
/// correct and proven.
static BT_BUS: OnceLock<&'static str> = OnceLock::new();
const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
const BT_BUS_BLUEZ: &str = "org.bluez";

/// Set the BT bus once from the detected SoC family. Called at startup.
pub fn set_bt_bus(soc: SocFamily) {
    let bus = match soc {
        SocFamily::Mtk => BT_BUS_MTK,
        SocFamily::Nxp | SocFamily::Sunxi => BT_BUS_BLUEZ,
    };
    // best-effort: OnceLock::set only fails if already set (first call wins)
    let _ = BT_BUS.set(bus);
    info!("bt: bus = {} ({:?})", bus, soc);
}

/// Returns the active BT bus name. Falls back to the MTK bus if startup hasn't
/// set it yet (all currently-shipping colour/MTK devices).
pub fn bt_bus() -> &'static str {
    BT_BUS.get().copied().unwrap_or(BT_BUS_MTK)
}

const DBUS_DEVICE1_IFACE: &str = "string:org.bluez.Device1";
const DBUS_ADAPTER1_IFACE: &str = "string:org.bluez.Adapter1";
const DBUS_PROPS_GET: &str = "org.freedesktop.DBus.Properties.Get";
const DBUS_PROPS_SET: &str = "org.freedesktop.DBus.Properties.Set";
const DBUS_DEVICE1_PATH: &str = "/org/bluez/hci0";
pub(super) const DBUS_OBJECT_MANAGER: &str = "org.freedesktop.DBus.ObjectManager.GetManagedObjects";
const DBUS_DEVICE1_CONNECT: &str = "org.bluez.Device1.Connect";
const DBUS_DEVICE1_DISCONNECT: &str = "org.bluez.Device1.Disconnect";

/// Returns a `dbus-send` Command pre-configured with the detected BT bus name.
pub(super) fn dbus_cmd() -> Command {
    let dest = format!("--dest={}", bt_bus());
    let mut cmd = Command::new("dbus-send");
    cmd.args(["--system", "--print-reply", "--type=method_call"]);
    cmd.arg(dest);
    cmd
}

/// True when the given bluez Device1 path reports `Connected` (the ACL link is
/// up). Shared by [`bt_status`] and [`bt_name`] so both agree on connection.
fn device_connected(dev: &str) -> bool {
    let out = dbus_cmd()
        .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Connected"])
        .output();
    match out {
        Ok(o) if o.status.success() => dbus_connected(&String::from_utf8_lossy(&o.stdout)),
        _ => false,
    }
}

pub fn bt_status() -> bool {
    let dev = match bt_target_device() {
        Some(d) => d,
        None => return false,
    };
    if device_connected(&dev) {
        set_last_ok_device(&dev);
        return true;
    }
    if let Some(connected) = discover_connected_paired_device() {
        if connected != dev {
            info!(
                "bt: switching target {} -> {} (already connected by btservice)",
                dev, connected
            );
            set_cached_bt_device(&connected);
            set_last_ok_device(&connected);
            return true;
        }
        set_last_ok_device(&connected);
    }
    false
}

pub fn default_bt_device(content: &str) -> Option<String> {
    content
        .lines()
        .find_map(|l| {
            l.strip_prefix("DefaultAudioDevice=")
                .map(|s| s.trim().to_string())
        })
        .filter(|d| !d.is_empty())
}

pub fn dbus_connected(text: &str) -> bool {
    text.contains("boolean true")
}

pub fn bt_toggle(on: bool) {
    // Stamp the toggle so the UI status refresh doesn't revert the pill while
    // the (async, multi-retry) connect is still settling.
    LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
    if on {
        // Power the adapter up SYNCHRONOUSLY (matches develop exactly). The Set
        // Powered call is fast and never hangs, and doing it on the caller's
        // thread means the adapter is already on before reconnect runs - which is
        // what made develop's single ON tap connect reliably.
        if let Err(e) = dbus_cmd()
            .args([
                DBUS_DEVICE1_PATH,
                DBUS_PROPS_SET,
                DBUS_ADAPTER1_IFACE,
                "string:Powered",
                "variant:boolean:true",
            ])
            .status()
        {
            warn!("bt: adapter power-on failed: {e}");
        }
        info!("bt: adapter powered on (bus={})", bt_bus());
        // Reconnect (Connect can retry for several seconds) off the main loop.
        // best-effort: the handle is dropped so the thread runs detached
        let _ = std::thread::spawn(reconnect_bt);
        info!("bt: turned ON + reconnecting");
    } else {
        // Power-down path: a Device1.Disconnect can block indefinitely when the
        // configured speaker isn't actually linked, and callers (sleep entry,
        // panel toggle) run on the main loop - so run the whole thing off-thread
        // to never freeze the UI on any device.
        std::thread::spawn(move || {
            if let Some(dev) = bt_target_device() {
                if let Err(e) = dbus_cmd()
                    .args([&dev, DBUS_DEVICE1_DISCONNECT])
                    .stdout(Stdio::null())
                    .stderr(Stdio::null())
                    .status()
                {
                    warn!("bt: disconnect {dev} failed: {e}");
                }
            }
            clear_cached_bt_device();
            if let Err(e) = dbus_cmd()
                .args([
                    DBUS_DEVICE1_PATH,
                    DBUS_PROPS_SET,
                    DBUS_ADAPTER1_IFACE,
                    "string:Powered",
                    "variant:boolean:false",
                ])
                .status()
            {
                warn!("bt: adapter power-off failed: {e}");
            }
            info!("bt: turned OFF (adapter powered down)");
        });
    }
}

pub fn reconnect_bt() {
    if RECONNECT_BUSY.swap(1, Ordering::SeqCst) != 0 {
        info!("reconnect_bt: already running, skipping");
        return;
    }
    let mut dev: Option<String> = None;
    info!("reconnect_bt: connecting on bus={}", bt_bus());
    for attempt in 1..=15 {
        if dev.is_none() {
            dev = bt_target_device();
            if dev.is_none() {
                std::thread::sleep(std::time::Duration::from_millis(400));
                continue;
            }
        }
        let dev_str = dev.as_ref().unwrap();
        let _ = dbus_cmd()
            .args([dev_str, DBUS_DEVICE1_CONNECT])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        let connected = bt_status();
        if connected {
            info!("reconnect_bt: connected on attempt {attempt}");
            BT_RECONNECT_RESULT.store(1, Ordering::Relaxed);
            RECONNECT_BUSY.store(0, Ordering::SeqCst);
            return;
        }
        if attempt == 5 {
            if let Some(d) = bt_target_device() {
                if Some(&d) != dev.as_ref() {
                    info!("reconnect_bt: switching target {:?} -> {d}", dev);
                    dev = Some(d);
                }
            }
        }
        std::thread::sleep(std::time::Duration::from_millis(400));
    }
    info!("reconnect_bt: gave up after 15 attempts");
    BT_RECONNECT_RESULT.store(2, Ordering::Relaxed);
    RECONNECT_BUSY.store(0, Ordering::SeqCst);
}

pub fn bt_name() -> Option<String> {
    if !bt_status() {
        return None;
    }
    let dev = bt_target_device()?;
    let out = dbus_cmd()
        .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    parse_dbus_string(&text).or_else(|| {
        // Some stacks expose "Name" rather than "Alias".
        let out2 = dbus_cmd()
            .args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
            .output()
            .ok()?;
        parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
    })
}

pub fn parse_dbus_string(text: &str) -> Option<String> {
    let idx = text.find("string \"")?;
    let rest = &text[idx + 8..];
    let end = rest.find('"')?;
    let name = &rest[..end];
    (!name.is_empty()).then(|| name.to_string())
}

pub struct BtDeviceInfo {
    pub path: String,
    pub name: String,
    pub connected: bool,
}

fn device_alias(dev: &str) -> Option<String> {
    let out = dbus_cmd()
        .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    parse_dbus_string(&text).or_else(|| {
        let out2 = dbus_cmd()
            .args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
            .output()
            .ok()?;
        parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
    })
}

pub fn bt_list_devices() -> Vec<BtDeviceInfo> {
    let paired = discover_paired_devices();
    paired
        .into_iter()
        .map(|d| {
            let name = device_alias(&d.path).unwrap_or_else(|| {
                d.path
                    .rsplit('/')
                    .next()
                    .unwrap_or("Unknown")
                    .replace('_', ":")
            });
            BtDeviceInfo {
                name,
                connected: d.connected,
                path: d.path,
            }
        })
        .collect()
}

/// Pins the BT target device without spawning a connect thread. Callers that
/// are about to trigger `bt_toggle(true)` (which spawns `reconnect_bt`) use
/// this to steer that reconnect at a specific device instead of letting it
/// fall back to cached/last-known/default -- avoids running a second,
/// unguarded connect loop (`bt_connect_device`) in parallel with
/// `reconnect_bt`, which was hammering the same device with concurrent
/// Connect calls.
pub fn bt_set_target_device(path: &str) {
    set_cached_bt_device(path);
}

pub fn bt_connect_device(path: &str) {
    info!("bt: switching to device {path}");
    if let Some(current) = bt_target_device() {
        if current != path {
            // best-effort: a failed disconnect doesn't block the connect that follows
            let _ = dbus_cmd()
                .args([&current, DBUS_DEVICE1_DISCONNECT])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
        }
    }
    set_cached_bt_device(path);
    let path = path.to_string();
    std::thread::spawn(move || {
        std::thread::sleep(std::time::Duration::from_millis(500));
        for _ in 1..=4 {
            let _ = dbus_cmd()
                .args([&path, DBUS_DEVICE1_CONNECT])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
            let connected = device_connected(&path);
            if connected {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(1500));
        }
        info!("bt_connect_device: gave up after 4 attempts ({path})");
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_bt_device_extracts_first_entry() {
        let cfg = "[Audio]\nDefaultAudioDevice=/org/bluez/hci0/dev_AA_BB\nFoo=bar\n";
        assert_eq!(
            default_bt_device(cfg).as_deref(),
            Some("/org/bluez/hci0/dev_AA_BB")
        );
    }

    #[test]
    fn default_bt_device_ignores_empty_value() {
        assert_eq!(default_bt_device("DefaultAudioDevice=\n"), None);
        assert_eq!(default_bt_device("DefaultAudioDevice=   \n"), None);
        assert_eq!(default_bt_device("[Section]\n"), None);
    }

    #[test]
    fn dbus_connected_detects_true() {
        assert!(dbus_connected("   variant   boolean true\n"));
        assert!(!dbus_connected("   variant   boolean false\n"));
        assert!(!dbus_connected(""));
    }

    #[test]
    fn parse_dbus_string_extracts_value() {
        let out = "   variant       string \"JBL Flip\"\n";
        assert_eq!(parse_dbus_string(out).as_deref(), Some("JBL Flip"));
    }

    #[test]
    fn parse_dbus_string_none_when_empty_or_absent() {
        assert_eq!(parse_dbus_string("string \"\"\n"), None);
        assert_eq!(parse_dbus_string("no string field"), None);
    }
}