Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions v1/src/sensing/ws_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
# ESP32 UDP Collector — reads ADR-018 binary frames
# ---------------------------------------------------------------------------


class Esp32UdpCollector:
"""
Collects real CSI data from ESP32 nodes via UDP (ADR-018 binary format).
Expand All @@ -79,7 +80,7 @@ class Esp32UdpCollector:
# ADR-018 header: magic(4) node_id(1) n_ant(1) n_sc(2) freq(4) seq(4) rssi(1) noise(1) reserved(2)
MAGIC = 0xC5110001
HEADER_SIZE = 20
HEADER_FMT = '<IBBHIIBB2x'
HEADER_FMT = "<IBBHIIBB2x"

def __init__(
self,
Expand Down Expand Up @@ -130,7 +131,9 @@ def stop(self) -> None:
if self._sock:
self._sock.close()
self._sock = None
logger.info("Esp32UdpCollector stopped (%d frames received)", self._frames_received)
logger.info(
"Esp32UdpCollector stopped (%d frames received)", self._frames_received
)

def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
if n is not None:
Expand All @@ -152,8 +155,9 @@ def _parse_and_store(self, raw: bytes, addr) -> None:
if len(raw) < self.HEADER_SIZE:
return

magic, node_id, n_ant, n_sc, freq_mhz, seq, rssi_u8, noise_u8 = \
magic, node_id, n_ant, n_sc, freq_mhz, seq, rssi_u8, noise_u8 = (
struct.unpack_from(self.HEADER_FMT, raw, 0)
)

if magic != self.MAGIC:
return
Expand All @@ -167,10 +171,10 @@ def _parse_and_store(self, raw: bytes, addr) -> None:
amplitude_list = []

if len(raw) >= iq_bytes_needed and iq_count > 0:
iq_raw = struct.unpack_from(f'<{iq_count * 2}b', raw, self.HEADER_SIZE)
iq_raw = struct.unpack_from(f"<{iq_count * 2}b", raw, self.HEADER_SIZE)
i_vals = np.array(iq_raw[0::2], dtype=np.float64)
q_vals = np.array(iq_raw[1::2], dtype=np.float64)
amplitudes = np.sqrt(i_vals ** 2 + q_vals ** 2)
amplitudes = np.sqrt(i_vals**2 + q_vals**2)
mean_amp = float(np.mean(amplitudes))
amplitude_list = amplitudes.tolist()
else:
Expand Down Expand Up @@ -216,6 +220,7 @@ def _parse_and_store(self, raw: bytes, addr) -> None:
# Probe for ESP32 UDP
# ---------------------------------------------------------------------------


def probe_esp32_udp(port: int = ESP32_UDP_PORT, timeout: float = 2.0) -> bool:
"""Return True if an ESP32 is actively streaming on the UDP port."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
Expand All @@ -225,7 +230,7 @@ def probe_esp32_udp(port: int = ESP32_UDP_PORT, timeout: float = 2.0) -> bool:
sock.bind(("0.0.0.0", port))
data, _ = sock.recvfrom(256)
if len(data) >= 20:
magic = struct.unpack_from('<I', data, 0)[0]
magic = struct.unpack_from("<I", data, 0)[0]
return magic == 0xC5110001
return False
except (socket.timeout, OSError):
Expand All @@ -238,6 +243,7 @@ def probe_esp32_udp(port: int = ESP32_UDP_PORT, timeout: float = 2.0) -> bool:
# Signal field generator
# ---------------------------------------------------------------------------


def generate_signal_field(
features: RssiFeatures,
result: SensingResult,
Expand Down Expand Up @@ -309,6 +315,7 @@ def generate_signal_field(
# WebSocket server
# ---------------------------------------------------------------------------


class SensingWebSocketServer:
"""Async WebSocket server that broadcasts sensing updates."""

Expand Down Expand Up @@ -344,15 +351,20 @@ def _create_collector(self):
# In Docker on Mac, Linux is detected but no wireless extensions exist.
# Force SimulatedCollector if /proc/net/wireless doesn't exist.
import os

if os.path.exists("/proc/net/wireless"):
try:
collector = LinuxWifiCollector(sample_rate_hz=10.0)
collector.collect_once() # verify the interface is readable before committing to Linux WiFi
self.source = "linux_wifi"
logger.info("Using LinuxWifiCollector")
return collector
except RuntimeError:
logger.warning("Linux WiFi unavailable, falling back")
except RuntimeError as e:
logger.warning("Linux WiFi unavailable (%s), falling back", e)
else:
logger.warning("Linux detected but /proc/net/wireless missing (likely Docker). Falling back.")
logger.warning(
"Linux detected but /proc/net/wireless missing (likely Docker). Falling back."
)
elif system == "Darwin":
try:
collector = MacosWifiCollector(sample_rate_hz=10.0)
Expand Down Expand Up @@ -512,6 +524,7 @@ def stop(self) -> None:
# Entry point
# ---------------------------------------------------------------------------


def main():
logging.basicConfig(
level=logging.INFO,
Expand Down
51 changes: 51 additions & 0 deletions v1/tests/unit/test_ws_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from __future__ import annotations

import sys
import types
from unittest.mock import patch

fake_numpy = types.ModuleType("numpy")
setattr(fake_numpy, "array", lambda *args, **kwargs: [])
setattr(fake_numpy, "zeros", lambda *args, **kwargs: [])
setattr(fake_numpy, "linspace", lambda *args, **kwargs: [])
setattr(fake_numpy, "meshgrid", lambda *args, **kwargs: ([], []))
setattr(fake_numpy, "exp", lambda *args, **kwargs: 0)
setattr(fake_numpy, "sqrt", lambda *args, **kwargs: 0)
setattr(fake_numpy, "clip", lambda value, *_args, **_kwargs: value)
setattr(fake_numpy, "float64", float)
setattr(fake_numpy, "ndarray", list)
sys.modules.setdefault("numpy", fake_numpy)

fake_numpy_typing = types.ModuleType("numpy.typing")
setattr(fake_numpy_typing, "NDArray", list)
sys.modules.setdefault("numpy.typing", fake_numpy_typing)

fake_scipy = types.ModuleType("scipy")
setattr(fake_scipy, "fft", types.SimpleNamespace())
setattr(fake_scipy, "stats", types.SimpleNamespace())
sys.modules.setdefault("scipy", fake_scipy)

from v1.src.sensing.ws_server import SensingWebSocketServer


def test_create_collector_falls_back_when_linux_wifi_probe_fails() -> None:
server = SensingWebSocketServer()
simulated_collector = object()

with (
patch("v1.src.sensing.ws_server.probe_esp32_udp", return_value=False),
patch("v1.src.sensing.ws_server.platform.system", return_value="Linux"),
patch("os.path.exists", return_value=True),
patch(
"v1.src.sensing.ws_server.LinuxWifiCollector.collect_once",
side_effect=RuntimeError("wifi unavailable"),
),
patch(
"v1.src.sensing.ws_server.SimulatedCollector",
return_value=simulated_collector,
),
):
collector = server._create_collector()

assert collector is simulated_collector
assert server.source == "simulated"