1pub 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 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#[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#[cfg(target_os = "windows")]
318mod windows_impl {
319 use super::format_windows_bluetooth;
320 use std::ffi::{c_void, OsStr};
321 use std::os::windows::ffi::OsStrExt;
322 use std::ptr;
323
324 type Handle = *mut c_void;
325
326 const SC_MANAGER_CONNECT: u32 = 0x0001;
328 const SERVICE_QUERY_STATUS: u32 = 0x0004;
329 const SERVICE_RUNNING: u32 = 4;
330
331 #[repr(C)]
332 struct ServiceStatus {
333 service_type: u32,
334 current_state: u32,
335 controls_accepted: u32,
336 win32_exit_code: u32,
337 service_specific_exit_code: u32,
338 check_point: u32,
339 wait_hint: u32,
340 }
341
342 extern "system" {
343 fn OpenSCManagerW(
344 machine_name: *const u16,
345 database_name: *const u16,
346 desired_access: u32,
347 ) -> Handle;
348 fn OpenServiceW(scm: Handle, service_name: *const u16, desired_access: u32) -> Handle;
349 fn QueryServiceStatus(service: Handle, status: *mut ServiceStatus) -> i32;
350 fn CloseServiceHandle(handle: Handle) -> i32;
351 }
352
353 fn wide(s: &str) -> Vec<u16> {
354 OsStr::new(s).encode_wide().chain(Some(0)).collect()
355 }
356
357 fn bthserv_running() -> bool {
360 unsafe {
363 let scm = OpenSCManagerW(ptr::null(), ptr::null(), SC_MANAGER_CONNECT);
364 if scm.is_null() {
365 return false;
366 }
367 let name = wide("bthserv");
368 let svc = OpenServiceW(scm, name.as_ptr(), SERVICE_QUERY_STATUS);
369 let mut running = false;
370 if !svc.is_null() {
371 let mut status: ServiceStatus = std::mem::zeroed();
372 if QueryServiceStatus(svc, &mut status) != 0 {
373 running = status.current_state == SERVICE_RUNNING;
374 }
375 CloseServiceHandle(svc);
376 }
377 CloseServiceHandle(scm);
378 running
379 }
380 }
381
382 pub(super) fn looks_like_adapter(name: &str) -> bool {
385 let l = name.to_ascii_lowercase();
386 [
387 "adapter",
388 "controller",
389 "radio",
390 "intel",
391 "realtek",
392 "broadcom",
393 ]
394 .iter()
395 .any(|k| l.contains(k))
396 }
397
398 fn adapter_name() -> Option<String> {
401 crate::win_setupapi::present_device_names(&crate::win_setupapi::GUID_DEVCLASS_BLUETOOTH)
402 .into_iter()
403 .find(|name| looks_like_adapter(name))
404 }
405
406 pub(super) fn is_remote_device_id(instance_id: &str) -> bool {
414 let id = instance_id.to_ascii_uppercase();
415 id.starts_with("BTHENUM\\DEV_") || id.starts_with("BTHLE\\DEV_")
416 }
417
418 pub(super) fn address_from_instance_id(instance_id: &str) -> Option<String> {
427 let id = instance_id.to_ascii_uppercase();
428 let rest = id.split_once("\\DEV_")?.1;
429 let addr = rest.split('\\').next()?;
430 if addr.is_empty() || !addr.chars().all(|c| c.is_ascii_hexdigit()) {
431 return None;
432 }
433 Some(addr.to_string())
434 }
435
436 fn connected_devices() -> Vec<String> {
438 let mut seen = std::collections::HashSet::new();
439 let mut names = Vec::new();
440 for device in
441 crate::win_setupapi::present_devices(&crate::win_setupapi::GUID_DEVCLASS_BLUETOOTH)
442 {
443 if !is_remote_device_id(&device.instance_id) {
444 continue;
445 }
446 if device.connected != Some(true) {
449 continue;
450 }
451 if let Some(addr) = address_from_instance_id(&device.instance_id) {
452 if !seen.insert(addr) {
453 continue;
454 }
455 }
456 if let Some(name) = device.name {
457 names.push(name);
458 }
459 }
460 names
461 }
462
463 pub fn detect() -> Option<String> {
464 let on = bthserv_running();
465 let adapter = adapter_name().unwrap_or_default();
466 let devices = if on { connected_devices() } else { Vec::new() };
467 Some(format_windows_bluetooth(on, &adapter, &devices))
468 }
469
470 #[cfg(test)]
471 mod layout {
472 use std::mem::size_of;
473
474 #[test]
477 fn ffi_struct_layout() {
478 assert_eq!(size_of::<super::ServiceStatus>(), 28);
479 }
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn test_parse_macos_bluetooth() {
489 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";
490 assert_eq!(
491 parse_macos_bluetooth(sample),
492 Some(
493 "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
494 .to_string()
495 )
496 );
497
498 let sample_off = "Bluetooth:\n\n Bluetooth Power: Off\n";
499 assert_eq!(
500 parse_macos_bluetooth(sample_off),
501 Some("Off (Apple Bluetooth)".to_string())
502 );
503
504 let sample_state_on = "Bluetooth:\n\n State: On\n Chipset: BCM_4388\n";
505 assert_eq!(
506 parse_macos_bluetooth(sample_state_on),
507 Some("On (Apple BCM_4388) - 0 connected".to_string())
508 );
509 }
510
511 #[cfg(target_os = "windows")]
512 #[test]
513 fn test_format_windows_bluetooth() {
514 assert_eq!(
516 format_windows_bluetooth(
517 true,
518 "Intel(R) Wireless Bluetooth(R)",
519 &["Sony WH-1000XM4".to_string(), "Logitech MX Master".to_string()],
520 ),
521 "On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
522 );
523
524 assert_eq!(
526 format_windows_bluetooth(true, "MediaTek Bluetooth Adapter", &[]),
527 "On (MediaTek Bluetooth Adapter) - 0 connected"
528 );
529
530 assert_eq!(
532 format_windows_bluetooth(false, "MediaTek Bluetooth Adapter", &[]),
533 "Off (MediaTek Bluetooth Adapter)"
534 );
535
536 assert_eq!(format_windows_bluetooth(false, "", &[]), "Off");
538
539 assert_eq!(
541 format_windows_bluetooth(true, "", &["Pixel Buds".to_string()]),
542 "On - 1 connected (Pixel Buds)"
543 );
544 }
545
546 #[cfg(target_os = "windows")]
547 #[test]
548 fn test_looks_like_adapter() {
549 use super::windows_impl::looks_like_adapter;
550 assert!(looks_like_adapter("MediaTek Bluetooth Adapter"));
551 assert!(looks_like_adapter("Intel(R) Wireless Bluetooth(R)"));
552 assert!(looks_like_adapter("Realtek Bluetooth Controller"));
553 assert!(!looks_like_adapter("Ken's Pixel Buds Pro 2"));
554 assert!(!looks_like_adapter("MX Anywhere 3S"));
555 }
556
557 #[test]
560 #[cfg(target_os = "windows")]
561 fn test_is_remote_device_id() {
562 use super::windows_impl::is_remote_device_id;
563 assert!(is_remote_device_id(
565 r"BTHENUM\DEV_7CE9138B5564\9&8E04A68&0&BLUETOOTHDEVICE_7CE9138B5564"
566 ));
567 assert!(is_remote_device_id(
568 r"BTHLE\DEV_F5183CA50C6B\9&1C053637&0&F5183CA50C6B"
569 ));
570 assert!(!is_remote_device_id(
573 r"BTHENUM\{0000110E-0000-1000-8000-00805F9B34FB}_VID&000100E0_PID&4115\9&8E04A68&0&7CE9138B5564_C00000000"
574 ));
575 assert!(!is_remote_device_id(
576 r"BTHLEDEVICE\{0000180F-0000-1000-8000-00805F9B34FB}_DEV_VID&02046D_PID&B037_REV&0003_D0940BA106B6\A&38AF489E&0&001B"
577 ));
578 assert!(!is_remote_device_id(
580 r"USB\VID_13D3&PID_3602&MI_00\7&2434504C&0&0000"
581 ));
582 assert!(!is_remote_device_id(r"BTH\MS_BTHLE\8&29FC6E36&0&3"));
583 }
584
585 #[test]
586 #[cfg(target_os = "windows")]
587 fn test_address_from_instance_id() {
588 use super::windows_impl::address_from_instance_id;
589 assert_eq!(
592 address_from_instance_id(
593 r"BTHENUM\DEV_B0D5FBBB66EA\9&8E04A68&0&BLUETOOTHDEVICE_B0D5FBBB66EA"
594 ),
595 Some("B0D5FBBB66EA".to_string())
596 );
597 assert_eq!(
598 address_from_instance_id(r"BTHLE\DEV_B0D5FBBB66EA\9&1C053637&0&B0D5FBBB66EA"),
599 Some("B0D5FBBB66EA".to_string())
600 );
601 assert_eq!(
603 address_from_instance_id(r"BTHLE\Dev_f5183ca50c6b\9&1c053637&0&f5183ca50c6b"),
604 Some("F5183CA50C6B".to_string())
605 );
606 assert_eq!(
609 address_from_instance_id(
610 r"BTHLEDEVICE\{0000180F-0000-1000-8000-00805F9B34FB}_DEV_VID&02046D_PID&B037_REV&0003_D0940BA106B6\A&38AF489E&0&001B"
611 ),
612 None
613 );
614 assert_eq!(
615 address_from_instance_id(r"BTH\MS_BTHLE\8&29FC6E36&0&3"),
616 None
617 );
618 assert_eq!(
622 address_from_instance_id(r"BTHENUM\DEV_NOTANADDRESS\9&1"),
623 None
624 );
625 }
626}