Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 75d4685

Browse files
committed
feat: cross-platform WiFi collector factory with graceful degradation (ADR-049)
- Add create_collector() factory function that auto-detects platform and never raises - Add LinuxWifiCollector.is_available() classmethod for probe-without-exception - Refactor ws_server.py to use create_collector(), removing ~30 lines of duplicated platform detection - Add 10 unit tests covering all platform paths and edge cases - Add ADR-049 documenting the cross-platform detection and fallback chain Docker, WSL, and headless users now get SimulatedCollector automatically with a clear WARNING log instead of a RuntimeError crash. Closes ruvnet#148 Closes ruvnet#155 Co-Authored-By: claude-flow <[email protected]>
1 parent 45c15b7 commit 75d4685

4 files changed

Lines changed: 366 additions & 60 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# ADR-049: Cross-Platform WiFi Interface Detection and Graceful Degradation
2+
3+
| Field | Value |
4+
|-------|-------|
5+
| Status | Proposed |
6+
| Date | 2026-03-06 |
7+
| Deciders | ruv |
8+
| Depends on | ADR-013 (Feature-Level Sensing), ADR-025 (macOS CoreWLAN) |
9+
| Issue | [#148](https://github.com/ruvnet/wifi-densepose/issues/148) |
10+
11+
## Context
12+
13+
Users report `RuntimeError: Cannot read /proc/net/wireless` when running WiFi DensePose in environments where the Linux wireless proc filesystem is unavailable:
14+
15+
- **Docker containers** on macOS/Windows (Linux kernel detected, but no wireless subsystem)
16+
- **WSL2** without USB WiFi passthrough
17+
- **Headless Linux servers** without WiFi hardware
18+
- **Embedded Linux** boards without wireless-extensions support
19+
20+
The current architecture has two layers of defense:
21+
22+
1. **`ws_server.py`** (line 345-355) checks `os.path.exists("/proc/net/wireless")` before instantiating `LinuxWifiCollector` and falls back to `SimulatedCollector` if missing.
23+
2. **`rssi_collector.py`** `LinuxWifiCollector._validate_interface()` (line 178-196) raises a hard `RuntimeError` if `/proc/net/wireless` is missing or the interface isn't listed.
24+
25+
However, there are gaps:
26+
27+
- **Direct usage**: Any code that instantiates `LinuxWifiCollector` directly (outside `ws_server.py`) hits the unguarded `RuntimeError` with no fallback.
28+
- **Error message**: The RuntimeError message tells users to "use SimulatedCollector instead" but doesn't explain how.
29+
- **No auto-detection**: The collector selection logic is duplicated between `ws_server.py` and `install.sh` with no shared platform-detection utility.
30+
- **Partial `/proc/net/wireless`**: The file may exist (e.g., kernel module loaded) but contain no interfaces, producing a confusing "interface not found" error instead of a clean fallback.
31+
32+
## Decision
33+
34+
### 1. Platform-Aware Collector Factory
35+
36+
Introduce a `create_collector()` factory function in `rssi_collector.py` that encapsulates the platform detection and fallback chain:
37+
38+
```python
39+
def create_collector(
40+
preferred: str = "auto",
41+
interface: str = "wlan0",
42+
sample_rate_hz: float = 10.0,
43+
) -> BaseCollector:
44+
"""
45+
Create the best available WiFi collector for the current platform.
46+
47+
Resolution order (when preferred="auto"):
48+
1. ESP32 CSI (if UDP port 5005 is receiving frames)
49+
2. Platform-native WiFi:
50+
- Linux: LinuxWifiCollector (requires /proc/net/wireless + active interface)
51+
- Windows: WindowsWifiCollector (netsh wlan)
52+
- macOS: MacosWifiCollector (CoreWLAN)
53+
3. SimulatedCollector (always available)
54+
55+
Raises nothing — always returns a usable collector.
56+
"""
57+
```
58+
59+
### 2. Soft Validation in LinuxWifiCollector
60+
61+
Replace the hard `RuntimeError` in `_validate_interface()` with a class method that returns availability status without raising:
62+
63+
```python
64+
@classmethod
65+
def is_available(cls, interface: str = "wlan0") -> tuple[bool, str]:
66+
"""Check if Linux WiFi collection is possible. Returns (available, reason)."""
67+
if not os.path.exists("/proc/net/wireless"):
68+
return False, "/proc/net/wireless not found (Docker, WSL, or no wireless subsystem)"
69+
with open("/proc/net/wireless") as f:
70+
content = f.read()
71+
if interface not in content:
72+
names = cls._parse_interface_names(content)
73+
return False, f"Interface '{interface}' not in /proc/net/wireless. Available: {names}"
74+
return True, "ok"
75+
```
76+
77+
The existing `_validate_interface()` continues to raise `RuntimeError` for direct callers who need fail-fast behavior, but `create_collector()` uses `is_available()` to probe without exceptions.
78+
79+
### 3. Structured Fallback Logging
80+
81+
When auto-detection skips a collector, log at `WARNING` level with actionable context:
82+
83+
```
84+
WiFi collector: LinuxWifiCollector unavailable (/proc/net/wireless not found — likely Docker/WSL).
85+
WiFi collector: Falling back to SimulatedCollector. For real sensing, connect ESP32 nodes via UDP:5005.
86+
```
87+
88+
### 4. Consolidate Platform Detection
89+
90+
Remove duplicated platform-detection logic from `ws_server.py` and `install.sh`. Both should use `create_collector()` (Python) or a shared `detect_wifi_platform()` shell function.
91+
92+
## Consequences
93+
94+
### Positive
95+
96+
- **Zero-crash startup**: `create_collector("auto")` never raises — Docker, WSL, and headless users get `SimulatedCollector` automatically with a clear log message.
97+
- **Single detection path**: Platform logic lives in one place (`rssi_collector.py`), reducing drift between `ws_server.py`, `install.sh`, and future entry points.
98+
- **Better DX**: Error messages explain *why* a collector is unavailable and *what to do* (connect ESP32, install WiFi driver, etc.).
99+
100+
### Negative
101+
102+
- **SimulatedCollector may mask hardware issues**: Users with real WiFi hardware that fails detection might unknowingly run on simulated data. Mitigated by the `WARNING`-level log.
103+
- **Breaking change for direct `LinuxWifiCollector` callers**: Code that catches `RuntimeError` from `_validate_interface()` as a signal needs to migrate to `is_available()` or `create_collector()`. This is a minor change — there are no known external consumers.
104+
105+
### Neutral
106+
107+
- `_validate_interface()` behavior is unchanged for existing direct callers — this is additive.
108+
109+
## Implementation Notes
110+
111+
1. Add `create_collector()` and `BaseCollector.is_available()` to `v1/src/sensing/rssi_collector.py`
112+
2. Refactor `ws_server.py` `_init_collector()` to call `create_collector()`
113+
3. Update `install.sh` `detect_wifi_hardware()` to use shared detection logic
114+
4. Add unit tests for each platform path (mock `/proc/net/wireless` presence/absence)
115+
5. Comment on issue #148 with the fix
116+
117+
## References
118+
119+
- Issue #148: RuntimeError: Cannot read /proc/net/wireless
120+
- ADR-013: Feature-Level Sensing on Commodity Gear
121+
- ADR-025: macOS CoreWLAN WiFi Sensing
122+
- [Linux /proc/net/wireless documentation](https://www.kernel.org/doc/html/latest/networking/statistics.html)

v1/src/sensing/rssi_collector.py

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212

1313
import logging
1414
import math
15+
import os
16+
import platform
1517
import re
1618
import subprocess
1719
import threading
1820
import time
1921
from collections import deque
2022
from dataclasses import dataclass, field
21-
from typing import Deque, List, Optional, Protocol
23+
from typing import Deque, List, Optional, Protocol, Union
2224

2325
import numpy as np
2426

@@ -173,27 +175,47 @@ def collect_once(self) -> WifiSample:
173175
"""Collect a single sample right now (blocking)."""
174176
return self._read_sample()
175177

176-
# -- internals -----------------------------------------------------------
178+
# -- availability check --------------------------------------------------
177179

178-
def _validate_interface(self) -> None:
179-
"""Check that the interface exists on this machine."""
180+
@classmethod
181+
def is_available(cls, interface: str = "wlan0") -> tuple[bool, str]:
182+
"""Check if Linux WiFi collection is possible without raising.
183+
184+
Returns
185+
-------
186+
(available, reason) : tuple[bool, str]
187+
``available`` is True when /proc/net/wireless exists and lists
188+
the requested interface. ``reason`` is a human-readable
189+
explanation when unavailable.
190+
"""
191+
if not os.path.exists("/proc/net/wireless"):
192+
return False, (
193+
"/proc/net/wireless not found. "
194+
"This environment has no Linux wireless subsystem "
195+
"(common in Docker, WSL, or headless servers)."
196+
)
180197
try:
181198
with open("/proc/net/wireless", "r") as f:
182199
content = f.read()
183-
if self._interface not in content:
184-
raise RuntimeError(
185-
f"WiFi interface '{self._interface}' not found in "
186-
f"/proc/net/wireless. Available interfaces may include: "
187-
f"{self._parse_interface_names(content)}. "
188-
f"Ensure the interface is up and associated with an AP."
189-
)
190-
except FileNotFoundError:
191-
raise RuntimeError(
192-
"Cannot read /proc/net/wireless. "
193-
"This collector requires a Linux system with wireless-extensions support. "
194-
"If running in a container or VM without WiFi hardware, use "
195-
"SimulatedCollector instead."
200+
except OSError as exc:
201+
return False, f"Cannot read /proc/net/wireless: {exc}"
202+
203+
if interface not in content:
204+
names = cls._parse_interface_names(content)
205+
return False, (
206+
f"Interface '{interface}' not listed in /proc/net/wireless. "
207+
f"Available: {names or '(none)'}. "
208+
f"Ensure the interface is up and associated with an AP."
196209
)
210+
return True, "ok"
211+
212+
# -- internals -----------------------------------------------------------
213+
214+
def _validate_interface(self) -> None:
215+
"""Check that the interface exists on this machine."""
216+
available, reason = self.is_available(self._interface)
217+
if not available:
218+
raise RuntimeError(reason)
197219

198220
@staticmethod
199221
def _parse_interface_names(proc_content: str) -> List[str]:
@@ -736,3 +758,86 @@ def _sample_loop(self) -> None:
736758
if self._running:
737759
logger.error("macOS WiFi utility exited unexpectedly. Collector stopped.")
738760
self._running = False
761+
762+
763+
# ---------------------------------------------------------------------------
764+
# Collector factory (ADR-049)
765+
# ---------------------------------------------------------------------------
766+
767+
CollectorType = Union[LinuxWifiCollector, WindowsWifiCollector, MacosWifiCollector, SimulatedCollector]
768+
769+
770+
def create_collector(
771+
preferred: str = "auto",
772+
interface: str = "wlan0",
773+
sample_rate_hz: float = 10.0,
774+
) -> CollectorType:
775+
"""Create the best available WiFi collector for the current platform.
776+
777+
Resolution order (when ``preferred="auto"``):
778+
1. Platform-native WiFi:
779+
- Linux: LinuxWifiCollector (requires /proc/net/wireless + active interface)
780+
- Windows: WindowsWifiCollector (netsh wlan)
781+
- macOS: MacosWifiCollector (CoreWLAN)
782+
2. SimulatedCollector (always available)
783+
784+
This function never raises -- it always returns a usable collector.
785+
786+
Parameters
787+
----------
788+
preferred : str
789+
``"auto"`` for platform detection, or one of ``"linux"``,
790+
``"windows"``, ``"macos"``, ``"simulated"`` to force a specific
791+
collector.
792+
interface : str
793+
WiFi interface name (Linux/Windows only).
794+
sample_rate_hz : float
795+
Target sampling rate.
796+
"""
797+
_VALID_PREFERRED = {"auto", "linux", "windows", "macos", "simulated"}
798+
if preferred not in _VALID_PREFERRED:
799+
logger.warning(
800+
"WiFi collector: unknown preferred=%r (valid: %s). Falling back to auto.",
801+
preferred, ", ".join(sorted(_VALID_PREFERRED)),
802+
)
803+
preferred = "auto"
804+
805+
system = platform.system()
806+
807+
if preferred == "auto":
808+
if system == "Linux":
809+
available, reason = LinuxWifiCollector.is_available(interface)
810+
if available:
811+
logger.info("WiFi collector: using LinuxWifiCollector on %s", interface)
812+
return LinuxWifiCollector(interface=interface, sample_rate_hz=sample_rate_hz)
813+
logger.warning("WiFi collector: LinuxWifiCollector unavailable (%s).", reason)
814+
elif system == "Windows":
815+
try:
816+
win_iface = interface if interface != "wlan0" else "Wi-Fi"
817+
collector = WindowsWifiCollector(interface=win_iface, sample_rate_hz=min(sample_rate_hz, 2.0))
818+
collector.collect_once()
819+
logger.info("WiFi collector: using WindowsWifiCollector on '%s'", interface)
820+
return collector
821+
except Exception as exc:
822+
logger.warning("WiFi collector: WindowsWifiCollector unavailable (%s).", exc)
823+
elif system == "Darwin":
824+
try:
825+
collector = MacosWifiCollector(sample_rate_hz=sample_rate_hz)
826+
logger.info("WiFi collector: using MacosWifiCollector")
827+
return collector
828+
except Exception as exc:
829+
logger.warning("WiFi collector: MacosWifiCollector unavailable (%s).", exc)
830+
elif preferred == "linux":
831+
return LinuxWifiCollector(interface=interface, sample_rate_hz=sample_rate_hz)
832+
elif preferred == "windows":
833+
return WindowsWifiCollector(interface=interface, sample_rate_hz=min(sample_rate_hz, 2.0))
834+
elif preferred == "macos":
835+
return MacosWifiCollector(sample_rate_hz=sample_rate_hz)
836+
elif preferred == "simulated":
837+
return SimulatedCollector(seed=42, sample_rate_hz=sample_rate_hz)
838+
839+
logger.info(
840+
"WiFi collector: falling back to SimulatedCollector. "
841+
"For real sensing, connect ESP32 nodes via UDP:5005 or install platform WiFi drivers."
842+
)
843+
return SimulatedCollector(seed=42, sample_rate_hz=sample_rate_hz)

v1/src/sensing/ws_server.py

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import json
2525
import logging
2626
import math
27-
import platform
2827
import signal
2928
import socket
3029
import struct
@@ -38,10 +37,6 @@
3837

3938
# Sensing pipeline imports
4039
from v1.src.sensing.rssi_collector import (
41-
LinuxWifiCollector,
42-
SimulatedCollector,
43-
WindowsWifiCollector,
44-
MacosWifiCollector,
4540
WifiSample,
4641
RingBuffer,
4742
)
@@ -321,51 +316,32 @@ def __init__(self) -> None:
321316
self._running = False
322317

323318
def _create_collector(self):
324-
"""Auto-detect data source: ESP32 UDP > Windows WiFi > Linux WiFi > simulated."""
319+
"""Auto-detect data source: ESP32 UDP > platform WiFi > simulated.
320+
321+
Uses the ``create_collector`` factory (ADR-049) for platform WiFi
322+
detection, which never raises and logs actionable fallback messages.
323+
"""
324+
from .rssi_collector import create_collector
325+
325326
# 1. Try ESP32 UDP first
326327
print(" Probing for ESP32 on UDP :5005 ...")
327328
if probe_esp32_udp(ESP32_UDP_PORT, timeout=2.0):
328329
logger.info("ESP32 CSI stream detected on UDP :%d", ESP32_UDP_PORT)
329330
self.source = "esp32"
330331
return Esp32UdpCollector(port=ESP32_UDP_PORT, sample_rate_hz=10.0)
331332

332-
# 2. Platform-specific WiFi
333-
system = platform.system()
334-
if system == "Windows":
335-
try:
336-
collector = WindowsWifiCollector(sample_rate_hz=2.0)
337-
collector.collect_once() # test that it works
338-
logger.info("Using WindowsWifiCollector")
339-
self.source = "windows_wifi"
340-
return collector
341-
except Exception as e:
342-
logger.warning("Windows WiFi unavailable (%s), falling back", e)
343-
elif system == "Linux":
344-
# In Docker on Mac, Linux is detected but no wireless extensions exist.
345-
# Force SimulatedCollector if /proc/net/wireless doesn't exist.
346-
import os
347-
if os.path.exists("/proc/net/wireless"):
348-
try:
349-
collector = LinuxWifiCollector(sample_rate_hz=10.0)
350-
self.source = "linux_wifi"
351-
return collector
352-
except RuntimeError:
353-
logger.warning("Linux WiFi unavailable, falling back")
354-
else:
355-
logger.warning("Linux detected but /proc/net/wireless missing (likely Docker). Falling back.")
356-
elif system == "Darwin":
357-
try:
358-
collector = MacosWifiCollector(sample_rate_hz=10.0)
359-
logger.info("Using MacosWifiCollector")
360-
self.source = "macos_wifi"
361-
return collector
362-
except Exception as e:
363-
logger.warning("macOS WiFi unavailable (%s), falling back", e)
364-
365-
# 3. Simulated
366-
logger.info("Using SimulatedCollector")
367-
self.source = "simulated"
368-
return SimulatedCollector(seed=42, sample_rate_hz=10.0)
333+
# 2. Platform-specific WiFi (auto-detect with graceful fallback)
334+
collector = create_collector(preferred="auto", sample_rate_hz=10.0)
335+
336+
# Map collector class to source label
337+
source_map = {
338+
"LinuxWifiCollector": "linux_wifi",
339+
"WindowsWifiCollector": "windows_wifi",
340+
"MacosWifiCollector": "macos_wifi",
341+
"SimulatedCollector": "simulated",
342+
}
343+
self.source = source_map.get(type(collector).__name__, "unknown")
344+
return collector
369345

370346
def _build_message(self, features: RssiFeatures, result: SensingResult) -> str:
371347
"""Build the JSON message to broadcast."""

0 commit comments

Comments
 (0)