chore(repo): move v1/ → archive/v1/ + add archive/README.md (#430)

The Rust port at v2/ has been the primary codebase since the rename
in #427. The Python implementation at v1/ is no longer the active
target; the only load-bearing path is the deterministic proof bundle
at v1/data/proof/ (per ADR-011 / ADR-028 witness verification).

Move the whole Python tree into archive/v1/ and document the policy
in archive/README.md: no new features, bug fixes only when they affect
a still-load-bearing path (currently just the proof), CI continues to
verify the proof on every push and PR.

Path references updated in 26 files via path-pattern sed (only
matches v1/<known-child> patterns, never bare v1 or API URLs like
/api/v1/). Two double-prefix typos (archive/archive/v1/) caught and
hand-fixed in verify-pipeline.yml and ADR-011.

Validated:
- Python proof verify.py imports cleanly at archive/v1/data/proof/
  (numpy/scipy still required; CI installs requirements-lock.txt
  from archive/v1/ now)
- cargo test --workspace --no-default-features → 1,539 passed,
  0 failed, 8 ignored (unaffected by Python tree relocation)
- ESP32-S3 on COM7 untouched (no firmware paths changed)

After-merge: contributors should re-run any local `python v1/...`
commands as `python archive/v1/...` (CLAUDE.md and CHANGELOG already
updated).
This commit is contained in:
rUv
2026-04-25 23:07:52 -04:00
committed by GitHub
parent 74233cfb23
commit 81cc241b9e
183 changed files with 290 additions and 216 deletions
+58
View File
@@ -0,0 +1,58 @@
"""
Commodity WiFi Sensing Module (ADR-013)
=======================================
RSSI-based presence and motion detection using standard Linux WiFi metrics.
This module provides real signal processing from commodity WiFi hardware,
extracting presence and motion features from RSSI time series.
Components:
- rssi_collector: Data collection from Linux WiFi interfaces
- feature_extractor: Time-domain and frequency-domain feature extraction
- classifier: Presence and motion classification from features
- backend: Common sensing backend interface
Capabilities:
- PRESENCE: Detect whether a person is present in the sensing area
- MOTION: Classify motion level (absent / still / active)
Note: This module uses RSSI only. For higher-fidelity sensing (respiration,
pose estimation), CSI-capable hardware and the full DensePose pipeline
are required.
"""
from v1.src.sensing.rssi_collector import (
LinuxWifiCollector,
SimulatedCollector,
WindowsWifiCollector,
WifiSample,
)
from v1.src.sensing.feature_extractor import (
RssiFeatureExtractor,
RssiFeatures,
)
from v1.src.sensing.classifier import (
PresenceClassifier,
SensingResult,
MotionLevel,
)
from v1.src.sensing.backend import (
SensingBackend,
CommodityBackend,
Capability,
)
__all__ = [
"LinuxWifiCollector",
"SimulatedCollector",
"WindowsWifiCollector",
"WifiSample",
"RssiFeatureExtractor",
"RssiFeatures",
"PresenceClassifier",
"SensingResult",
"MotionLevel",
"SensingBackend",
"CommodityBackend",
"Capability",
]
+165
View File
@@ -0,0 +1,165 @@
"""
Common sensing backend interface.
Defines the ``SensingBackend`` protocol and the ``CommodityBackend`` concrete
implementation that wires together the RSSI collector, feature extractor, and
classifier into a single coherent pipeline.
The ``Capability`` enum enumerates all possible sensing capabilities. The
``CommodityBackend`` honestly reports that it supports only PRESENCE and MOTION.
"""
from __future__ import annotations
import logging
from enum import Enum, auto
from typing import List, Optional, Protocol, Set, runtime_checkable
from v1.src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult
from v1.src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures
from v1.src.sensing.rssi_collector import (
LinuxWifiCollector,
SimulatedCollector,
WindowsWifiCollector,
WifiCollector,
WifiSample,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Capability enum
# ---------------------------------------------------------------------------
class Capability(Enum):
"""All possible sensing capabilities across backend tiers."""
PRESENCE = auto()
MOTION = auto()
RESPIRATION = auto()
LOCATION = auto()
POSE = auto()
# ---------------------------------------------------------------------------
# Backend protocol
# ---------------------------------------------------------------------------
@runtime_checkable
class SensingBackend(Protocol):
"""Protocol that all sensing backends must implement."""
def get_features(self) -> RssiFeatures:
"""Extract current features from the sensing pipeline."""
...
def get_capabilities(self) -> Set[Capability]:
"""Return the set of capabilities this backend supports."""
...
# ---------------------------------------------------------------------------
# Commodity backend
# ---------------------------------------------------------------------------
class CommodityBackend:
"""
RSSI-based commodity sensing backend.
Wires together:
- A WiFi collector (real or simulated)
- An RSSI feature extractor
- A presence/motion classifier
Capabilities: PRESENCE and MOTION only.
Parameters
----------
collector : WifiCollector-compatible object
The data source (LinuxWifiCollector or SimulatedCollector).
extractor : RssiFeatureExtractor, optional
Feature extractor (created with defaults if not provided).
classifier : PresenceClassifier, optional
Classifier (created with defaults if not provided).
"""
SUPPORTED_CAPABILITIES: Set[Capability] = frozenset(
{Capability.PRESENCE, Capability.MOTION}
)
def __init__(
self,
collector: LinuxWifiCollector | SimulatedCollector | WindowsWifiCollector,
extractor: Optional[RssiFeatureExtractor] = None,
classifier: Optional[PresenceClassifier] = None,
) -> None:
self._collector = collector
self._extractor = extractor or RssiFeatureExtractor()
self._classifier = classifier or PresenceClassifier()
@property
def collector(self) -> LinuxWifiCollector | SimulatedCollector | WindowsWifiCollector:
return self._collector
@property
def extractor(self) -> RssiFeatureExtractor:
return self._extractor
@property
def classifier(self) -> PresenceClassifier:
return self._classifier
# -- SensingBackend protocol ---------------------------------------------
def get_features(self) -> RssiFeatures:
"""
Get current features from the latest collected samples.
Uses the extractor's window_seconds to determine how many samples
to pull from the collector's ring buffer.
"""
window = self._extractor.window_seconds
sample_rate = self._collector.sample_rate_hz
n_needed = int(window * sample_rate)
samples = self._collector.get_samples(n=n_needed)
return self._extractor.extract(samples)
def get_capabilities(self) -> Set[Capability]:
"""CommodityBackend supports PRESENCE and MOTION only."""
return set(self.SUPPORTED_CAPABILITIES)
# -- convenience methods -------------------------------------------------
def get_result(self) -> SensingResult:
"""
Run the full pipeline: collect -> extract -> classify.
Returns
-------
SensingResult
Classification result with motion level and confidence.
"""
features = self.get_features()
return self._classifier.classify(features)
def start(self) -> None:
"""Start the underlying collector."""
self._collector.start()
logger.info(
"CommodityBackend started (capabilities: %s)",
", ".join(c.name for c in self.SUPPORTED_CAPABILITIES),
)
def stop(self) -> None:
"""Stop the underlying collector."""
self._collector.stop()
logger.info("CommodityBackend stopped")
def is_capable(self, capability: Capability) -> bool:
"""Check whether this backend supports a specific capability."""
return capability in self.SUPPORTED_CAPABILITIES
def __repr__(self) -> str:
caps = ", ".join(c.name for c in sorted(self.SUPPORTED_CAPABILITIES, key=lambda c: c.value))
return f"CommodityBackend(capabilities=[{caps}])"
+201
View File
@@ -0,0 +1,201 @@
"""
Presence and motion classification from RSSI features.
Uses rule-based logic with configurable thresholds to classify the current
sensing state into one of three motion levels:
ABSENT -- no person detected
PRESENT_STILL -- person present but stationary
ACTIVE -- person present and moving
Confidence is derived from spectral feature strength and optional
cross-receiver agreement.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from v1.src.sensing.feature_extractor import RssiFeatures
logger = logging.getLogger(__name__)
class MotionLevel(Enum):
"""Classified motion state."""
ABSENT = "absent"
PRESENT_STILL = "present_still"
ACTIVE = "active"
@dataclass
class SensingResult:
"""Output of the presence/motion classifier."""
motion_level: MotionLevel
confidence: float # 0.0 to 1.0
presence_detected: bool
rssi_variance: float
motion_band_energy: float
breathing_band_energy: float
n_change_points: int
details: str = ""
class PresenceClassifier:
"""
Rule-based presence and motion classifier.
Classification rules
--------------------
1. **Presence**: RSSI variance exceeds ``presence_variance_threshold``.
2. **Motion level**:
- ABSENT if variance < presence threshold
- ACTIVE if variance >= presence threshold AND motion band energy
exceeds ``motion_energy_threshold``
- PRESENT_STILL otherwise (variance above threshold but low motion energy)
Confidence model
----------------
Base confidence comes from how far the measured variance / energy exceeds
the respective thresholds. Cross-receiver agreement (when multiple
receivers report results) can boost confidence further.
Parameters
----------
presence_variance_threshold : float
Minimum RSSI variance (dBm^2) to declare presence (default 0.5).
motion_energy_threshold : float
Minimum motion-band spectral energy to classify as ACTIVE (default 0.1).
max_receivers : int
Maximum number of receivers for cross-receiver agreement (default 1).
"""
def __init__(
self,
presence_variance_threshold: float = 0.5,
motion_energy_threshold: float = 0.1,
max_receivers: int = 1,
) -> None:
self._var_thresh = presence_variance_threshold
self._motion_thresh = motion_energy_threshold
self._max_receivers = max_receivers
@property
def presence_variance_threshold(self) -> float:
return self._var_thresh
@property
def motion_energy_threshold(self) -> float:
return self._motion_thresh
def classify(
self,
features: RssiFeatures,
other_receiver_results: Optional[List[SensingResult]] = None,
) -> SensingResult:
"""
Classify presence and motion from extracted RSSI features.
Parameters
----------
features : RssiFeatures
Features extracted from the RSSI time series of one receiver.
other_receiver_results : list of SensingResult, optional
Results from other receivers for cross-receiver agreement.
Returns
-------
SensingResult
"""
variance = features.variance
motion_energy = features.motion_band_power
breathing_energy = features.breathing_band_power
# -- presence decision ------------------------------------------------
presence = variance >= self._var_thresh
# -- motion level -----------------------------------------------------
if not presence:
level = MotionLevel.ABSENT
elif motion_energy >= self._motion_thresh:
level = MotionLevel.ACTIVE
else:
level = MotionLevel.PRESENT_STILL
# -- confidence -------------------------------------------------------
confidence = self._compute_confidence(
variance, motion_energy, breathing_energy, level, other_receiver_results
)
# -- detail string ----------------------------------------------------
details = (
f"var={variance:.4f} (thresh={self._var_thresh}), "
f"motion_energy={motion_energy:.4f} (thresh={self._motion_thresh}), "
f"breathing_energy={breathing_energy:.4f}, "
f"change_points={features.n_change_points}"
)
return SensingResult(
motion_level=level,
confidence=confidence,
presence_detected=presence,
rssi_variance=variance,
motion_band_energy=motion_energy,
breathing_band_energy=breathing_energy,
n_change_points=features.n_change_points,
details=details,
)
def _compute_confidence(
self,
variance: float,
motion_energy: float,
breathing_energy: float,
level: MotionLevel,
other_results: Optional[List[SensingResult]],
) -> float:
"""
Compute a confidence score in [0, 1].
The score is composed of:
- Base (60%): how clearly the variance exceeds (or falls below) the
presence threshold.
- Spectral (20%): strength of the relevant spectral band.
- Agreement (20%): cross-receiver consensus (if available).
"""
# -- base confidence (0..1) ------------------------------------------
if level == MotionLevel.ABSENT:
# Confidence in absence increases as variance shrinks relative to threshold
if self._var_thresh > 0:
base = max(0.0, 1.0 - variance / self._var_thresh)
else:
base = 1.0
else:
# Confidence in presence increases as variance exceeds threshold
ratio = variance / self._var_thresh if self._var_thresh > 0 else 10.0
base = min(1.0, ratio)
# -- spectral confidence (0..1) --------------------------------------
if level == MotionLevel.ACTIVE:
spectral = min(1.0, motion_energy / max(self._motion_thresh, 1e-12))
elif level == MotionLevel.PRESENT_STILL:
# For still, breathing band energy is more relevant
spectral = min(1.0, breathing_energy / max(self._motion_thresh, 1e-12))
else:
spectral = 1.0 # No spectral requirement for absence
# -- cross-receiver agreement (0..1) ---------------------------------
agreement = 1.0 # default: single receiver
if other_results:
same_level = sum(
1 for r in other_results if r.motion_level == level
)
agreement = (same_level + 1) / (len(other_results) + 1)
# Weighted combination
confidence = 0.6 * base + 0.2 * spectral + 0.2 * agreement
return max(0.0, min(1.0, confidence))
+331
View File
@@ -0,0 +1,331 @@
"""
Signal feature extraction from RSSI time series.
Extracts both time-domain statistical features and frequency-domain spectral
features using real mathematics (scipy.fft, scipy.stats). Also implements
CUSUM change-point detection for abrupt RSSI transitions.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
import numpy as np
from numpy.typing import NDArray
from scipy import fft as scipy_fft
from scipy import stats as scipy_stats
from v1.src.sensing.rssi_collector import WifiSample
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Feature dataclass
# ---------------------------------------------------------------------------
@dataclass
class RssiFeatures:
"""Container for all extracted RSSI features."""
# -- time-domain --------------------------------------------------------
mean: float = 0.0
variance: float = 0.0
std: float = 0.0
skewness: float = 0.0
kurtosis: float = 0.0
range: float = 0.0
iqr: float = 0.0 # inter-quartile range
# -- frequency-domain ---------------------------------------------------
dominant_freq_hz: float = 0.0
breathing_band_power: float = 0.0 # 0.1 - 0.5 Hz
motion_band_power: float = 0.0 # 0.5 - 3.0 Hz
total_spectral_power: float = 0.0
# -- change-point -------------------------------------------------------
change_points: List[int] = field(default_factory=list)
n_change_points: int = 0
# -- metadata -----------------------------------------------------------
n_samples: int = 0
duration_seconds: float = 0.0
sample_rate_hz: float = 0.0
# ---------------------------------------------------------------------------
# Feature extractor
# ---------------------------------------------------------------------------
class RssiFeatureExtractor:
"""
Extract time-domain and frequency-domain features from an RSSI time series.
Parameters
----------
window_seconds : float
Length of the analysis window in seconds (default 30).
cusum_threshold : float
CUSUM threshold for change-point detection (default 3.0 standard deviations
of the signal).
cusum_drift : float
CUSUM drift allowance (default 0.5 standard deviations).
"""
def __init__(
self,
window_seconds: float = 30.0,
cusum_threshold: float = 3.0,
cusum_drift: float = 0.5,
) -> None:
self._window_seconds = window_seconds
self._cusum_threshold = cusum_threshold
self._cusum_drift = cusum_drift
@property
def window_seconds(self) -> float:
return self._window_seconds
def extract(self, samples: List[WifiSample]) -> RssiFeatures:
"""
Extract features from a list of WifiSample objects.
Only the most recent ``window_seconds`` of data are used.
At least 4 samples are required for meaningful features.
"""
if len(samples) < 4:
logger.warning(
"Not enough samples for feature extraction (%d < 4)", len(samples)
)
return RssiFeatures(n_samples=len(samples))
# Trim to window
samples = self._trim_to_window(samples)
if len(samples) < 4:
return RssiFeatures(n_samples=len(samples))
rssi = np.array([s.rssi_dbm for s in samples], dtype=np.float64)
timestamps = np.array([s.timestamp for s in samples], dtype=np.float64)
# Estimate sample rate from actual timestamps
dt = np.diff(timestamps)
if len(dt) == 0 or np.mean(dt) <= 0:
sample_rate = 10.0 # fallback
else:
sample_rate = 1.0 / np.mean(dt)
duration = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0
# Build features
features = RssiFeatures(
n_samples=len(rssi),
duration_seconds=float(duration),
sample_rate_hz=float(sample_rate),
)
self._compute_time_domain(rssi, features)
self._compute_frequency_domain(rssi, sample_rate, features)
self._compute_change_points(rssi, features)
return features
def extract_from_array(
self, rssi: NDArray[np.float64], sample_rate_hz: float
) -> RssiFeatures:
"""
Extract features directly from a numpy array (useful for testing).
Parameters
----------
rssi : ndarray
1-D array of RSSI values in dBm.
sample_rate_hz : float
Sampling rate in Hz.
"""
if len(rssi) < 4:
return RssiFeatures(n_samples=len(rssi))
duration = len(rssi) / sample_rate_hz
features = RssiFeatures(
n_samples=len(rssi),
duration_seconds=float(duration),
sample_rate_hz=float(sample_rate_hz),
)
self._compute_time_domain(rssi, features)
self._compute_frequency_domain(rssi, sample_rate_hz, features)
self._compute_change_points(rssi, features)
return features
# -- window trimming -----------------------------------------------------
def _trim_to_window(self, samples: List[WifiSample]) -> List[WifiSample]:
"""Keep only samples within the most recent ``window_seconds``."""
if not samples:
return samples
latest_ts = samples[-1].timestamp
cutoff = latest_ts - self._window_seconds
trimmed = [s for s in samples if s.timestamp >= cutoff]
return trimmed
# -- time-domain ---------------------------------------------------------
@staticmethod
def _compute_time_domain(rssi: NDArray[np.float64], features: RssiFeatures) -> None:
features.mean = float(np.mean(rssi))
features.variance = float(np.var(rssi, ddof=1)) if len(rssi) > 1 else 0.0
features.std = float(np.std(rssi, ddof=1)) if len(rssi) > 1 else 0.0
features.range = float(np.ptp(rssi))
# Guard against constant signals where higher moments are undefined
if features.std < 1e-12:
features.skewness = 0.0
features.kurtosis = 0.0
else:
features.skewness = float(scipy_stats.skew(rssi, bias=False)) if len(rssi) > 2 else 0.0
features.kurtosis = float(scipy_stats.kurtosis(rssi, bias=False)) if len(rssi) > 3 else 0.0
q75, q25 = np.percentile(rssi, [75, 25])
features.iqr = float(q75 - q25)
# -- frequency-domain ----------------------------------------------------
@staticmethod
def _compute_frequency_domain(
rssi: NDArray[np.float64],
sample_rate: float,
features: RssiFeatures,
) -> None:
"""Compute one-sided FFT power spectrum and extract band powers."""
n = len(rssi)
if n < 4:
return
# Remove DC (subtract mean)
signal = rssi - np.mean(rssi)
# Apply Hann window to reduce spectral leakage
window = np.hanning(n)
windowed = signal * window
# Compute real FFT
fft_vals = scipy_fft.rfft(windowed)
freqs = scipy_fft.rfftfreq(n, d=1.0 / sample_rate)
# Power spectral density (magnitude squared, normalised by N)
psd = (np.abs(fft_vals) ** 2) / n
# Skip DC component (index 0)
if len(freqs) > 1:
freqs_no_dc = freqs[1:]
psd_no_dc = psd[1:]
else:
return
# Total spectral power
features.total_spectral_power = float(np.sum(psd_no_dc))
# Dominant frequency
if len(psd_no_dc) > 0:
peak_idx = int(np.argmax(psd_no_dc))
features.dominant_freq_hz = float(freqs_no_dc[peak_idx])
# Band powers
features.breathing_band_power = float(
_band_power(freqs_no_dc, psd_no_dc, 0.1, 0.5)
)
features.motion_band_power = float(
_band_power(freqs_no_dc, psd_no_dc, 0.5, 3.0)
)
# -- change-point detection (CUSUM) --------------------------------------
def _compute_change_points(
self, rssi: NDArray[np.float64], features: RssiFeatures
) -> None:
"""
Detect change points using the CUSUM algorithm.
The CUSUM statistic tracks cumulative deviations from the mean,
flagging points where the signal mean shifts abruptly.
"""
if len(rssi) < 4:
return
mean_val = np.mean(rssi)
std_val = np.std(rssi, ddof=1)
if std_val < 1e-12:
features.change_points = []
features.n_change_points = 0
return
threshold = self._cusum_threshold * std_val
drift = self._cusum_drift * std_val
change_points = cusum_detect(rssi, mean_val, threshold, drift)
features.change_points = change_points
features.n_change_points = len(change_points)
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _band_power(
freqs: NDArray[np.float64],
psd: NDArray[np.float64],
low_hz: float,
high_hz: float,
) -> float:
"""Sum PSD within a frequency band [low_hz, high_hz]."""
mask = (freqs >= low_hz) & (freqs <= high_hz)
return float(np.sum(psd[mask]))
def cusum_detect(
signal: NDArray[np.float64],
target: float,
threshold: float,
drift: float,
) -> List[int]:
"""
CUSUM (cumulative sum) change-point detection.
Detects both upward and downward shifts in the signal mean.
Parameters
----------
signal : ndarray
The 1-D signal to analyse.
target : float
Expected mean of the signal.
threshold : float
Decision threshold for declaring a change point.
drift : float
Allowable drift before accumulating deviation.
Returns
-------
list of int
Indices where change points were detected.
"""
n = len(signal)
s_pos = 0.0
s_neg = 0.0
change_points: List[int] = []
for i in range(n):
deviation = signal[i] - target
s_pos = max(0.0, s_pos + deviation - drift)
s_neg = max(0.0, s_neg - deviation - drift)
if s_pos > threshold or s_neg > threshold:
change_points.append(i)
# Reset after detection to find subsequent changes
s_pos = 0.0
s_neg = 0.0
return change_points
+34
View File
@@ -0,0 +1,34 @@
import Foundation
import CoreWLAN
// Output format: JSON lines for easy parsing by Python
// {"timestamp": 1234567.89, "rssi": -50, "noise": -90, "tx_rate": 866.0}
func main() {
guard let interface = CWWiFiClient.shared().interface() else {
fputs("{\"error\": \"No WiFi interface found\"}\n", stderr)
exit(1)
}
// Flush stdout automatically to prevent buffering issues with Python subprocess
setbuf(stdout, nil)
// Run at ~10Hz
let interval: TimeInterval = 0.1
while true {
let timestamp = Date().timeIntervalSince1970
let rssi = interface.rssiValue()
let noise = interface.noiseMeasurement()
let txRate = interface.transmitRate()
let json = """
{"timestamp": \(timestamp), "rssi": \(rssi), "noise": \(noise), "tx_rate": \(txRate)}
"""
print(json)
Thread.sleep(forTimeInterval: interval)
}
}
main()
+843
View File
@@ -0,0 +1,843 @@
"""
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 os
import platform
import re
import subprocess
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Deque, List, Optional, Protocol, Union
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()
# -- availability check --------------------------------------------------
@classmethod
def is_available(cls, interface: str = "wlan0") -> tuple[bool, str]:
"""Check if Linux WiFi collection is possible without raising.
Returns
-------
(available, reason) : tuple[bool, str]
``available`` is True when /proc/net/wireless exists and lists
the requested interface. ``reason`` is a human-readable
explanation when unavailable.
"""
if not os.path.exists("/proc/net/wireless"):
return False, (
"/proc/net/wireless not found. "
"This environment has no Linux wireless subsystem "
"(common in Docker, WSL, or headless servers)."
)
try:
with open("/proc/net/wireless", "r") as f:
content = f.read()
except OSError as exc:
return False, f"Cannot read /proc/net/wireless: {exc}"
if interface not in content:
names = cls._parse_interface_names(content)
return False, (
f"Interface '{interface}' not listed in /proc/net/wireless. "
f"Available: {names or '(none)'}. "
f"Ensure the interface is up and associated with an AP."
)
return True, "ok"
# -- internals -----------------------------------------------------------
def _validate_interface(self) -> None:
"""Check that the interface exists on this machine."""
available, reason = self.is_available(self._interface)
if not available:
raise RuntimeError(reason)
@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,
)
# ---------------------------------------------------------------------------
# macOS WiFi collector (real hardware via Swift CoreWLAN utility)
# ---------------------------------------------------------------------------
class MacosWifiCollector:
"""
Collects real RSSI data from a macOS WiFi interface using a Swift utility.
Data source: A small compiled Swift binary (`mac_wifi`) that polls the
CoreWLAN `CWWiFiClient.shared().interface()` at a high rate.
"""
def __init__(
self,
sample_rate_hz: float = 10.0,
buffer_seconds: int = 120,
) -> None:
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._process: Optional[subprocess.Popen] = None
self._interface = "en0" # CoreWLAN automatically targets the active Wi-Fi interface
# Compile the Swift utility if the binary doesn't exist
import os
base_dir = os.path.dirname(os.path.abspath(__file__))
self.swift_src = os.path.join(base_dir, "mac_wifi.swift")
self.swift_bin = os.path.join(base_dir, "mac_wifi")
# -- public API ----------------------------------------------------------
@property
def sample_rate_hz(self) -> float:
return self._rate
def start(self) -> None:
if self._running:
return
# Ensure binary exists
import os
if not os.path.exists(self.swift_bin):
logger.info("Compiling mac_wifi.swift to %s", self.swift_bin)
try:
subprocess.run(["swiftc", "-O", "-o", self.swift_bin, self.swift_src], check=True, capture_output=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to compile macOS WiFi utility: {e.stderr.decode('utf-8')}")
except FileNotFoundError:
raise RuntimeError("swiftc is not installed. Please install Xcode Command Line Tools to use native macOS WiFi sensing.")
self._running = True
self._thread = threading.Thread(
target=self._sample_loop, daemon=True, name="mac-rssi-collector"
)
self._thread.start()
logger.info("MacosWifiCollector started at %.1f Hz", self._rate)
def stop(self) -> None:
self._running = False
if self._process:
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process = None
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
logger.info("MacosWifiCollector 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()
# -- internals -----------------------------------------------------------
def _sample_loop(self) -> None:
import json
# Start the Swift binary
self._process = subprocess.Popen(
[self.swift_bin],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1 # Line buffered
)
while self._running and self._process and self._process.poll() is None:
try:
line = self._process.stdout.readline()
if not line:
continue
line = line.strip()
if not line:
continue
if line.startswith("{"):
data = json.loads(line)
if "error" in data:
logger.error("macOS WiFi utility error: %s", data["error"])
continue
rssi = float(data.get("rssi", -80.0))
noise = float(data.get("noise", -95.0))
link_quality = max(0.0, min(1.0, (rssi + 100.0) / 60.0))
sample = WifiSample(
timestamp=time.time(),
rssi_dbm=rssi,
noise_dbm=noise,
link_quality=link_quality,
tx_bytes=0,
rx_bytes=0,
retry_count=0,
interface=self._interface,
)
self._buffer.append(sample)
except Exception as e:
logger.error("Error reading macOS WiFi stream: %s", e)
time.sleep(1.0)
# Process exited unexpectedly
if self._running:
logger.error("macOS WiFi utility exited unexpectedly. Collector stopped.")
self._running = False
# ---------------------------------------------------------------------------
# Collector factory (ADR-049)
# ---------------------------------------------------------------------------
CollectorType = Union[LinuxWifiCollector, WindowsWifiCollector, MacosWifiCollector, SimulatedCollector]
def create_collector(
preferred: str = "auto",
interface: str = "wlan0",
sample_rate_hz: float = 10.0,
) -> CollectorType:
"""Create the best available WiFi collector for the current platform.
Resolution order (when ``preferred="auto"``):
1. Platform-native WiFi:
- Linux: LinuxWifiCollector (requires /proc/net/wireless + active interface)
- Windows: WindowsWifiCollector (netsh wlan)
- macOS: MacosWifiCollector (CoreWLAN)
2. SimulatedCollector (always available)
This function never raises -- it always returns a usable collector.
Parameters
----------
preferred : str
``"auto"`` for platform detection, or one of ``"linux"``,
``"windows"``, ``"macos"``, ``"simulated"`` to force a specific
collector.
interface : str
WiFi interface name (Linux/Windows only).
sample_rate_hz : float
Target sampling rate.
"""
_VALID_PREFERRED = {"auto", "linux", "windows", "macos", "simulated"}
if preferred not in _VALID_PREFERRED:
logger.warning(
"WiFi collector: unknown preferred=%r (valid: %s). Falling back to auto.",
preferred, ", ".join(sorted(_VALID_PREFERRED)),
)
preferred = "auto"
system = platform.system()
if preferred == "auto":
if system == "Linux":
available, reason = LinuxWifiCollector.is_available(interface)
if available:
logger.info("WiFi collector: using LinuxWifiCollector on %s", interface)
return LinuxWifiCollector(interface=interface, sample_rate_hz=sample_rate_hz)
logger.warning("WiFi collector: LinuxWifiCollector unavailable (%s).", reason)
elif system == "Windows":
try:
win_iface = interface if interface != "wlan0" else "Wi-Fi"
collector = WindowsWifiCollector(interface=win_iface, sample_rate_hz=min(sample_rate_hz, 2.0))
collector.collect_once()
logger.info("WiFi collector: using WindowsWifiCollector on '%s'", interface)
return collector
except Exception as exc:
logger.warning("WiFi collector: WindowsWifiCollector unavailable (%s).", exc)
elif system == "Darwin":
try:
collector = MacosWifiCollector(sample_rate_hz=sample_rate_hz)
logger.info("WiFi collector: using MacosWifiCollector")
return collector
except Exception as exc:
logger.warning("WiFi collector: MacosWifiCollector unavailable (%s).", exc)
elif preferred == "linux":
return LinuxWifiCollector(interface=interface, sample_rate_hz=sample_rate_hz)
elif preferred == "windows":
return WindowsWifiCollector(interface=interface, sample_rate_hz=min(sample_rate_hz, 2.0))
elif preferred == "macos":
return MacosWifiCollector(sample_rate_hz=sample_rate_hz)
elif preferred == "simulated":
return SimulatedCollector(seed=42, sample_rate_hz=sample_rate_hz)
logger.info(
"WiFi collector: falling back to SimulatedCollector. "
"For real sensing, connect ESP32 nodes via UDP:5005 or install platform WiFi drivers."
)
return SimulatedCollector(seed=42, sample_rate_hz=sample_rate_hz)
+519
View File
@@ -0,0 +1,519 @@
"""
WebSocket sensing server.
Lightweight asyncio server that bridges the WiFi sensing pipeline to the
browser UI. Runs the RSSI feature extractor + classifier on a 500 ms
tick and broadcasts JSON frames to all connected WebSocket clients on
``ws://localhost:8765``.
Usage
-----
pip install websockets
python -m v1.src.sensing.ws_server # or python v1/src/sensing/ws_server.py
Data sources (tried in order):
1. ESP32 CSI over UDP port 5005 (ADR-018 binary frames)
2. Windows WiFi RSSI via netsh
3. Linux WiFi RSSI via /proc/net/wireless
4. Simulated collector (fallback)
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import signal
import socket
import struct
import sys
import threading
import time
from collections import deque
from typing import Dict, List, Optional, Set
import numpy as np
# Sensing pipeline imports
from v1.src.sensing.rssi_collector import (
WifiSample,
RingBuffer,
)
from v1.src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures
from v1.src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
HOST = "localhost"
PORT = 8765
TICK_INTERVAL = 0.5 # seconds between broadcasts
SIGNAL_FIELD_GRID = 20 # NxN grid for signal field visualization
ESP32_UDP_PORT = 5005
# ---------------------------------------------------------------------------
# ESP32 UDP Collector — reads ADR-018 binary frames
# ---------------------------------------------------------------------------
class Esp32UdpCollector:
"""
Collects real CSI data from ESP32 nodes via UDP (ADR-018 binary format).
Parses I/Q pairs, computes mean amplitude per frame, and stores it as
an RSSI-equivalent value in the standard WifiSample ring buffer so the
existing feature extractor and classifier work unchanged.
Also keeps the last parsed CSI frame for the UI to show subcarrier data.
"""
# 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'
def __init__(
self,
bind_addr: str = "0.0.0.0",
port: int = ESP32_UDP_PORT,
sample_rate_hz: float = 10.0,
buffer_seconds: int = 120,
) -> None:
self._bind = bind_addr
self._port = port
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._sock: Optional[socket.socket] = None
# Last CSI frame for enhanced UI
self.last_csi: Optional[Dict] = None
self._frames_received = 0
@property
def sample_rate_hz(self) -> float:
return self._rate
@property
def frames_received(self) -> int:
return self._frames_received
def start(self) -> None:
if self._running:
return
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.settimeout(1.0)
self._sock.bind((self._bind, self._port))
self._running = True
self._thread = threading.Thread(
target=self._recv_loop, daemon=True, name="esp32-udp-collector"
)
self._thread.start()
logger.info("Esp32UdpCollector listening on %s:%d", self._bind, self._port)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
if self._sock:
self._sock.close()
self._sock = None
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:
return self._buffer.get_last_n(n)
return self._buffer.get_all()
def _recv_loop(self) -> None:
while self._running:
try:
data, addr = self._sock.recvfrom(4096)
self._parse_and_store(data, addr)
except socket.timeout:
continue
except Exception:
if self._running:
logger.exception("Error receiving ESP32 UDP packet")
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 = \
struct.unpack_from(self.HEADER_FMT, raw, 0)
if magic != self.MAGIC:
return
rssi = rssi_u8 if rssi_u8 < 128 else rssi_u8 - 256
noise = noise_u8 if noise_u8 < 128 else noise_u8 - 256
# Parse I/Q data if available
iq_count = n_ant * n_sc
iq_bytes_needed = self.HEADER_SIZE + iq_count * 2
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)
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)
mean_amp = float(np.mean(amplitudes))
amplitude_list = amplitudes.tolist()
else:
mean_amp = 0.0
# Store enhanced CSI info for UI
self.last_csi = {
"node_id": node_id,
"n_antennas": n_ant,
"n_subcarriers": n_sc,
"freq_mhz": freq_mhz,
"sequence": seq,
"rssi_dbm": rssi,
"noise_floor_dbm": noise,
"mean_amplitude": mean_amp,
"amplitude": amplitude_list[:56], # cap for JSON size
"source_addr": f"{addr[0]}:{addr[1]}",
}
# Use RSSI from the ESP32 frame header as the primary signal metric.
# If RSSI is the default -80 placeholder, derive a pseudo-RSSI from
# mean amplitude to keep the feature extractor meaningful.
effective_rssi = float(rssi)
if rssi == -80 and mean_amp > 0:
# Map amplitude (typically 1-20) to dBm range (-70 to -30)
effective_rssi = -70.0 + min(mean_amp, 20.0) * 2.0
sample = WifiSample(
timestamp=time.time(),
rssi_dbm=effective_rssi,
noise_dbm=float(noise),
link_quality=max(0.0, min(1.0, (effective_rssi + 100.0) / 60.0)),
tx_bytes=seq * 1500,
rx_bytes=seq * 3000,
retry_count=0,
interface=f"esp32-node{node_id}",
)
self._buffer.append(sample)
self._frames_received += 1
# ---------------------------------------------------------------------------
# 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)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(timeout)
try:
sock.bind(("0.0.0.0", port))
data, _ = sock.recvfrom(256)
if len(data) >= 20:
magic = struct.unpack_from('<I', data, 0)[0]
return magic == 0xC5110001
return False
except (socket.timeout, OSError):
return False
finally:
sock.close()
# ---------------------------------------------------------------------------
# Signal field generator
# ---------------------------------------------------------------------------
def generate_signal_field(
features: RssiFeatures,
result: SensingResult,
grid_size: int = SIGNAL_FIELD_GRID,
csi_data: Optional[Dict] = None,
) -> Dict:
"""
Generate a 2-D signal-strength field for the Gaussian splat visualization.
When real CSI amplitude data is available, it modulates the field.
"""
field = np.zeros((grid_size, grid_size), dtype=np.float64)
# Base noise floor
rng = np.random.default_rng(int(abs(features.mean * 100)) % (2**31))
field += rng.uniform(0.02, 0.08, size=(grid_size, grid_size))
cx, cy = grid_size // 2, grid_size // 2
# Radial attenuation from router
for y in range(grid_size):
for x in range(grid_size):
dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
attenuation = max(0.0, 1.0 - dist / (grid_size * 0.7))
field[y, x] += attenuation * 0.3
# If we have real CSI subcarrier amplitudes, paint them along one axis
if csi_data and csi_data.get("amplitude"):
amps = np.array(csi_data["amplitude"][:grid_size], dtype=np.float64)
if len(amps) > 0:
max_a = np.max(amps) if np.max(amps) > 0 else 1.0
norm_amps = amps / max_a
# Spread subcarrier energy as vertical stripes
for ix, a in enumerate(norm_amps):
col = int(ix * grid_size / len(norm_amps))
col = min(col, grid_size - 1)
field[:, col] += a * 0.4
if result.presence_detected:
body_x = cx + int(3 * math.sin(time.time() * 0.2))
body_y = cy + int(2 * math.cos(time.time() * 0.15))
sigma = 2.0 + features.variance * 0.5
for y in range(grid_size):
for x in range(grid_size):
dx = x - body_x
dy = y - body_y
blob = math.exp(-(dx * dx + dy * dy) / (2.0 * sigma * sigma))
intensity = 0.3 + 0.7 * min(1.0, features.motion_band_power * 5)
field[y, x] += blob * intensity
if features.breathing_band_power > 0.01:
breath_phase = math.sin(2 * math.pi * 0.3 * time.time())
breath_radius = 3.0 + breath_phase * 0.8
for y in range(grid_size):
for x in range(grid_size):
dist_body = math.sqrt((x - body_x) ** 2 + (y - body_y) ** 2)
ring = math.exp(-((dist_body - breath_radius) ** 2) / 1.5)
field[y, x] += ring * features.breathing_band_power * 2
field = np.clip(field, 0.0, 1.0)
return {
"grid_size": [grid_size, 1, grid_size],
"values": field.flatten().tolist(),
}
# ---------------------------------------------------------------------------
# WebSocket server
# ---------------------------------------------------------------------------
class SensingWebSocketServer:
"""Async WebSocket server that broadcasts sensing updates."""
def __init__(self) -> None:
self.clients: Set = set()
self.collector = None
self.extractor = RssiFeatureExtractor(window_seconds=10.0)
self.classifier = PresenceClassifier()
self.source: str = "unknown"
self._running = False
def _create_collector(self):
"""Auto-detect data source: ESP32 UDP > platform WiFi > simulated.
Uses the ``create_collector`` factory (ADR-049) for platform WiFi
detection, which never raises and logs actionable fallback messages.
"""
from .rssi_collector import create_collector
# 1. Try ESP32 UDP first
print(" Probing for ESP32 on UDP :5005 ...")
if probe_esp32_udp(ESP32_UDP_PORT, timeout=2.0):
logger.info("ESP32 CSI stream detected on UDP :%d", ESP32_UDP_PORT)
self.source = "esp32"
return Esp32UdpCollector(port=ESP32_UDP_PORT, sample_rate_hz=10.0)
# 2. Platform-specific WiFi (auto-detect with graceful fallback)
collector = create_collector(preferred="auto", sample_rate_hz=10.0)
# Map collector class to source label
source_map = {
"LinuxWifiCollector": "linux_wifi",
"WindowsWifiCollector": "windows_wifi",
"MacosWifiCollector": "macos_wifi",
"SimulatedCollector": "simulated",
}
self.source = source_map.get(type(collector).__name__, "unknown")
return collector
def _build_message(self, features: RssiFeatures, result: SensingResult) -> str:
"""Build the JSON message to broadcast."""
# Get CSI-specific data if available
csi_data = None
if isinstance(self.collector, Esp32UdpCollector):
csi_data = self.collector.last_csi
signal_field = generate_signal_field(features, result, csi_data=csi_data)
node_info = {
"node_id": 1,
"rssi_dbm": features.mean,
"position": [2.0, 0.0, 1.5],
"amplitude": [],
"subcarrier_count": 0,
}
# Enrich with real CSI data
if csi_data:
node_info["node_id"] = csi_data.get("node_id", 1)
node_info["rssi_dbm"] = csi_data.get("rssi_dbm", features.mean)
node_info["amplitude"] = csi_data.get("amplitude", [])
node_info["subcarrier_count"] = csi_data.get("n_subcarriers", 0)
node_info["mean_amplitude"] = csi_data.get("mean_amplitude", 0)
node_info["freq_mhz"] = csi_data.get("freq_mhz", 0)
node_info["sequence"] = csi_data.get("sequence", 0)
node_info["source_addr"] = csi_data.get("source_addr", "")
msg = {
"type": "sensing_update",
"timestamp": time.time(),
"source": self.source,
"nodes": [node_info],
"features": {
"mean_rssi": features.mean,
"variance": features.variance,
"std": features.std,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"dominant_freq_hz": features.dominant_freq_hz,
"change_points": features.n_change_points,
"spectral_power": features.total_spectral_power,
"range": features.range,
"iqr": features.iqr,
"skewness": features.skewness,
"kurtosis": features.kurtosis,
},
"classification": {
"motion_level": result.motion_level.value,
"presence": result.presence_detected,
"confidence": round(result.confidence, 3),
},
"signal_field": signal_field,
}
return json.dumps(msg)
async def _handler(self, websocket):
"""Handle a single WebSocket client connection."""
self.clients.add(websocket)
remote = websocket.remote_address
logger.info("Client connected: %s", remote)
try:
async for _ in websocket:
pass
finally:
self.clients.discard(websocket)
logger.info("Client disconnected: %s", remote)
async def _broadcast(self, message: str) -> None:
"""Send message to all connected clients."""
if not self.clients:
return
disconnected = set()
for ws in self.clients:
try:
await ws.send(message)
except Exception:
disconnected.add(ws)
self.clients -= disconnected
async def _tick_loop(self) -> None:
"""Main sensing loop."""
while self._running:
try:
window = self.extractor.window_seconds
sample_rate = self.collector.sample_rate_hz
n_needed = int(window * sample_rate)
samples = self.collector.get_samples(n=n_needed)
if len(samples) >= 4:
features = self.extractor.extract(samples)
result = self.classifier.classify(features)
message = self._build_message(features, result)
await self._broadcast(message)
# Print status every few ticks
if isinstance(self.collector, Esp32UdpCollector):
csi = self.collector.last_csi
if csi and self.collector.frames_received % 20 == 0:
print(
f" [{csi['source_addr']}] node:{csi['node_id']} "
f"seq:{csi['sequence']} sc:{csi['n_subcarriers']} "
f"rssi:{csi['rssi_dbm']}dBm amp:{csi['mean_amplitude']:.1f} "
f"=> {result.motion_level.value} ({result.confidence:.0%})"
)
else:
logger.debug("Waiting for samples (%d/%d)", len(samples), n_needed)
except Exception:
logger.exception("Error in sensing tick")
await asyncio.sleep(TICK_INTERVAL)
async def run(self) -> None:
"""Start the server and run until interrupted."""
try:
import websockets
except ImportError:
print("ERROR: 'websockets' package not found.")
print("Install it with: pip install websockets")
sys.exit(1)
self.collector = self._create_collector()
self.collector.start()
self._running = True
print(f"\n Sensing WebSocket server on ws://{HOST}:{PORT}")
print(f" Source: {self.source}")
print(f" Tick: {TICK_INTERVAL}s | Window: {self.extractor.window_seconds}s")
print(" Press Ctrl+C to stop\n")
async with websockets.serve(self._handler, HOST, PORT):
await self._tick_loop()
def stop(self) -> None:
"""Stop the server gracefully."""
self._running = False
if self.collector:
self.collector.stop()
logger.info("Sensing server stopped")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
server = SensingWebSocketServer()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def _shutdown(sig, frame):
print("\nShutting down...")
server.stop()
loop.stop()
signal.signal(signal.SIGINT, _shutdown)
try:
loop.run_until_complete(server.run())
except KeyboardInterrupt:
pass
finally:
server.stop()
loop.close()
if __name__ == "__main__":
main()