Skip to main content

retch_sysinfo/
bluetooth.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Bluetooth controller state and connected device detection.
5
6/// Detects Bluetooth power state, adapter hardware, and connected devices.
7pub fn detect_bluetooth() -> Option<String> {
8    #[cfg(target_os = "linux")]
9    {
10        if let Ok(entries) = std::fs::read_dir("/sys/class/bluetooth") {
11            let mut hcis = Vec::new();
12            for entry in entries.filter_map(|e| e.ok()) {
13                let name = entry.file_name().to_string_lossy().to_string();
14                if name.starts_with("hci") {
15                    hcis.push(name);
16                }
17            }
18            hcis.sort();
19
20            if !hcis.is_empty() {
21                let hci = &hcis[0];
22                let mut state = "Off";
23                if let Ok(subdirs) = std::fs::read_dir(format!("/sys/class/bluetooth/{}", hci)) {
24                    for sub in subdirs.filter_map(|e| e.ok()) {
25                        let sub_name = sub.file_name().to_string_lossy().to_string();
26                        if sub_name.starts_with("rfkill") {
27                            if let Ok(st) = std::fs::read_to_string(sub.path().join("state")) {
28                                if st.trim() == "1" || st.trim() == "3" {
29                                    state = "On";
30                                }
31                            }
32                        }
33                    }
34                }
35
36                let mut hw_info = None;
37                if let Ok(canonical_device) =
38                    std::fs::canonicalize(format!("/sys/class/bluetooth/{}/device", hci))
39                {
40                    let mut current = Some(canonical_device);
41                    while let Some(path) = current {
42                        let id_vendor = path.join("idVendor");
43                        let id_product = path.join("idProduct");
44                        let pci_vendor = path.join("vendor");
45                        let pci_device = path.join("device");
46
47                        if id_vendor.exists() && id_product.exists() {
48                            if let (Ok(v), Ok(p)) = (
49                                std::fs::read_to_string(id_vendor),
50                                std::fs::read_to_string(id_product),
51                            ) {
52                                let v_clean = v.trim();
53                                let p_clean = p.trim();
54                                let vendor_name = lookup_usb_vendor(v_clean);
55                                let product_name = lookup_usb_device(v_clean, p_clean);
56                                match (vendor_name, product_name) {
57                                    (Some(v_name), Some(p_name)) => {
58                                        let v_disp = v_name
59                                            .replace(", Inc.", "")
60                                            .replace(" Corporation", "")
61                                            .replace(" Co., Ltd.", "")
62                                            .replace(" Co., Ltd", "");
63                                        hw_info = Some(format!("{} {}", v_disp, p_name));
64                                    }
65                                    (Some(v_name), None) => {
66                                        let v_disp = v_name
67                                            .replace(", Inc.", "")
68                                            .replace(" Corporation", "")
69                                            .replace(" Co., Ltd.", "")
70                                            .replace(" Co., Ltd", "");
71                                        hw_info = Some(v_disp);
72                                    }
73                                    _ => {}
74                                }
75                                break;
76                            }
77                        } else if pci_vendor.exists()
78                            && pci_device.exists()
79                            && !pci_vendor.is_dir()
80                            && !pci_device.is_dir()
81                        {
82                            if let (Ok(v), Ok(d)) = (
83                                std::fs::read_to_string(pci_vendor),
84                                std::fs::read_to_string(pci_device),
85                            ) {
86                                let v_clean = v.trim().trim_start_matches("0x").to_lowercase();
87                                let d_clean = d.trim().trim_start_matches("0x").to_lowercase();
88                                let vendor_name = crate::network::lookup_pci_vendor(&v_clean);
89                                let product_name =
90                                    crate::gpu::lookup_pci_device(&v_clean, &d_clean);
91                                match (vendor_name, product_name) {
92                                    (Some(v_name), Some(p_name)) => {
93                                        let v_disp = v_name
94                                            .replace(", Inc.", "")
95                                            .replace(" Corporation", "")
96                                            .replace(" Co., Ltd.", "")
97                                            .replace(" Co., Ltd", "");
98                                        hw_info = Some(format!("{} {}", v_disp, p_name));
99                                    }
100                                    (Some(v_name), None) => {
101                                        let v_disp = v_name
102                                            .replace(", Inc.", "")
103                                            .replace(" Corporation", "")
104                                            .replace(" Co., Ltd.", "")
105                                            .replace(" Co., Ltd", "");
106                                        hw_info = Some(v_disp);
107                                    }
108                                    _ => {}
109                                }
110                                break;
111                            }
112                        }
113                        current = path.parent().map(|p| p.to_path_buf());
114                    }
115                }
116
117                let mut connected_names = Vec::new();
118                if let Ok(output) = std::process::Command::new("bluetoothctl")
119                    .args(["devices", "Connected"])
120                    .output()
121                {
122                    if let Ok(stdout) = String::from_utf8(output.stdout) {
123                        for line in stdout.lines() {
124                            let trimmed = line.trim();
125                            if trimmed.starts_with("Device ") {
126                                let parts: Vec<&str> = trimmed.split_whitespace().collect();
127                                if parts.len() >= 3 {
128                                    let name = parts[2..].join(" ");
129                                    connected_names.push(name);
130                                }
131                            }
132                        }
133                    }
134                }
135                let mut info_str = state.to_string();
136                info_str.push_str(&format!(" [{}]", hci));
137                if let Some(hw) = hw_info {
138                    info_str.push_str(&format!(" ({})", hw));
139                }
140
141                if state == "On" {
142                    info_str.push_str(&format!(" - {} connected", connected_names.len()));
143                    if !connected_names.is_empty() {
144                        info_str.push_str(&format!(" ({})", connected_names.join(", ")));
145                    }
146                }
147
148                return Some(info_str);
149            }
150        }
151        None
152    }
153
154    #[cfg(target_os = "macos")]
155    {
156        if let Some((power_on, chipset)) = crate::macos_ffi::get_bluetooth_state() {
157            let state = if power_on { "On" } else { "Off" };
158            let mut info_str = state.to_string();
159            if let Some(ch) = chipset {
160                info_str.push_str(&format!(" (Apple {})", ch));
161            } else {
162                info_str.push_str(" (Apple Bluetooth)");
163            }
164            // Connected device names require Obj-C IOBluetooth; not available via C IOKit.
165            if power_on {
166                info_str.push_str(" - connected devices unknown");
167            }
168            Some(info_str)
169        } else {
170            None
171        }
172    }
173
174    #[cfg(target_os = "windows")]
175    {
176        windows_impl::detect()
177    }
178
179    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
180    {
181        None
182    }
183}
184
185#[cfg(target_os = "linux")]
186fn lookup_usb_vendor(vendor_id: &str) -> Option<String> {
187    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
188    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
189    for path in &paths {
190        if let Ok(content) = std::fs::read_to_string(path) {
191            for line in content.lines() {
192                if line.starts_with('#') || line.is_empty() {
193                    continue;
194                }
195                if !line.starts_with('\t') {
196                    let parts: Vec<&str> = line.split_whitespace().collect();
197                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
198                        let name = line.strip_prefix(parts[0]).unwrap().trim();
199                        return Some(name.to_string());
200                    }
201                }
202            }
203        }
204    }
205    None
206}
207
208#[cfg(target_os = "linux")]
209fn lookup_usb_device(vendor_id: &str, product_id: &str) -> Option<String> {
210    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
211    let product_id = product_id.trim_start_matches("0x").to_lowercase();
212    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
213    for path in &paths {
214        if let Ok(content) = std::fs::read_to_string(path) {
215            let mut in_vendor = false;
216            for line in content.lines() {
217                if line.starts_with('#') || line.is_empty() {
218                    continue;
219                }
220                if !line.starts_with('\t') {
221                    let parts: Vec<&str> = line.split_whitespace().collect();
222                    in_vendor = parts.len() >= 2 && parts[0].to_lowercase() == vendor_id;
223                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
224                    let trimmed = line.trim_start();
225                    if let Some(stripped) = trimmed.strip_prefix(&product_id) {
226                        let name = stripped.trim();
227                        return Some(name.to_string());
228                    }
229                }
230            }
231        }
232    }
233    None
234}
235
236#[allow(dead_code)]
237fn parse_macos_bluetooth(stdout: &str) -> Option<String> {
238    let mut state = "Off";
239    let mut connected_names = Vec::new();
240    let mut chipset = None;
241    let mut current_device = None;
242
243    for line in stdout.lines() {
244        let trimmed = line.trim();
245        if trimmed.starts_with("Bluetooth Power:") || trimmed.starts_with("State:") {
246            if trimmed.contains("On") {
247                state = "On";
248            }
249        } else if trimmed.starts_with("Chipset:") {
250            chipset = Some(trimmed.strip_prefix("Chipset:").unwrap().trim().to_string());
251        } else if line.starts_with("          ") && !trimmed.is_empty() && trimmed.ends_with(':') {
252            current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
253        } else if (trimmed.starts_with("Connected:") || trimmed.starts_with("Connection:"))
254            && trimmed.contains("Yes")
255        {
256            if let Some(ref dev) = current_device {
257                connected_names.push(dev.clone());
258            }
259        }
260    }
261
262    let mut info_str = state.to_string();
263    if let Some(ch) = chipset {
264        info_str.push_str(&format!(" (Apple {})", ch));
265    } else {
266        info_str.push_str(" (Apple Bluetooth)");
267    }
268
269    if state == "On" {
270        info_str.push_str(&format!(" - {} connected", connected_names.len()));
271        if !connected_names.is_empty() {
272            info_str.push_str(&format!(" ({})", connected_names.join(", ")));
273        }
274    }
275    Some(info_str)
276}
277
278/// Formats Bluetooth state into the display string, matching the previous
279/// PowerShell-parsing output: `"On (Adapter) - N connected (name1, name2)"`,
280/// `"Off (Adapter)"`, or `"Off"`. Connected devices are only shown when powered on.
281#[cfg(target_os = "windows")]
282fn format_windows_bluetooth(on: bool, adapter: &str, devices: &[String]) -> String {
283    let mut s = if on { "On" } else { "Off" }.to_string();
284    if !adapter.is_empty() {
285        s.push_str(&format!(" ({})", adapter));
286    }
287    if on {
288        s.push_str(&format!(" - {} connected", devices.len()));
289        if !devices.is_empty() {
290            s.push_str(&format!(" ({})", devices.join(", ")));
291        }
292    }
293    s
294}
295
296/// Native Windows Bluetooth detection.
297///
298/// Replaces the previous PowerShell spawn (`Get-Service bthserv` + two
299/// `Get-PnpDevice -Class Bluetooth` queries, ~1.8 s) with native Win32:
300/// - Power state: the `bthserv` service state via the Service Control Manager
301///   (advapi32) — the same signal the old `Get-Service` check used.
302/// - Adapter name + connected devices: the classic `bthprops` Bluetooth API
303///   (`BluetoothFindFirstRadio`/`BluetoothGetRadioInfo` and
304///   `BluetoothFindFirstDevice` with `fReturnConnected`) — no WinRT.
305///
306/// Hand-written `extern "system"` FFI matching the crate's style (`win_reg.rs`).
307#[cfg(target_os = "windows")]
308mod windows_impl {
309    use super::format_windows_bluetooth;
310    use std::ffi::{c_void, OsStr};
311    use std::mem::size_of;
312    use std::os::windows::ffi::OsStrExt;
313    use std::ptr;
314
315    type Handle = *mut c_void;
316
317    // Service Control Manager (advapi32 — linked by std, like win_reg.rs's Reg* calls).
318    const SC_MANAGER_CONNECT: u32 = 0x0001;
319    const SERVICE_QUERY_STATUS: u32 = 0x0004;
320    const SERVICE_RUNNING: u32 = 4;
321
322    #[repr(C)]
323    struct ServiceStatus {
324        service_type: u32,
325        current_state: u32,
326        controls_accepted: u32,
327        win32_exit_code: u32,
328        service_specific_exit_code: u32,
329        check_point: u32,
330        wait_hint: u32,
331    }
332
333    extern "system" {
334        fn OpenSCManagerW(
335            machine_name: *const u16,
336            database_name: *const u16,
337            desired_access: u32,
338        ) -> Handle;
339        fn OpenServiceW(scm: Handle, service_name: *const u16, desired_access: u32) -> Handle;
340        fn QueryServiceStatus(service: Handle, status: *mut ServiceStatus) -> i32;
341        fn CloseServiceHandle(handle: Handle) -> i32;
342    }
343
344    const BLUETOOTH_MAX_NAME_SIZE: usize = 248;
345
346    #[repr(C)]
347    struct DeviceSearchParams {
348        dw_size: u32,
349        return_authenticated: i32,
350        return_remembered: i32,
351        return_unknown: i32,
352        return_connected: i32,
353        issue_inquiry: i32,
354        timeout_multiplier: u8,
355        h_radio: Handle,
356    }
357
358    #[repr(C)]
359    struct SystemTime {
360        year: u16,
361        month: u16,
362        day_of_week: u16,
363        day: u16,
364        hour: u16,
365        minute: u16,
366        second: u16,
367        milliseconds: u16,
368    }
369
370    #[repr(C)]
371    struct DeviceInfo {
372        dw_size: u32,
373        address: u64,
374        ul_class_of_device: u32,
375        f_connected: i32,
376        f_remembered: i32,
377        f_authenticated: i32,
378        st_last_seen: SystemTime,
379        st_last_used: SystemTime,
380        sz_name: [u16; BLUETOOTH_MAX_NAME_SIZE],
381    }
382
383    #[link(name = "bthprops")]
384    extern "system" {
385        fn BluetoothFindFirstDevice(
386            params: *const DeviceSearchParams,
387            info: *mut DeviceInfo,
388        ) -> Handle;
389        fn BluetoothFindNextDevice(find: Handle, info: *mut DeviceInfo) -> i32;
390        fn BluetoothFindDeviceClose(find: Handle) -> i32;
391    }
392
393    fn wide(s: &str) -> Vec<u16> {
394        OsStr::new(s).encode_wide().chain(Some(0)).collect()
395    }
396
397    /// Converts a null-terminated wide buffer to a `String`, trimmed; `None` if empty.
398    fn wide_to_string(buf: &[u16]) -> Option<String> {
399        let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
400        let s = String::from_utf16_lossy(&buf[..len]);
401        let s = s.trim().to_string();
402        if s.is_empty() {
403            None
404        } else {
405            Some(s)
406        }
407    }
408
409    /// Whether the `bthserv` (Bluetooth Support Service) is running — the power-state
410    /// signal the old `Get-Service -Name bthserv` check used.
411    fn bthserv_running() -> bool {
412        // SAFETY: SCM handles are opened and closed in-scope; QueryServiceStatus writes
413        // into a stack-allocated ServiceStatus.
414        unsafe {
415            let scm = OpenSCManagerW(ptr::null(), ptr::null(), SC_MANAGER_CONNECT);
416            if scm.is_null() {
417                return false;
418            }
419            let name = wide("bthserv");
420            let svc = OpenServiceW(scm, name.as_ptr(), SERVICE_QUERY_STATUS);
421            let mut running = false;
422            if !svc.is_null() {
423                let mut status: ServiceStatus = std::mem::zeroed();
424                if QueryServiceStatus(svc, &mut status) != 0 {
425                    running = status.current_state == SERVICE_RUNNING;
426                }
427                CloseServiceHandle(svc);
428            }
429            CloseServiceHandle(scm);
430            running
431        }
432    }
433
434    /// Whether a device friendly name looks like the Bluetooth adapter/controller itself
435    /// (rather than a paired peripheral). Mirrors the old PowerShell name filter.
436    pub(super) fn looks_like_adapter(name: &str) -> bool {
437        let l = name.to_ascii_lowercase();
438        [
439            "adapter",
440            "controller",
441            "radio",
442            "intel",
443            "realtek",
444            "broadcom",
445        ]
446        .iter()
447        .any(|k| l.contains(k))
448    }
449
450    /// The Bluetooth adapter's hardware friendly name, via the shared SetupAPI helper over
451    /// the Bluetooth device class (what the old `Get-PnpDevice -Class Bluetooth` reported).
452    fn adapter_name() -> Option<String> {
453        crate::win_setupapi::present_device_names(&crate::win_setupapi::GUID_DEVCLASS_BLUETOOTH)
454            .into_iter()
455            .find(|name| looks_like_adapter(name))
456    }
457
458    /// Names of currently-connected Bluetooth devices across all radios.
459    fn connected_devices() -> Vec<String> {
460        let params = DeviceSearchParams {
461            dw_size: size_of::<DeviceSearchParams>() as u32,
462            return_authenticated: 0,
463            return_remembered: 0,
464            return_unknown: 0,
465            return_connected: 1,
466            issue_inquiry: 0, // no scan — only already-known/connected devices
467            timeout_multiplier: 0,
468            h_radio: ptr::null_mut(), // all radios
469        };
470        let mut names = Vec::new();
471        // SAFETY: info's dw_size is set before each call; the find handle is closed.
472        unsafe {
473            let mut info: DeviceInfo = std::mem::zeroed();
474            info.dw_size = size_of::<DeviceInfo>() as u32;
475            let find = BluetoothFindFirstDevice(&params, &mut info);
476            if find.is_null() {
477                return names;
478            }
479            loop {
480                if info.f_connected != 0 {
481                    if let Some(n) = wide_to_string(&info.sz_name) {
482                        names.push(n);
483                    }
484                }
485                info.dw_size = size_of::<DeviceInfo>() as u32;
486                if BluetoothFindNextDevice(find, &mut info) == 0 {
487                    break;
488                }
489            }
490            BluetoothFindDeviceClose(find);
491        }
492        names
493    }
494
495    pub fn detect() -> Option<String> {
496        let on = bthserv_running();
497        let adapter = adapter_name().unwrap_or_default();
498        let devices = if on { connected_devices() } else { Vec::new() };
499        Some(format_windows_bluetooth(on, &adapter, &devices))
500    }
501
502    #[cfg(test)]
503    mod layout {
504        use std::mem::{offset_of, size_of};
505
506        // `dw_size` fields must match the OS's sizeof, and the API fills these `#[repr(C)]`
507        // buffers by offset — pin the layout so a reorder/padding change can't slip through.
508        #[test]
509        fn ffi_struct_layout() {
510            assert_eq!(size_of::<super::ServiceStatus>(), 28);
511            assert_eq!(size_of::<super::DeviceSearchParams>(), 40);
512            assert_eq!(size_of::<super::SystemTime>(), 16);
513            assert_eq!(size_of::<super::DeviceInfo>(), 560);
514            assert_eq!(offset_of!(super::DeviceInfo, f_connected), 20);
515            assert_eq!(offset_of!(super::DeviceInfo, sz_name), 64);
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_parse_macos_bluetooth() {
526        let sample = "Bluetooth:\n\n      Bluetooth Power: On\n      Chipset: BCM4350\n      Devices (Connected):\n          Sony WH-1000XM4:\n              Address: AA-BB-CC\n              Connected: Yes\n          Logitech MX Master:\n              Address: DD-EE-FF\n              Connected: Yes\n";
527        assert_eq!(
528            parse_macos_bluetooth(sample),
529            Some(
530                "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
531                    .to_string()
532            )
533        );
534
535        let sample_off = "Bluetooth:\n\n      Bluetooth Power: Off\n";
536        assert_eq!(
537            parse_macos_bluetooth(sample_off),
538            Some("Off (Apple Bluetooth)".to_string())
539        );
540
541        let sample_state_on = "Bluetooth:\n\n      State: On\n      Chipset: BCM_4388\n";
542        assert_eq!(
543            parse_macos_bluetooth(sample_state_on),
544            Some("On (Apple BCM_4388) - 0 connected".to_string())
545        );
546    }
547
548    #[cfg(target_os = "windows")]
549    #[test]
550    fn test_format_windows_bluetooth() {
551        // On, adapter, two connected devices.
552        assert_eq!(
553            format_windows_bluetooth(
554                true,
555                "Intel(R) Wireless Bluetooth(R)",
556                &["Sony WH-1000XM4".to_string(), "Logitech MX Master".to_string()],
557            ),
558            "On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
559        );
560
561        // On, adapter, nothing connected.
562        assert_eq!(
563            format_windows_bluetooth(true, "MediaTek Bluetooth Adapter", &[]),
564            "On (MediaTek Bluetooth Adapter) - 0 connected"
565        );
566
567        // Off with a known adapter — no connected suffix when powered off.
568        assert_eq!(
569            format_windows_bluetooth(false, "MediaTek Bluetooth Adapter", &[]),
570            "Off (MediaTek Bluetooth Adapter)"
571        );
572
573        // Off, no adapter detected.
574        assert_eq!(format_windows_bluetooth(false, "", &[]), "Off");
575
576        // On, no adapter name resolved.
577        assert_eq!(
578            format_windows_bluetooth(true, "", &["Pixel Buds".to_string()]),
579            "On - 1 connected (Pixel Buds)"
580        );
581    }
582
583    #[cfg(target_os = "windows")]
584    #[test]
585    fn test_looks_like_adapter() {
586        use super::windows_impl::looks_like_adapter;
587        assert!(looks_like_adapter("MediaTek Bluetooth Adapter"));
588        assert!(looks_like_adapter("Intel(R) Wireless Bluetooth(R)"));
589        assert!(looks_like_adapter("Realtek Bluetooth Controller"));
590        assert!(!looks_like_adapter("Ken's Pixel Buds Pro 2"));
591        assert!(!looks_like_adapter("MX Anywhere 3S"));
592    }
593}