retch_sysinfo/
bluetooth.rs1pub 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 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}