Skip to main content

subetha_cxc/
link_sensor.rs

1//! Cross-platform link-quality sensing: the radio / interface stats the
2//! adaptive controller reads to anticipate loss before the in-band loss
3//! estimate sees it.
4//!
5//! Each OS exposes a different best signal, normalized behind one trait:
6//!
7//!  - **Linux**: `/sys/class/net/<iface>/statistics` drop and error
8//!    counters (delta-based drop rate). Works on any interface, wired or
9//!    wireless - the relevant signal on a wired / virtual link where no
10//!    RSSI exists.
11//!  - **Windows**: both `WlanQueryInterface` connection signal quality
12//!    (0..100, the RSSI-equivalent) on a Wi-Fi interface AND the
13//!    `GetIfTable2` discard / error counters on ANY adapter (the Ethernet
14//!    path, and a fallback where there is no Wi-Fi). The worse of the two
15//!    wins, so wired and wireless links are both covered.
16//!  - **macOS / other**: a stub returning "unknown" until a CoreWLAN
17//!    backend lands.
18//!
19//! The controller fuses [`LinkSnapshot::link_stress`] (0..1) with the
20//! loss / burstiness / delay sensors: a degrading link raises protection
21//! pre-emptively.
22
23/// The kind of link the local interface presents. A class change (Wi-Fi to
24/// cellular, a wired uplink dropping to Wi-Fi) is a path event in its own right.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26#[repr(u8)]
27pub enum LinkClass {
28    /// Not yet determined / no usable interface.
29    #[default]
30    Unknown = 0,
31    /// Software loopback.
32    Loopback = 1,
33    /// Wired Ethernet (no radio).
34    Wired = 2,
35    /// Wi-Fi (802.11) - the radio MAC stats below apply.
36    Wifi = 3,
37    /// Cellular (WWAN).
38    Cellular = 4,
39}
40
41impl LinkClass {
42    /// The wire code for the `Link` control frame.
43    pub fn as_u8(self) -> u8 {
44        self as u8
45    }
46}
47
48/// A normalized link-quality reading. Every field is optional because no
49/// single platform / interface exposes them all.
50#[derive(Debug, Clone, Copy, Default, PartialEq)]
51pub struct LinkSnapshot {
52    /// Signal quality, 0..=100 (Wi-Fi). `None` on wired links.
53    pub signal_quality: Option<u8>,
54    /// Fraction of recent packets dropped / errored at the interface,
55    /// 0.0..=1.0. `None` if counters are unavailable.
56    pub drop_rate: Option<f32>,
57    /// Normalized current PHY rate (current TX rate / the best rate seen),
58    /// 0.0..=1.0, Wi-Fi. A falling value is rate-adaptation backing off under
59    /// poor radio conditions - an early loss predictor before frames drop.
60    /// `None` off Wi-Fi.
61    pub mcs_norm: Option<f32>,
62    /// MAC-layer transmit retry rate (`tx_retries / tx_packets`), 0.0..=1.0,
63    /// Wi-Fi. A climbing value is the radio struggling milliseconds before the
64    /// loss reaches shard accounting. `None` where the OS does not expose it
65    /// (Windows WLAN has no retry counter; Linux nl80211 does).
66    pub retry_rate: Option<f32>,
67    /// The raw first-hop PHY rate in kbit/s (Windows `ulTxRate`, Linux nl80211
68    /// `tx_bitrate`). This is `nominal` - the rate a SINGLE Wi-Fi hop can carry -
69    /// which the mesh-hop detector compares against the measured end-to-end
70    /// `BtlBw`: each single-radio backhaul hop roughly halves throughput, so
71    /// `round(log2(nominal / BtlBw))` is the backhaul-hop count. `None` off
72    /// Wi-Fi.
73    pub phy_rate_kbps: Option<u32>,
74    /// The link's class (wired / Wi-Fi / cellular / loopback / unknown).
75    pub class: LinkClass,
76}
77
78impl LinkSnapshot {
79    /// Combined "link stress" in 0..=1: how degraded the link looks right now.
80    /// Low signal quality, a high interface drop rate, a fallen PHY rate, and a
81    /// climbing retry rate all push it up - the worst of the available signals
82    /// wins. Used as a feed-forward term in the fusion controller.
83    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
98/// A pollable link-quality sensor. `sample` is called on the controller's
99/// slow cadence (not per packet).
100pub trait LinkSensor {
101    /// Read the current link snapshot.
102    fn sample(&mut self) -> LinkSnapshot;
103    /// Backend identifier (for diagnostics).
104    fn backend(&self) -> &'static str;
105}
106
107/// Construct the best link sensor for this platform. `iface` names the
108/// interface to watch (Linux); `None` auto-detects the first non-loopback
109/// up interface.
110pub 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
127/// A sensor that knows nothing (macOS until CoreWLAN lands, and any other
128/// target). Always returns an empty snapshot.
129pub 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    /// Reads `/sys/class/net/<iface>/statistics` and derives a drop rate
147    /// from the delta between samples.
148    pub struct SysfsSensor {
149        iface: Option<String>,
150        prev: Option<(u64, u64)>, // (dropped+errors, packets)
151    }
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    /// First non-loopback interface whose `operstate` is `up`.
170    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    /// The interface's class: Wi-Fi if it has a `wireless` sysfs node, else
189    /// wired (loopback is excluded by `detect_iface`). A real radio E2E of the
190    /// nl80211 retry/MCS read needs a Wi-Fi host; on a wired host this stays
191    /// Wired and the drop-rate path carries the link signal.
192    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            // On a Wi-Fi interface, the nl80211 station table carries the MAC
223            // retry rate, PHY rate, and signal; on a wired interface there is no
224            // radio, so those stay `None` and the drop rate is the signal.
225            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    /// Wi-Fi MAC statistics from the nl80211 station table over generic
249    /// netlink. The radio knows it is struggling - retries climbing, the PHY
250    /// rate dropping - before the loss reaches shard accounting. Compile-clean
251    /// everywhere; exercised only on a Wi-Fi host, since a wired interface never
252    /// reaches it (see `iface_class`), so the VMs' Ethernet links short-circuit
253    /// before this runs.
254    pub mod nl80211 {
255        use std::ffi::CString;
256        use std::mem::size_of;
257
258        /// One station's MAC-layer health.
259        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        // Generic-netlink control family + nl80211 command / attribute ids
267        // (linux/netlink.h, linux/genetlink.h, linux/nl80211.h). Stable kernel
268        // ABI numbers, declared locally since libc does not expose nl80211.
269        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; // u16, units of 100 kbps
281        const RATE_INFO_BITRATE32: u16 = 5; // u32, units of 100 kbps
282        const NLMSG_ERROR: u16 = 2;
283        const NLMSG_DONE: u16 = 3;
284        const GENL_HDRLEN: usize = 4; // genlmsghdr: cmd u8, version u8, reserved u16
285        /// A representative high modern Wi-Fi PHY rate (Mbps) to normalize the
286        /// current rate against, so `mcs_norm` is a 0..1 fraction without
287        /// per-host calibration.
288        const REF_RATE_MBPS: f32 = 866.0;
289
290        /// `NLA_ALIGN`: netlink attributes are 4-byte aligned.
291        fn nla_align(len: usize) -> usize {
292            (len + 3) & !3
293        }
294
295        /// Read a `nlattr` header at `buf[pos..]`: `(nla_type, payload, next_pos)`.
296        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        /// Walk the nested `STA_INFO` attributes for the rate / packet / retry /
310        /// signal counters and reduce them to a `RadioStats`.
311        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                        // Signal is i8 dBm; map [-100, -50] dBm to [0, 100].
319                        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            // 100-kbps units -> kbps for the raw first-hop PHY rate (`nominal`).
354            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        /// Resolve the nl80211 generic-netlink family id, dump the station table
364        /// for `iface`, and reduce the first station to its MAC health. Returns
365        /// `None` on any netlink error or if the interface has no associated
366        /// station.
367        pub fn station_stats(iface: &str) -> Option<RadioStats> {
368            // SAFETY: the socket is a valid fd for its lifetime, every buffer
369            // handed to send/recv outlives the call, the sockaddr is zeroed and
370            // sized correctly, and the fd is closed before return.
371            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        /// Build and send a netlink request: a 16-byte `nlmsghdr`, a 4-byte
400        /// genl header (`cmd`/version), then the supplied attributes (already
401        /// `NLA_ALIGN`-padded). Returns the bytes written or `None` on error.
402        ///
403        /// # Safety
404        /// `fd` must be a bound NETLINK_GENERIC socket.
405        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            // nlmsg_seq @ 8, nlmsg_pid @ 12 left 0 (kernel fills pid).
412            msg[16] = cmd; // genlmsghdr.cmd
413            msg[17] = 0; // version
414            msg[20..].copy_from_slice(attrs);
415            // SAFETY: msg is `total` valid bytes; fd is the bound socket.
416            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        /// Encode one `nlattr` (`type`, payload) with `NLA_ALIGN` padding.
421        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        /// CTRL_CMD_GETFAMILY("nl80211") -> the family id.
430        ///
431        /// # Safety
432        /// `fd` must be a bound NETLINK_GENERIC socket.
433        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            // SAFETY: forwarded; fd is the bound socket.
439            unsafe { send_request(fd, GENL_ID_CTRL, NLM_F_REQUEST, CTRL_CMD_GETFAMILY, &attrs)? };
440            let mut buf = vec![0u8; 8192];
441            // SAFETY: buf is 8192 valid bytes; fd is the bound socket.
442            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            // Skip nlmsghdr (16) + genlmsghdr (4); walk the control attributes.
448            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        /// NL80211_CMD_GET_STATION dump for `ifindex` -> the first station's
459        /// MAC health.
460        ///
461        /// # Safety
462        /// `fd` must be a bound NETLINK_GENERIC socket.
463        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            // SAFETY: forwarded; fd is the bound socket.
469            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            // SAFETY: buf is 16384 valid bytes; fd is the bound socket.
480            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            // Walk the concatenated netlink messages; the first station's
486            // STA_INFO is enough for a representative reading.
487            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    /// `WLAN_INTERFACE_STATE` value for a connected interface; only then are the
526    /// association's signal quality and PHY rate meaningful.
527    const WLAN_INTERFACE_STATE_CONNECTED: i32 = 1;
528
529    /// `IF_OPER_STATUS` value for an interface that is up.
530    const IF_OPER_STATUS_UP: i32 = 1;
531    /// `IFTYPE` value for a software loopback interface (skipped).
532    const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
533
534    /// Reads the adapter's real-time health two ways and keeps the worse:
535    /// `WlanQueryInterface` signal quality on Wi-Fi, and the `GetIfTable2`
536    /// discard/error counters on ANY adapter (the Ethernet path, and a
537    /// fallback when there is no Wi-Fi). The drop rate is a delta between
538    /// samples on the busiest up, non-loopback interface.
539    pub struct WindowsSensor {
540        prev: Option<(u64, u64)>, // (discards+errors, packets)
541        /// Best TX PHY rate seen (Kbps); the reference for `mcs_norm`, so a
542        /// later rate below it reads as the radio rate-adapting down.
543        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        /// Delta discard+error rate over the busiest up, non-loopback
555        /// interface (Ethernet or Wi-Fi), via `GetIfTable2`.
556        fn query_drop_rate(&mut self) -> Option<f32> {
557            // SAFETY: GetIfTable2 allocates the table; every row is read
558            // within `NumEntries`, the table is freed exactly once with
559            // FreeMibTable, and no pointer outlives the call.
560            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            // The current Wi-Fi association: signal quality and the TX PHY rate.
605            // A rate below the best seen is rate-adaptation backing off under
606            // poor radio conditions, an early loss predictor.
607            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    /// Open a WLAN handle, find the first interface, and read its current
642    /// connection's signal quality and TX PHY rate (Kbps). Returns `None` if
643    /// there is no Wi-Fi interface or it is not associated. The FFI is
644    /// encapsulated and manages its own handles, so this is a safe wrapper.
645    fn query_wlan() -> Option<(u8, u32)> {
646        // SAFETY: every pointer the WLAN API hands back is checked for
647        // null before use, each allocation is freed exactly once with
648        // WlanFreeMemory, and the handle is closed before return.
649        unsafe {
650            let mut handle = ptr::null_mut();
651            let mut negotiated = 0u32;
652            // Client version 2 (Vista+).
653            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                    // Signal quality and rate are meaningful only when connected.
677                    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        // 20% quality -> 0.8 stress.
701        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        // signal 90 -> 0.1 stress, drops 0.4 -> max is 0.4.
718        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        // A fallen PHY rate (rate-adaptation backing off) raises stress.
735        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        // A climbing retry rate raises stress directly.
738        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        // A full-rate, no-retry Wi-Fi link is unstressed.
741        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        // Whatever backend this platform builds, sampling must be safe.
754        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}