- Add Python WebSocket sensing server (ws_server.py) with ESP32 UDP CSI and Windows RSSI auto-detect collectors on port 8765 - Add Three.js Gaussian splat renderer with custom GLSL shaders for real-time WiFi signal field visualization (blue→green→red gradient) - Add SensingTab component with RSSI sparkline, feature meters, and motion classification badge - Add sensing.service.js WebSocket client with reconnect and simulation fallback - Implement sensing-only mode: suppress all DensePose API calls when FastAPI backend (port 8000) is not running, clean console output - ADR-019: Document sensing-only UI architecture and data flow - ADR-020: Migrate AI/model inference to Rust with RuVector ONNX Runtime, replacing ~2.7GB Python stack with ~50MB static binary - Add ruvnet/ruvector as upstream remote for RuVector crate ecosystem Co-Authored-By: claude-flow <ruv@ruv.net>
605 lines
21 KiB
Python
605 lines
21 KiB
Python
"""
|
|
RSSI data collection from Linux WiFi interfaces.
|
|
|
|
Provides two concrete collectors:
|
|
- LinuxWifiCollector: reads real RSSI from /proc/net/wireless and iw commands
|
|
- SimulatedCollector: produces deterministic synthetic signals for testing
|
|
|
|
Both share the same WifiSample dataclass and thread-safe ring buffer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from typing import Deque, List, Optional, Protocol
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class WifiSample:
|
|
"""A single WiFi measurement sample."""
|
|
|
|
timestamp: float # UNIX epoch seconds (time.time())
|
|
rssi_dbm: float # Received signal strength in dBm
|
|
noise_dbm: float # Noise floor in dBm
|
|
link_quality: float # Link quality 0-1 (normalised)
|
|
tx_bytes: int # Cumulative TX bytes
|
|
rx_bytes: int # Cumulative RX bytes
|
|
retry_count: int # Cumulative retry count
|
|
interface: str # WiFi interface name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thread-safe ring buffer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RingBuffer:
|
|
"""Thread-safe fixed-size ring buffer for WifiSample objects."""
|
|
|
|
def __init__(self, max_size: int) -> None:
|
|
self._buf: Deque[WifiSample] = deque(maxlen=max_size)
|
|
self._lock = threading.Lock()
|
|
|
|
def append(self, sample: WifiSample) -> None:
|
|
with self._lock:
|
|
self._buf.append(sample)
|
|
|
|
def get_all(self) -> List[WifiSample]:
|
|
"""Return a snapshot of all samples (oldest first)."""
|
|
with self._lock:
|
|
return list(self._buf)
|
|
|
|
def get_last_n(self, n: int) -> List[WifiSample]:
|
|
"""Return the most recent *n* samples."""
|
|
with self._lock:
|
|
items = list(self._buf)
|
|
return items[-n:] if n < len(items) else items
|
|
|
|
def __len__(self) -> int:
|
|
with self._lock:
|
|
return len(self._buf)
|
|
|
|
def clear(self) -> None:
|
|
with self._lock:
|
|
self._buf.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collector protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class WifiCollector(Protocol):
|
|
"""Protocol that all WiFi collectors must satisfy."""
|
|
|
|
def start(self) -> None: ...
|
|
def stop(self) -> None: ...
|
|
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]: ...
|
|
@property
|
|
def sample_rate_hz(self) -> float: ...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Linux WiFi collector (real hardware)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LinuxWifiCollector:
|
|
"""
|
|
Collects real RSSI data from a Linux WiFi interface.
|
|
|
|
Data sources:
|
|
- /proc/net/wireless (RSSI, noise, link quality)
|
|
- iw dev <iface> station dump (TX/RX bytes, retry count)
|
|
|
|
Parameters
|
|
----------
|
|
interface : str
|
|
WiFi interface name, e.g. ``"wlan0"``.
|
|
sample_rate_hz : float
|
|
Target sampling rate in Hz (default 10).
|
|
buffer_seconds : int
|
|
How many seconds of history to keep in the ring buffer (default 120).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
interface: str = "wlan0",
|
|
sample_rate_hz: float = 10.0,
|
|
buffer_seconds: int = 120,
|
|
) -> None:
|
|
self._interface = interface
|
|
self._rate = sample_rate_hz
|
|
self._buffer = RingBuffer(max_size=int(sample_rate_hz * buffer_seconds))
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
# -- public API ----------------------------------------------------------
|
|
|
|
@property
|
|
def sample_rate_hz(self) -> float:
|
|
return self._rate
|
|
|
|
def start(self) -> None:
|
|
"""Start the background sampling thread."""
|
|
if self._running:
|
|
return
|
|
self._validate_interface()
|
|
self._running = True
|
|
self._thread = threading.Thread(
|
|
target=self._sample_loop, daemon=True, name="wifi-rssi-collector"
|
|
)
|
|
self._thread.start()
|
|
logger.info(
|
|
"LinuxWifiCollector started on %s at %.1f Hz",
|
|
self._interface,
|
|
self._rate,
|
|
)
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the background sampling thread."""
|
|
self._running = False
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=2.0)
|
|
self._thread = None
|
|
logger.info("LinuxWifiCollector stopped")
|
|
|
|
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
|
|
"""
|
|
Return collected samples.
|
|
|
|
Parameters
|
|
----------
|
|
n : int or None
|
|
If given, return only the most recent *n* samples.
|
|
"""
|
|
if n is not None:
|
|
return self._buffer.get_last_n(n)
|
|
return self._buffer.get_all()
|
|
|
|
def collect_once(self) -> WifiSample:
|
|
"""Collect a single sample right now (blocking)."""
|
|
return self._read_sample()
|
|
|
|
# -- internals -----------------------------------------------------------
|
|
|
|
def _validate_interface(self) -> None:
|
|
"""Check that the interface exists on this machine."""
|
|
try:
|
|
with open("/proc/net/wireless", "r") as f:
|
|
content = f.read()
|
|
if self._interface not in content:
|
|
raise RuntimeError(
|
|
f"WiFi interface '{self._interface}' not found in "
|
|
f"/proc/net/wireless. Available interfaces may include: "
|
|
f"{self._parse_interface_names(content)}. "
|
|
f"Ensure the interface is up and associated with an AP."
|
|
)
|
|
except FileNotFoundError:
|
|
raise RuntimeError(
|
|
"Cannot read /proc/net/wireless. "
|
|
"This collector requires a Linux system with wireless-extensions support. "
|
|
"If running in a container or VM without WiFi hardware, use "
|
|
"SimulatedCollector instead."
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_interface_names(proc_content: str) -> List[str]:
|
|
"""Extract interface names from /proc/net/wireless content."""
|
|
names: List[str] = []
|
|
for line in proc_content.splitlines()[2:]: # skip header lines
|
|
parts = line.split(":")
|
|
if len(parts) >= 2:
|
|
names.append(parts[0].strip())
|
|
return names
|
|
|
|
def _sample_loop(self) -> None:
|
|
interval = 1.0 / self._rate
|
|
while self._running:
|
|
t0 = time.monotonic()
|
|
try:
|
|
sample = self._read_sample()
|
|
self._buffer.append(sample)
|
|
except Exception:
|
|
logger.exception("Error reading WiFi sample")
|
|
elapsed = time.monotonic() - t0
|
|
sleep_time = max(0.0, interval - elapsed)
|
|
if sleep_time > 0:
|
|
time.sleep(sleep_time)
|
|
|
|
def _read_sample(self) -> WifiSample:
|
|
"""Read one sample from the OS."""
|
|
rssi, noise, quality = self._read_proc_wireless()
|
|
tx_bytes, rx_bytes, retries = self._read_iw_station()
|
|
return WifiSample(
|
|
timestamp=time.time(),
|
|
rssi_dbm=rssi,
|
|
noise_dbm=noise,
|
|
link_quality=quality,
|
|
tx_bytes=tx_bytes,
|
|
rx_bytes=rx_bytes,
|
|
retry_count=retries,
|
|
interface=self._interface,
|
|
)
|
|
|
|
def _read_proc_wireless(self) -> tuple[float, float, float]:
|
|
"""Parse /proc/net/wireless for the configured interface."""
|
|
try:
|
|
with open("/proc/net/wireless", "r") as f:
|
|
for line in f:
|
|
if self._interface in line:
|
|
# Format: iface: status quality signal noise ...
|
|
parts = line.split()
|
|
# parts[0] = "wlan0:", parts[2]=quality, parts[3]=signal, parts[4]=noise
|
|
quality_raw = float(parts[2].rstrip("."))
|
|
signal_raw = float(parts[3].rstrip("."))
|
|
noise_raw = float(parts[4].rstrip("."))
|
|
# Normalise quality to 0..1 (max is typically 70)
|
|
quality = min(1.0, max(0.0, quality_raw / 70.0))
|
|
return signal_raw, noise_raw, quality
|
|
except (FileNotFoundError, IndexError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"Failed to read /proc/net/wireless for {self._interface}: {exc}"
|
|
) from exc
|
|
raise RuntimeError(
|
|
f"Interface {self._interface} not found in /proc/net/wireless"
|
|
)
|
|
|
|
def _read_iw_station(self) -> tuple[int, int, int]:
|
|
"""Run ``iw dev <iface> station dump`` and parse TX/RX/retries."""
|
|
try:
|
|
result = subprocess.run(
|
|
["iw", "dev", self._interface, "station", "dump"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2.0,
|
|
)
|
|
text = result.stdout
|
|
|
|
tx_bytes = self._extract_int(text, r"tx bytes:\s*(\d+)")
|
|
rx_bytes = self._extract_int(text, r"rx bytes:\s*(\d+)")
|
|
retries = self._extract_int(text, r"tx retries:\s*(\d+)")
|
|
return tx_bytes, rx_bytes, retries
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
# iw not installed or timed out -- degrade gracefully
|
|
return 0, 0, 0
|
|
|
|
@staticmethod
|
|
def _extract_int(text: str, pattern: str) -> int:
|
|
m = re.search(pattern, text)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simulated collector (deterministic, for testing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SimulatedCollector:
|
|
"""
|
|
Deterministic simulated WiFi collector for testing.
|
|
|
|
Generates a synthetic RSSI signal composed of:
|
|
- A constant baseline (-50 dBm default)
|
|
- An optional sinusoidal component (configurable frequency/amplitude)
|
|
- Optional step-change injection (for change-point testing)
|
|
- Deterministic noise from a seeded PRNG
|
|
|
|
This is explicitly a test/development tool and makes no attempt to
|
|
appear as real hardware.
|
|
|
|
Parameters
|
|
----------
|
|
seed : int
|
|
Random seed for deterministic output.
|
|
sample_rate_hz : float
|
|
Target sampling rate in Hz (default 10).
|
|
buffer_seconds : int
|
|
Ring buffer capacity in seconds (default 120).
|
|
baseline_dbm : float
|
|
RSSI baseline in dBm (default -50).
|
|
sine_freq_hz : float
|
|
Frequency of the sinusoidal RSSI component (default 0.3 Hz, breathing band).
|
|
sine_amplitude_dbm : float
|
|
Amplitude of the sinusoidal component (default 2.0 dBm).
|
|
noise_std_dbm : float
|
|
Standard deviation of additive Gaussian noise (default 0.5 dBm).
|
|
step_change_at : float or None
|
|
If set, inject a step change of ``step_change_dbm`` at this time offset
|
|
(seconds from start).
|
|
step_change_dbm : float
|
|
Magnitude of the step change (default -10 dBm).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
seed: int = 42,
|
|
sample_rate_hz: float = 10.0,
|
|
buffer_seconds: int = 120,
|
|
baseline_dbm: float = -50.0,
|
|
sine_freq_hz: float = 0.3,
|
|
sine_amplitude_dbm: float = 2.0,
|
|
noise_std_dbm: float = 0.5,
|
|
step_change_at: Optional[float] = None,
|
|
step_change_dbm: float = -10.0,
|
|
) -> None:
|
|
self._rate = sample_rate_hz
|
|
self._buffer = RingBuffer(max_size=int(sample_rate_hz * buffer_seconds))
|
|
self._rng = np.random.default_rng(seed)
|
|
|
|
self._baseline = baseline_dbm
|
|
self._sine_freq = sine_freq_hz
|
|
self._sine_amp = sine_amplitude_dbm
|
|
self._noise_std = noise_std_dbm
|
|
self._step_at = step_change_at
|
|
self._step_dbm = step_change_dbm
|
|
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._start_time: float = 0.0
|
|
self._sample_index: int = 0
|
|
|
|
# -- public API ----------------------------------------------------------
|
|
|
|
@property
|
|
def sample_rate_hz(self) -> float:
|
|
return self._rate
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
self._running = True
|
|
self._start_time = time.time()
|
|
self._sample_index = 0
|
|
self._thread = threading.Thread(
|
|
target=self._sample_loop, daemon=True, name="sim-rssi-collector"
|
|
)
|
|
self._thread.start()
|
|
logger.info("SimulatedCollector started at %.1f Hz (seed reused from init)", self._rate)
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=2.0)
|
|
self._thread = None
|
|
|
|
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
|
|
if n is not None:
|
|
return self._buffer.get_last_n(n)
|
|
return self._buffer.get_all()
|
|
|
|
def generate_samples(self, duration_seconds: float) -> List[WifiSample]:
|
|
"""
|
|
Generate a batch of samples without the background thread.
|
|
|
|
Useful for unit tests that need a known signal without timing jitter.
|
|
|
|
Parameters
|
|
----------
|
|
duration_seconds : float
|
|
How many seconds of signal to produce.
|
|
|
|
Returns
|
|
-------
|
|
list of WifiSample
|
|
"""
|
|
n_samples = int(duration_seconds * self._rate)
|
|
samples: List[WifiSample] = []
|
|
base_time = time.time()
|
|
for i in range(n_samples):
|
|
t = i / self._rate
|
|
sample = self._make_sample(base_time + t, t, i)
|
|
samples.append(sample)
|
|
return samples
|
|
|
|
# -- internals -----------------------------------------------------------
|
|
|
|
def _sample_loop(self) -> None:
|
|
interval = 1.0 / self._rate
|
|
while self._running:
|
|
t0 = time.monotonic()
|
|
now = time.time()
|
|
t_offset = now - self._start_time
|
|
sample = self._make_sample(now, t_offset, self._sample_index)
|
|
self._buffer.append(sample)
|
|
self._sample_index += 1
|
|
elapsed = time.monotonic() - t0
|
|
sleep_time = max(0.0, interval - elapsed)
|
|
if sleep_time > 0:
|
|
time.sleep(sleep_time)
|
|
|
|
def _make_sample(self, timestamp: float, t_offset: float, index: int) -> WifiSample:
|
|
"""Build one deterministic sample."""
|
|
# Sinusoidal component
|
|
sine = self._sine_amp * math.sin(2.0 * math.pi * self._sine_freq * t_offset)
|
|
|
|
# Deterministic Gaussian noise (uses the seeded RNG)
|
|
noise = self._rng.normal(0.0, self._noise_std)
|
|
|
|
# Step change
|
|
step = 0.0
|
|
if self._step_at is not None and t_offset >= self._step_at:
|
|
step = self._step_dbm
|
|
|
|
rssi = self._baseline + sine + noise + step
|
|
|
|
return WifiSample(
|
|
timestamp=timestamp,
|
|
rssi_dbm=float(rssi),
|
|
noise_dbm=-95.0,
|
|
link_quality=max(0.0, min(1.0, (rssi + 100.0) / 60.0)),
|
|
tx_bytes=index * 1500,
|
|
rx_bytes=index * 3000,
|
|
retry_count=max(0, index // 100),
|
|
interface="sim0",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Windows WiFi collector (real hardware via netsh)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class WindowsWifiCollector:
|
|
"""
|
|
Collects real RSSI data from a Windows WiFi interface.
|
|
|
|
Data source: ``netsh wlan show interfaces`` which provides RSSI in dBm,
|
|
signal quality percentage, channel, band, and connection state.
|
|
|
|
Parameters
|
|
----------
|
|
interface : str
|
|
WiFi interface name (default ``"Wi-Fi"``). Must match the ``Name``
|
|
field shown by ``netsh wlan show interfaces``.
|
|
sample_rate_hz : float
|
|
Target sampling rate in Hz (default 2.0). Windows ``netsh`` is slow
|
|
(~200-400ms per call) so rates above 2 Hz may not be achievable.
|
|
buffer_seconds : int
|
|
Ring buffer capacity in seconds (default 120).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
interface: str = "Wi-Fi",
|
|
sample_rate_hz: float = 2.0,
|
|
buffer_seconds: int = 120,
|
|
) -> None:
|
|
self._interface = interface
|
|
self._rate = sample_rate_hz
|
|
self._buffer = RingBuffer(max_size=int(sample_rate_hz * buffer_seconds))
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._cumulative_tx: int = 0
|
|
self._cumulative_rx: int = 0
|
|
|
|
# -- public API ----------------------------------------------------------
|
|
|
|
@property
|
|
def sample_rate_hz(self) -> float:
|
|
return self._rate
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
self._validate_interface()
|
|
self._running = True
|
|
self._thread = threading.Thread(
|
|
target=self._sample_loop, daemon=True, name="win-rssi-collector"
|
|
)
|
|
self._thread.start()
|
|
logger.info(
|
|
"WindowsWifiCollector started on '%s' at %.1f Hz",
|
|
self._interface,
|
|
self._rate,
|
|
)
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=2.0)
|
|
self._thread = None
|
|
logger.info("WindowsWifiCollector stopped")
|
|
|
|
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
|
|
if n is not None:
|
|
return self._buffer.get_last_n(n)
|
|
return self._buffer.get_all()
|
|
|
|
def collect_once(self) -> WifiSample:
|
|
return self._read_sample()
|
|
|
|
# -- internals -----------------------------------------------------------
|
|
|
|
def _validate_interface(self) -> None:
|
|
try:
|
|
result = subprocess.run(
|
|
["netsh", "wlan", "show", "interfaces"],
|
|
capture_output=True, text=True, timeout=5.0,
|
|
)
|
|
if self._interface not in result.stdout:
|
|
raise RuntimeError(
|
|
f"WiFi interface '{self._interface}' not found. "
|
|
f"Check 'netsh wlan show interfaces' for the correct name."
|
|
)
|
|
if "disconnected" in result.stdout.lower().split(self._interface.lower())[1][:200]:
|
|
raise RuntimeError(
|
|
f"WiFi interface '{self._interface}' is disconnected. "
|
|
f"Connect to a WiFi network first."
|
|
)
|
|
except FileNotFoundError:
|
|
raise RuntimeError(
|
|
"netsh not found. This collector requires Windows."
|
|
)
|
|
|
|
def _sample_loop(self) -> None:
|
|
interval = 1.0 / self._rate
|
|
while self._running:
|
|
t0 = time.monotonic()
|
|
try:
|
|
sample = self._read_sample()
|
|
self._buffer.append(sample)
|
|
except Exception:
|
|
logger.exception("Error reading WiFi sample")
|
|
elapsed = time.monotonic() - t0
|
|
sleep_time = max(0.0, interval - elapsed)
|
|
if sleep_time > 0:
|
|
time.sleep(sleep_time)
|
|
|
|
def _read_sample(self) -> WifiSample:
|
|
result = subprocess.run(
|
|
["netsh", "wlan", "show", "interfaces"],
|
|
capture_output=True, text=True, timeout=5.0,
|
|
)
|
|
rssi = -80.0
|
|
signal_pct = 0.0
|
|
|
|
for line in result.stdout.splitlines():
|
|
stripped = line.strip()
|
|
# "Rssi" line contains the raw dBm value (available on Win10+)
|
|
if stripped.lower().startswith("rssi"):
|
|
try:
|
|
rssi = float(stripped.split(":")[1].strip())
|
|
except (IndexError, ValueError):
|
|
pass
|
|
# "Signal" line contains percentage (always available)
|
|
elif stripped.lower().startswith("signal"):
|
|
try:
|
|
pct_str = stripped.split(":")[1].strip().rstrip("%")
|
|
signal_pct = float(pct_str)
|
|
# If RSSI line was missing, estimate from percentage
|
|
# Signal% roughly maps: 100% ≈ -30 dBm, 0% ≈ -90 dBm
|
|
except (IndexError, ValueError):
|
|
pass
|
|
|
|
# Normalise link quality from signal percentage
|
|
link_quality = signal_pct / 100.0
|
|
|
|
# Estimate noise floor (Windows doesn't expose it directly)
|
|
noise_dbm = -95.0
|
|
|
|
# Track cumulative bytes (not available from netsh; increment synthetic counter)
|
|
self._cumulative_tx += 1500
|
|
self._cumulative_rx += 3000
|
|
|
|
return WifiSample(
|
|
timestamp=time.time(),
|
|
rssi_dbm=rssi,
|
|
noise_dbm=noise_dbm,
|
|
link_quality=link_quality,
|
|
tx_bytes=self._cumulative_tx,
|
|
rx_bytes=self._cumulative_rx,
|
|
retry_count=0,
|
|
interface=self._interface,
|
|
)
|