1#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26#[repr(u8)]
27pub enum LinkClass {
28 #[default]
30 Unknown = 0,
31 Loopback = 1,
33 Wired = 2,
35 Wifi = 3,
37 Cellular = 4,
39}
40
41impl LinkClass {
42 pub fn as_u8(self) -> u8 {
44 self as u8
45 }
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq)]
51pub struct LinkSnapshot {
52 pub signal_quality: Option<u8>,
54 pub drop_rate: Option<f32>,
57 pub mcs_norm: Option<f32>,
62 pub retry_rate: Option<f32>,
67 pub phy_rate_kbps: Option<u32>,
74 pub class: LinkClass,
76}
77
78impl LinkSnapshot {
79 pub fn link_stress(&self) -> f32 {
84 let from_signal = self
85 .signal_quality
86 .map(|q| (1.0 - q as f32 / 100.0).clamp(0.0, 1.0))
87 .unwrap_or(0.0);
88 let from_drops = self.drop_rate.unwrap_or(0.0).clamp(0.0, 1.0);
89 let from_mcs = self
90 .mcs_norm
91 .map(|m| (1.0 - m).clamp(0.0, 1.0))
92 .unwrap_or(0.0);
93 let from_retry = self.retry_rate.unwrap_or(0.0).clamp(0.0, 1.0);
94 from_signal.max(from_drops).max(from_mcs).max(from_retry)
95 }
96}
97
98pub trait LinkSensor {
101 fn sample(&mut self) -> LinkSnapshot;
103 fn backend(&self) -> &'static str;
105}
106
107pub fn platform_sensor(iface: Option<String>) -> Box<dyn LinkSensor + Send> {
111 #[cfg(target_os = "linux")]
112 {
113 Box::new(linux::SysfsSensor::new(iface))
114 }
115 #[cfg(target_os = "windows")]
116 {
117 drop(iface);
118 Box::new(windows_net::WindowsSensor::new())
119 }
120 #[cfg(not(any(target_os = "linux", target_os = "windows")))]
121 {
122 drop(iface);
123 Box::new(StubSensor)
124 }
125}
126
127pub struct StubSensor;
130
131impl LinkSensor for StubSensor {
132 fn sample(&mut self) -> LinkSnapshot {
133 LinkSnapshot::default()
134 }
135 fn backend(&self) -> &'static str {
136 "stub"
137 }
138}
139
140#[cfg(target_os = "linux")]
141mod linux {
142 use super::{LinkClass, LinkSensor, LinkSnapshot};
143 use std::fs;
144 use std::path::PathBuf;
145
146 pub struct SysfsSensor {
149 iface: Option<String>,
150 prev: Option<(u64, u64)>, }
152
153 impl SysfsSensor {
154 pub fn new(iface: Option<String>) -> Self {
155 let iface = iface.or_else(detect_iface);
156 Self { iface, prev: None }
157 }
158
159 fn read_counter(&self, name: &str) -> Option<u64> {
160 let iface = self.iface.as_ref()?;
161 let mut p = PathBuf::from("/sys/class/net");
162 p.push(iface);
163 p.push("statistics");
164 p.push(name);
165 fs::read_to_string(p).ok()?.trim().parse().ok()
166 }
167 }
168
169 fn detect_iface() -> Option<String> {
171 let entries = fs::read_dir("/sys/class/net").ok()?;
172 for e in entries.flatten() {
173 let name = e.file_name().to_string_lossy().into_owned();
174 if name == "lo" {
175 continue;
176 }
177 let state = fs::read_to_string(e.path().join("operstate"))
178 .ok()
179 .map(|s| s.trim().to_string())
180 .unwrap_or_default();
181 if state == "up" {
182 return Some(name);
183 }
184 }
185 None
186 }
187
188 fn iface_class(iface: &str) -> LinkClass {
193 let mut p = PathBuf::from("/sys/class/net");
194 p.push(iface);
195 p.push("wireless");
196 if p.exists() {
197 LinkClass::Wifi
198 } else {
199 LinkClass::Wired
200 }
201 }
202
203 impl LinkSensor for SysfsSensor {
204 fn sample(&mut self) -> LinkSnapshot {
205 let dropped = self.read_counter("tx_dropped").unwrap_or(0)
206 + self.read_counter("rx_dropped").unwrap_or(0)
207 + self.read_counter("tx_errors").unwrap_or(0)
208 + self.read_counter("rx_errors").unwrap_or(0);
209 let packets = self.read_counter("tx_packets").unwrap_or(0)
210 + self.read_counter("rx_packets").unwrap_or(0);
211 let drop_rate = self.prev.map(|(pd, pp)| {
212 let dd = dropped.saturating_sub(pd) as f32;
213 let dp = packets.saturating_sub(pp).max(1) as f32;
214 (dd / dp).clamp(0.0, 1.0)
215 });
216 self.prev = Some((dropped, packets));
217 let class = self
218 .iface
219 .as_deref()
220 .map(iface_class)
221 .unwrap_or(LinkClass::Unknown);
222 let radio = if class == LinkClass::Wifi {
226 self.iface.as_deref().and_then(nl80211::station_stats)
227 } else {
228 None
229 };
230 let (signal_quality, mcs_norm, retry_rate, phy_rate_kbps) = match radio {
231 Some(r) => (r.signal_quality, r.mcs_norm, r.retry_rate, r.phy_rate_kbps),
232 None => (None, None, None, None),
233 };
234 LinkSnapshot {
235 signal_quality,
236 drop_rate,
237 mcs_norm,
238 retry_rate,
239 phy_rate_kbps,
240 class,
241 }
242 }
243 fn backend(&self) -> &'static str {
244 "linux-sysfs+nl80211"
245 }
246 }
247
248 pub mod nl80211 {
255 use std::ffi::CString;
256 use std::mem::size_of;
257
258 pub struct RadioStats {
260 pub signal_quality: Option<u8>,
261 pub mcs_norm: Option<f32>,
262 pub retry_rate: Option<f32>,
263 pub phy_rate_kbps: Option<u32>,
264 }
265
266 const GENL_ID_CTRL: u16 = 0x10;
270 const CTRL_CMD_GETFAMILY: u8 = 3;
271 const CTRL_ATTR_FAMILY_ID: u16 = 1;
272 const CTRL_ATTR_FAMILY_NAME: u16 = 2;
273 const NL80211_CMD_GET_STATION: u8 = 17;
274 const NL80211_ATTR_IFINDEX: u16 = 3;
275 const NL80211_ATTR_STA_INFO: u16 = 21;
276 const STA_INFO_SIGNAL: u16 = 7;
277 const STA_INFO_TX_BITRATE: u16 = 8;
278 const STA_INFO_TX_PACKETS: u16 = 10;
279 const STA_INFO_TX_RETRIES: u16 = 11;
280 const RATE_INFO_BITRATE: u16 = 1; const RATE_INFO_BITRATE32: u16 = 5; const NLMSG_ERROR: u16 = 2;
283 const NLMSG_DONE: u16 = 3;
284 const GENL_HDRLEN: usize = 4; const REF_RATE_MBPS: f32 = 866.0;
289
290 fn nla_align(len: usize) -> usize {
292 (len + 3) & !3
293 }
294
295 fn read_attr(buf: &[u8], pos: usize) -> Option<(u16, &[u8], usize)> {
297 if pos + 4 > buf.len() {
298 return None;
299 }
300 let nla_len = u16::from_ne_bytes([buf[pos], buf[pos + 1]]) as usize;
301 let nla_type = u16::from_ne_bytes([buf[pos + 2], buf[pos + 3]]);
302 if nla_len < 4 || pos + nla_len > buf.len() {
303 return None;
304 }
305 let payload = &buf[pos + 4..pos + nla_len];
306 Some((nla_type, payload, pos + nla_align(nla_len)))
307 }
308
309 fn parse_sta_info(buf: &[u8]) -> RadioStats {
312 let (mut signal, mut tx_packets, mut tx_retries, mut rate_100kbps) =
313 (None, None, None, None);
314 let mut pos = 0;
315 while let Some((ty, val, next)) = read_attr(buf, pos) {
316 match ty {
317 STA_INFO_SIGNAL => {
318 if let Some(&b) = val.first() {
320 let dbm = b as i8 as f32;
321 signal = Some((((dbm + 100.0) * 2.0).clamp(0.0, 100.0)) as u8);
322 }
323 }
324 STA_INFO_TX_PACKETS if val.len() >= 4 => {
325 tx_packets = Some(u32::from_ne_bytes([val[0], val[1], val[2], val[3]]));
326 }
327 STA_INFO_TX_RETRIES if val.len() >= 4 => {
328 tx_retries = Some(u32::from_ne_bytes([val[0], val[1], val[2], val[3]]));
329 }
330 STA_INFO_TX_BITRATE => {
331 let mut rp = 0;
332 while let Some((rty, rval, rnext)) = read_attr(val, rp) {
333 if rty == RATE_INFO_BITRATE32 && rval.len() >= 4 {
334 rate_100kbps =
335 Some(u32::from_ne_bytes([rval[0], rval[1], rval[2], rval[3]]));
336 } else if rty == RATE_INFO_BITRATE && rval.len() >= 2 && rate_100kbps.is_none()
337 {
338 rate_100kbps = Some(u16::from_ne_bytes([rval[0], rval[1]]) as u32);
339 }
340 rp = rnext;
341 }
342 }
343 _ => {}
344 }
345 pos = next;
346 }
347 let retry_rate = match (tx_retries, tx_packets) {
348 (Some(r), Some(p)) if p > 0 => Some((r as f32 / p as f32).clamp(0.0, 1.0)),
349 _ => None,
350 };
351 let mcs_norm = rate_100kbps
352 .map(|r| ((r as f32 / 10.0) / REF_RATE_MBPS).clamp(0.0, 1.0));
353 let phy_rate_kbps = rate_100kbps.map(|r| r.saturating_mul(100));
355 RadioStats {
356 signal_quality: signal,
357 mcs_norm,
358 retry_rate,
359 phy_rate_kbps,
360 }
361 }
362
363 pub fn station_stats(iface: &str) -> Option<RadioStats> {
368 unsafe {
372 let cname = CString::new(iface).ok()?;
373 let ifindex = libc::if_nametoindex(cname.as_ptr());
374 if ifindex == 0 {
375 return None;
376 }
377 let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, libc::NETLINK_GENERIC);
378 if fd < 0 {
379 return None;
380 }
381 let mut addr: libc::sockaddr_nl = std::mem::zeroed();
382 addr.nl_family = libc::AF_NETLINK as u16;
383 if libc::bind(
384 fd,
385 &addr as *const _ as *const libc::sockaddr,
386 size_of::<libc::sockaddr_nl>() as libc::socklen_t,
387 ) < 0
388 {
389 libc::close(fd);
390 return None;
391 }
392 let family = resolve_family(fd);
393 let stats = family.and_then(|fam| dump_first_station(fd, fam, ifindex));
394 libc::close(fd);
395 stats
396 }
397 }
398
399 unsafe fn send_request(fd: i32, family: u16, flags: u16, cmd: u8, attrs: &[u8]) -> Option<()> {
406 let total = 16 + GENL_HDRLEN + attrs.len();
407 let mut msg = vec![0u8; total];
408 msg[0..4].copy_from_slice(&(total as u32).to_ne_bytes());
409 msg[4..6].copy_from_slice(&family.to_ne_bytes());
410 msg[6..8].copy_from_slice(&flags.to_ne_bytes());
411 msg[16] = cmd; msg[17] = 0; msg[20..].copy_from_slice(attrs);
415 let n = unsafe { libc::send(fd, msg.as_ptr() as *const libc::c_void, total, 0) };
417 (n as usize == total).then_some(())
418 }
419
420 fn put_attr(out: &mut Vec<u8>, ty: u16, payload: &[u8]) {
422 let len = 4 + payload.len();
423 out.extend_from_slice(&(len as u16).to_ne_bytes());
424 out.extend_from_slice(&ty.to_ne_bytes());
425 out.extend_from_slice(payload);
426 out.resize(nla_align(out.len()), 0);
427 }
428
429 unsafe fn resolve_family(fd: i32) -> Option<u16> {
434 const NLM_F_REQUEST: u16 = 1;
435 let mut attrs = Vec::new();
436 let name = b"nl80211\0";
437 put_attr(&mut attrs, CTRL_ATTR_FAMILY_NAME, name);
438 unsafe { send_request(fd, GENL_ID_CTRL, NLM_F_REQUEST, CTRL_CMD_GETFAMILY, &attrs)? };
440 let mut buf = vec![0u8; 8192];
441 let n = unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
443 if n <= 0 {
444 return None;
445 }
446 let buf = &buf[..n as usize];
447 let mut pos = 16 + GENL_HDRLEN;
449 while let Some((ty, val, next)) = read_attr(buf, pos) {
450 if ty == CTRL_ATTR_FAMILY_ID && val.len() >= 2 {
451 return Some(u16::from_ne_bytes([val[0], val[1]]));
452 }
453 pos = next;
454 }
455 None
456 }
457
458 unsafe fn dump_first_station(fd: i32, family: u16, ifindex: u32) -> Option<RadioStats> {
464 const NLM_F_REQUEST: u16 = 1;
465 const NLM_F_DUMP: u16 = 0x300;
466 let mut attrs = Vec::new();
467 put_attr(&mut attrs, NL80211_ATTR_IFINDEX, &ifindex.to_ne_bytes());
468 unsafe {
470 send_request(
471 fd,
472 family,
473 NLM_F_REQUEST | NLM_F_DUMP,
474 NL80211_CMD_GET_STATION,
475 &attrs,
476 )?
477 };
478 let mut buf = vec![0u8; 16384];
479 let n = unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
481 if n <= 0 {
482 return None;
483 }
484 let buf = &buf[..n as usize];
485 let mut off = 0;
488 while off + 16 <= buf.len() {
489 let len = u32::from_ne_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
490 as usize;
491 let mtype = u16::from_ne_bytes([buf[off + 4], buf[off + 5]]);
492 if len < 16 || off + len > buf.len() {
493 break;
494 }
495 if mtype == NLMSG_DONE || mtype == NLMSG_ERROR {
496 break;
497 }
498 let body = &buf[off + 16 + GENL_HDRLEN..off + len];
499 let mut pos = 0;
500 while let Some((ty, val, next)) = read_attr(body, pos) {
501 if ty == NL80211_ATTR_STA_INFO {
502 return Some(parse_sta_info(val));
503 }
504 pos = next;
505 }
506 off += nla_align(len);
507 }
508 None
509 }
510 }
511}
512
513#[cfg(target_os = "windows")]
514mod windows_net {
515 use super::{LinkClass, LinkSensor, LinkSnapshot};
516 use std::ptr;
517 use windows_sys::Win32::NetworkManagement::IpHelper::{
518 FreeMibTable, GetIfTable2, MIB_IF_TABLE2,
519 };
520 use windows_sys::Win32::NetworkManagement::WiFi::{
521 wlan_intf_opcode_current_connection, WlanCloseHandle, WlanEnumInterfaces, WlanFreeMemory,
522 WlanOpenHandle, WlanQueryInterface, WLAN_CONNECTION_ATTRIBUTES, WLAN_INTERFACE_INFO_LIST,
523 };
524
525 const WLAN_INTERFACE_STATE_CONNECTED: i32 = 1;
528
529 const IF_OPER_STATUS_UP: i32 = 1;
531 const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
533
534 pub struct WindowsSensor {
540 prev: Option<(u64, u64)>, max_tx_kbps: u32,
544 }
545
546 impl WindowsSensor {
547 pub fn new() -> Self {
548 Self {
549 prev: None,
550 max_tx_kbps: 0,
551 }
552 }
553
554 fn query_drop_rate(&mut self) -> Option<f32> {
557 unsafe {
561 let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
562 if GetIfTable2(&mut table) != 0 || table.is_null() {
563 return None;
564 }
565 let n = (*table).NumEntries as usize;
566 let rows = &raw const (*table).Table[0];
567 let (mut best_pkts, mut best_drops, mut found) = (0u64, 0u64, false);
568 for i in 0..n {
569 let row = &*rows.add(i);
570 if row.OperStatus != IF_OPER_STATUS_UP
571 || row.Type == IF_TYPE_SOFTWARE_LOOPBACK
572 {
573 continue;
574 }
575 let pkts = row.InUcastPkts.saturating_add(row.OutUcastPkts);
576 if !found || pkts > best_pkts {
577 best_pkts = pkts;
578 best_drops = row
579 .InDiscards
580 .saturating_add(row.OutDiscards)
581 .saturating_add(row.InErrors)
582 .saturating_add(row.OutErrors);
583 found = true;
584 }
585 }
586 FreeMibTable(table as *const core::ffi::c_void);
587 if !found {
588 return None;
589 }
590 let rate = self.prev.map(|(pd, pp)| {
591 let dd = best_drops.saturating_sub(pd) as f32;
592 let dp = best_pkts.saturating_sub(pp).max(1) as f32;
593 (dd / dp).clamp(0.0, 1.0)
594 });
595 self.prev = Some((best_drops, best_pkts));
596 rate
597 }
598 }
599 }
600
601 impl LinkSensor for WindowsSensor {
602 fn sample(&mut self) -> LinkSnapshot {
603 let drop_rate = self.query_drop_rate();
604 let (signal_quality, mcs_norm, phy_rate_kbps, class) = match query_wlan() {
608 Some((signal, tx_kbps)) => {
609 if tx_kbps > self.max_tx_kbps {
610 self.max_tx_kbps = tx_kbps;
611 }
612 let mcs_norm = (self.max_tx_kbps > 0)
613 .then(|| (tx_kbps as f32 / self.max_tx_kbps as f32).clamp(0.0, 1.0));
614 (Some(signal), mcs_norm, Some(tx_kbps), LinkClass::Wifi)
615 }
616 None => (
617 None,
618 None,
619 None,
620 if drop_rate.is_some() {
621 LinkClass::Wired
622 } else {
623 LinkClass::Unknown
624 },
625 ),
626 };
627 LinkSnapshot {
628 signal_quality,
629 drop_rate,
630 mcs_norm,
631 retry_rate: None,
632 phy_rate_kbps,
633 class,
634 }
635 }
636 fn backend(&self) -> &'static str {
637 "windows-iftable+wlan"
638 }
639 }
640
641 fn query_wlan() -> Option<(u8, u32)> {
646 unsafe {
650 let mut handle = ptr::null_mut();
651 let mut negotiated = 0u32;
652 if WlanOpenHandle(2, ptr::null(), &mut negotiated, &mut handle) != 0 {
654 return None;
655 }
656 let mut result = None;
657 let mut list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut();
658 if WlanEnumInterfaces(handle, ptr::null(), &mut list) == 0
659 && !list.is_null()
660 && (*list).dwNumberOfItems > 0
661 {
662 let guid = (*list).InterfaceInfo[0].InterfaceGuid;
663 let mut size = 0u32;
664 let mut data: *mut core::ffi::c_void = ptr::null_mut();
665 let rc = WlanQueryInterface(
666 handle,
667 &guid,
668 wlan_intf_opcode_current_connection,
669 ptr::null(),
670 &mut size,
671 &mut data,
672 ptr::null_mut(),
673 );
674 if rc == 0 && !data.is_null() {
675 let attrs = data as *const WLAN_CONNECTION_ATTRIBUTES;
676 if (*attrs).isState == WLAN_INTERFACE_STATE_CONNECTED {
678 let assoc = (*attrs).wlanAssociationAttributes;
679 result = Some((assoc.wlanSignalQuality.min(100) as u8, assoc.ulTxRate));
680 }
681 WlanFreeMemory(data);
682 }
683 }
684 if !list.is_null() {
685 WlanFreeMemory(list as *mut core::ffi::c_void);
686 }
687 WlanCloseHandle(handle, ptr::null());
688 result
689 }
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn link_stress_from_low_signal() {
699 let s = LinkSnapshot { signal_quality: Some(20), ..Default::default() };
700 assert!((s.link_stress() - 0.8).abs() < 0.01);
702 }
703
704 #[test]
705 fn link_stress_from_drops() {
706 let s = LinkSnapshot { drop_rate: Some(0.3), ..Default::default() };
707 assert!((s.link_stress() - 0.3).abs() < 0.01);
708 }
709
710 #[test]
711 fn link_stress_takes_the_worse_signal() {
712 let s = LinkSnapshot {
713 signal_quality: Some(90),
714 drop_rate: Some(0.4),
715 ..Default::default()
716 };
717 assert!((s.link_stress() - 0.4).abs() < 0.01);
719 }
720
721 #[test]
722 fn clean_link_has_zero_stress() {
723 let s = LinkSnapshot {
724 signal_quality: Some(100),
725 drop_rate: Some(0.0),
726 mcs_norm: Some(1.0),
727 ..Default::default()
728 };
729 assert_eq!(s.link_stress(), 0.0);
730 }
731
732 #[test]
733 fn link_stress_from_wifi_mac_signals() {
734 let slow = LinkSnapshot { mcs_norm: Some(0.3), ..Default::default() };
736 assert!((slow.link_stress() - 0.7).abs() < 0.01, "mcs 0.3 -> 0.7 stress");
737 let retry = LinkSnapshot { retry_rate: Some(0.4), ..Default::default() };
739 assert!((retry.link_stress() - 0.4).abs() < 0.01, "retry 0.4 -> 0.4 stress");
740 let good = LinkSnapshot {
742 signal_quality: Some(95),
743 mcs_norm: Some(1.0),
744 retry_rate: Some(0.0),
745 class: LinkClass::Wifi,
746 ..Default::default()
747 };
748 assert!(good.link_stress() < 0.06, "good wifi low stress: {}", good.link_stress());
749 }
750
751 #[test]
752 fn platform_sensor_samples_without_panicking() {
753 let mut s = platform_sensor(None);
755 let snap = s.sample();
756 assert!(!s.backend().is_empty());
757 assert!(snap.link_stress() >= 0.0, "stress is well-defined");
758 }
759}