mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
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:
@@ -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",
|
||||
]
|
||||
@@ -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}])"
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user