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        let cmd = "$state = (Get-Service -Name bthserv -ErrorAction SilentlyContinue).Status; \
177                   $adapter = (Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -match 'Adapter|Controller|Radio|Intel|Realtek|Broadcom'} | Select-Object -First 1 -ExpandProperty FriendlyName); \
178                   $devices = (Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -notmatch 'Adapter|Enumerator|Controller|LE Device|RFCOMM|Module|Service|Generic|Computer|Protocol|Phone|Device'} | Select-Object -ExpandProperty FriendlyName); \
179                   Write-Output \"$state|$adapter|($($devices -join ','))\"";
180
181        if let Ok(output) = std::process::Command::new("powershell")
182            .args(["-Command", cmd])
183            .output()
184        {
185            if let Ok(stdout) = String::from_utf8(output.stdout) {
186                return parse_windows_bluetooth_output(&stdout);
187            }
188        }
189        None
190    }
191
192    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
193    {
194        None
195    }
196}
197
198#[cfg(target_os = "linux")]
199fn lookup_usb_vendor(vendor_id: &str) -> Option<String> {
200    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
201    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
202    for path in &paths {
203        if let Ok(content) = std::fs::read_to_string(path) {
204            for line in content.lines() {
205                if line.starts_with('#') || line.is_empty() {
206                    continue;
207                }
208                if !line.starts_with('\t') {
209                    let parts: Vec<&str> = line.split_whitespace().collect();
210                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
211                        let name = line.strip_prefix(parts[0]).unwrap().trim();
212                        return Some(name.to_string());
213                    }
214                }
215            }
216        }
217    }
218    None
219}
220
221#[cfg(target_os = "linux")]
222fn lookup_usb_device(vendor_id: &str, product_id: &str) -> Option<String> {
223    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
224    let product_id = product_id.trim_start_matches("0x").to_lowercase();
225    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
226    for path in &paths {
227        if let Ok(content) = std::fs::read_to_string(path) {
228            let mut in_vendor = false;
229            for line in content.lines() {
230                if line.starts_with('#') || line.is_empty() {
231                    continue;
232                }
233                if !line.starts_with('\t') {
234                    let parts: Vec<&str> = line.split_whitespace().collect();
235                    in_vendor = parts.len() >= 2 && parts[0].to_lowercase() == vendor_id;
236                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
237                    let trimmed = line.trim_start();
238                    if let Some(stripped) = trimmed.strip_prefix(&product_id) {
239                        let name = stripped.trim();
240                        return Some(name.to_string());
241                    }
242                }
243            }
244        }
245    }
246    None
247}
248
249#[allow(dead_code)]
250fn parse_macos_bluetooth(stdout: &str) -> Option<String> {
251    let mut state = "Off";
252    let mut connected_names = Vec::new();
253    let mut chipset = None;
254    let mut current_device = None;
255
256    for line in stdout.lines() {
257        let trimmed = line.trim();
258        if trimmed.starts_with("Bluetooth Power:") || trimmed.starts_with("State:") {
259            if trimmed.contains("On") {
260                state = "On";
261            }
262        } else if trimmed.starts_with("Chipset:") {
263            chipset = Some(trimmed.strip_prefix("Chipset:").unwrap().trim().to_string());
264        } else if line.starts_with("          ") && !trimmed.is_empty() && trimmed.ends_with(':') {
265            current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
266        } else if (trimmed.starts_with("Connected:") || trimmed.starts_with("Connection:"))
267            && trimmed.contains("Yes")
268        {
269            if let Some(ref dev) = current_device {
270                connected_names.push(dev.clone());
271            }
272        }
273    }
274
275    let mut info_str = state.to_string();
276    if let Some(ch) = chipset {
277        info_str.push_str(&format!(" (Apple {})", ch));
278    } else {
279        info_str.push_str(" (Apple Bluetooth)");
280    }
281
282    if state == "On" {
283        info_str.push_str(&format!(" - {} connected", connected_names.len()));
284        if !connected_names.is_empty() {
285            info_str.push_str(&format!(" ({})", connected_names.join(", ")));
286        }
287    }
288    Some(info_str)
289}
290
291#[allow(dead_code)]
292fn parse_windows_bluetooth_output(stdout: &str) -> Option<String> {
293    let parts: Vec<&str> = stdout.trim().split('|').collect();
294    if parts.len() < 3 {
295        return None;
296    }
297    let status_str = parts[0].trim();
298    let adapter_str = parts[1].trim();
299    let devices_str = parts[2]
300        .trim()
301        .trim_start_matches('(')
302        .trim_end_matches(')');
303
304    let state = if status_str.eq_ignore_ascii_case("running") {
305        "On"
306    } else {
307        "Off"
308    };
309
310    let mut info_str = state.to_string();
311    if !adapter_str.is_empty() {
312        info_str.push_str(&format!(" ({})", adapter_str));
313    }
314
315    if state == "On" {
316        let connected_names: Vec<String> = if devices_str.is_empty() {
317            Vec::new()
318        } else {
319            devices_str
320                .split(',')
321                .map(|s| s.trim().to_string())
322                .filter(|s| !s.is_empty())
323                .collect()
324        };
325
326        info_str.push_str(&format!(" - {} connected", connected_names.len()));
327        if !connected_names.is_empty() {
328            info_str.push_str(&format!(" ({})", connected_names.join(", ")));
329        }
330    }
331    Some(info_str)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_parse_macos_bluetooth() {
340        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";
341        assert_eq!(
342            parse_macos_bluetooth(sample),
343            Some(
344                "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
345                    .to_string()
346            )
347        );
348
349        let sample_off = "Bluetooth:\n\n      Bluetooth Power: Off\n";
350        assert_eq!(
351            parse_macos_bluetooth(sample_off),
352            Some("Off (Apple Bluetooth)".to_string())
353        );
354
355        let sample_state_on = "Bluetooth:\n\n      State: On\n      Chipset: BCM_4388\n";
356        assert_eq!(
357            parse_macos_bluetooth(sample_state_on),
358            Some("On (Apple BCM_4388) - 0 connected".to_string())
359        );
360    }
361
362    #[test]
363    fn test_parse_windows_bluetooth_output() {
364        let sample =
365            "Running | Intel(R) Wireless Bluetooth(R) | (Sony WH-1000XM4,Logitech MX Master)\n";
366        assert_eq!(
367            parse_windows_bluetooth_output(sample),
368            Some("On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)".to_string())
369        );
370
371        let sample_off = "Stopped | | ()\n";
372        assert_eq!(
373            parse_windows_bluetooth_output(sample_off),
374            Some("Off".to_string())
375        );
376    }
377}