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")]
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 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 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 fn bthserv_running() -> bool {
412 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 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 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 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, timeout_multiplier: 0,
468 h_radio: ptr::null_mut(), };
470 let mut names = Vec::new();
471 unsafe {
473 let mut info: DeviceInfo = std::mem::zeroed();
474 info.dw_size = size_of::<DeviceInfo>() as u32;
475 let find = BluetoothFindFirstDevice(¶ms, &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 #[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 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 assert_eq!(
563 format_windows_bluetooth(true, "MediaTek Bluetooth Adapter", &[]),
564 "On (MediaTek Bluetooth Adapter) - 0 connected"
565 );
566
567 assert_eq!(
569 format_windows_bluetooth(false, "MediaTek Bluetooth Adapter", &[]),
570 "Off (MediaTek Bluetooth Adapter)"
571 );
572
573 assert_eq!(format_windows_bluetooth(false, "", &[]), "Off");
575
576 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}