Back to Ruview

ADR-013: Feature-Level Sensing on Commodity Gear (Option 3)

docs/adr/ADR-013-feature-level-sensing-commodity-gear.md

0.7.020.2 KB
Original Source

ADR-013: Feature-Level Sensing on Commodity Gear (Option 3)

Status

Accepted — Implemented (36/36 unit tests pass, see v1/src/sensing/ and v1/tests/unit/test_sensing.py)

Date

2026-02-28

Context

Not Everyone Can Deploy Custom Hardware

ADR-012 specifies an ESP32 CSI mesh that provides real CSI data. However, it requires:

  • Purchasing ESP32 boards
  • Flashing custom firmware
  • ESP-IDF toolchain installation
  • Physical placement of nodes

For many users - especially those evaluating WiFi-DensePose or deploying in managed environments - modifying hardware is not an option. We need a sensing path that works with existing, unmodified consumer WiFi gear.

What Commodity Hardware Exposes

Standard WiFi drivers and tools expose several metrics without custom firmware:

SignalSourceAvailabilitySampling Rate
RSSI (Received Signal Strength)iwconfig, iw, NetworkManagerUniversal1-10 Hz
Noise flooriw dev wlan0 survey dumpMost Linux drivers~1 Hz
Link quality/proc/net/wirelessLinux1-10 Hz
MCS index / PHY rateiw dev wlan0 linkMost driversPer-packet
TX/RX bytes/sys/class/net/wlan0/statistics/UniversalContinuous
Retry countiw dev wlan0 station dumpMost drivers~1 Hz
Beacon interval timingiw dev wlan0 scan dumpUniversalPer-scan
Channel utilizationiw dev wlan0 survey dumpMost drivers~1 Hz

RSSI is the primary signal. It varies when humans move through the propagation path between any transmitter-receiver pair. Research confirms RSSI-based sensing for:

  • Presence detection (single receiver, threshold on variance)
  • Device-free motion detection (RSSI variance increases with movement)
  • Coarse room-level localization (multi-receiver RSSI fingerprinting)
  • Breathing detection (specialized setups, marginal quality)

Research Support

  • RSSI-based presence: Youssef et al. (2007) demonstrated device-free passive detection using RSSI from multiple receivers with >90% accuracy.
  • RSSI breathing: Abdelnasser et al. (2015) showed respiration detection via RSSI variance in controlled settings with ~85% accuracy using 4+ receivers.
  • Device-free tracking: Multiple receivers with RSSI fingerprinting achieve room-level (3-5m) accuracy.

Decision

We will implement a Feature-Level Sensing module that extracts motion, presence, and coarse activity information from standard WiFi metrics available on any Linux machine without hardware modification.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│              Feature-Level Sensing Pipeline                           │
├──────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  Data Sources (any Linux WiFi device):                               │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐              │
│  │ RSSI    │ │ Noise   │ │ Link    │ │ Packet Stats │              │
│  │ Stream  │ │ Floor   │ │ Quality │ │ (TX/RX/Retry)│              │
│  └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘              │
│       │           │           │              │                       │
│       └───────────┴───────────┴──────────────┘                       │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │           Feature Extraction Engine             │                  │
│  │                                                 │                  │
│  │  1. Rolling statistics (mean, var, skew, kurt)  │                  │
│  │  2. Spectral features (FFT of RSSI time series) │                  │
│  │  3. Change-point detection (CUSUM, PELT)        │                  │
│  │  4. Cross-receiver correlation                   │                  │
│  │  5. Packet timing jitter analysis               │                  │
│  └────────────────────────┬───────────────────────┘                  │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │          Classification / Decision              │                  │
│  │                                                 │                  │
│  │  • Presence: RSSI variance > threshold          │                  │
│  │  • Motion class: spectral peak frequency        │                  │
│  │  • Occupancy change: change-point event         │                  │
│  │  • Confidence: cross-receiver agreement         │                  │
│  └────────────────────────┬───────────────────────┘                  │
│                           │                                          │
│                           ▼                                          │
│  ┌────────────────────────────────────────────────┐                  │
│  │         Output: Presence/Motion Events          │                  │
│  │                                                 │                  │
│  │  { "timestamp": "...",                          │                  │
│  │    "presence": true,                            │                  │
│  │    "motion_level": "active",                    │                  │
│  │    "confidence": 0.87,                          │                  │
│  │    "receivers_agreeing": 3,                     │                  │
│  │    "rssi_variance": 4.2 }                       │                  │
│  └────────────────────────────────────────────────┘                  │
└──────────────────────────────────────────────────────────────────────┘

Feature Extraction Specification

python
class RssiFeatureExtractor:
    """Extract sensing features from RSSI and link statistics.

    No custom hardware required. Works with any WiFi interface
    that exposes standard Linux wireless statistics.
    """

    def __init__(self, config: FeatureSensingConfig):
        self.window_size = config.window_size  # 30 seconds
        self.sampling_rate = config.sampling_rate  # 10 Hz
        self.rssi_buffer = deque(maxlen=self.window_size * self.sampling_rate)
        self.noise_buffer = deque(maxlen=self.window_size * self.sampling_rate)

    def extract_features(self) -> FeatureVector:
        rssi_array = np.array(self.rssi_buffer)

        return FeatureVector(
            # Time-domain statistics
            rssi_mean=np.mean(rssi_array),
            rssi_variance=np.var(rssi_array),
            rssi_skewness=scipy.stats.skew(rssi_array),
            rssi_kurtosis=scipy.stats.kurtosis(rssi_array),
            rssi_range=np.ptp(rssi_array),
            rssi_iqr=np.subtract(*np.percentile(rssi_array, [75, 25])),

            # Spectral features (FFT of RSSI time series)
            spectral_energy=self._spectral_energy(rssi_array),
            dominant_frequency=self._dominant_freq(rssi_array),
            breathing_band_power=self._band_power(rssi_array, 0.1, 0.5),  # Hz
            motion_band_power=self._band_power(rssi_array, 0.5, 3.0),    # Hz

            # Change-point features
            num_change_points=self._cusum_changes(rssi_array),
            max_step_magnitude=self._max_step(rssi_array),

            # Noise floor features (environment stability)
            noise_mean=np.mean(np.array(self.noise_buffer)),
            snr_estimate=np.mean(rssi_array) - np.mean(np.array(self.noise_buffer)),
        )

    def _spectral_energy(self, rssi: np.ndarray) -> float:
        """Total spectral energy excluding DC component."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
        return float(np.sum(spectrum[1:] ** 2))

    def _dominant_freq(self, rssi: np.ndarray) -> float:
        """Dominant frequency in RSSI time series."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
        freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
        return float(freqs[np.argmax(spectrum[1:]) + 1])

    def _band_power(self, rssi: np.ndarray, low_hz: float, high_hz: float) -> float:
        """Power in a specific frequency band."""
        spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi))) ** 2
        freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
        mask = (freqs >= low_hz) & (freqs <= high_hz)
        return float(np.sum(spectrum[mask]))

    def _cusum_changes(self, rssi: np.ndarray) -> int:
        """Count change points using CUSUM algorithm."""
        mean = np.mean(rssi)
        cusum_pos = np.zeros_like(rssi)
        cusum_neg = np.zeros_like(rssi)
        threshold = 3.0 * np.std(rssi)
        changes = 0
        for i in range(1, len(rssi)):
            cusum_pos[i] = max(0, cusum_pos[i-1] + rssi[i] - mean - 0.5)
            cusum_neg[i] = max(0, cusum_neg[i-1] - rssi[i] + mean - 0.5)
            if cusum_pos[i] > threshold or cusum_neg[i] > threshold:
                changes += 1
                cusum_pos[i] = 0
                cusum_neg[i] = 0
        return changes

Data Collection (No Root Required)

python
class LinuxWifiCollector:
    """Collect WiFi statistics from standard Linux interfaces.

    No root required for most operations.
    No custom drivers or firmware.
    Works with NetworkManager, wpa_supplicant, or raw iw.
    """

    def __init__(self, interface: str = "wlan0"):
        self.interface = interface

    def get_rssi(self) -> float:
        """Get current RSSI from connected AP."""
        # Method 1: /proc/net/wireless (no root)
        with open("/proc/net/wireless") as f:
            for line in f:
                if self.interface in line:
                    parts = line.split()
                    return float(parts[3].rstrip('.'))

        # Method 2: iw (no root for own station)
        result = subprocess.run(
            ["iw", "dev", self.interface, "link"],
            capture_output=True, text=True
        )
        for line in result.stdout.split('\n'):
            if 'signal:' in line:
                return float(line.split(':')[1].strip().split()[0])

        raise SensingError(f"Cannot read RSSI from {self.interface}")

    def get_noise_floor(self) -> float:
        """Get noise floor estimate."""
        result = subprocess.run(
            ["iw", "dev", self.interface, "survey", "dump"],
            capture_output=True, text=True
        )
        for line in result.stdout.split('\n'):
            if 'noise:' in line:
                return float(line.split(':')[1].strip().split()[0])
        return -95.0  # Default noise floor estimate

    def get_link_stats(self) -> dict:
        """Get link quality statistics."""
        result = subprocess.run(
            ["iw", "dev", self.interface, "station", "dump"],
            capture_output=True, text=True
        )
        stats = {}
        for line in result.stdout.split('\n'):
            if 'tx bytes:' in line:
                stats['tx_bytes'] = int(line.split(':')[1].strip())
            elif 'rx bytes:' in line:
                stats['rx_bytes'] = int(line.split(':')[1].strip())
            elif 'tx retries:' in line:
                stats['tx_retries'] = int(line.split(':')[1].strip())
            elif 'signal:' in line:
                stats['signal'] = float(line.split(':')[1].strip().split()[0])
        return stats

Classification Rules

python
class PresenceClassifier:
    """Rule-based presence and motion classifier.

    Uses simple, interpretable rules rather than ML to ensure
    transparency and debuggability.
    """

    def __init__(self, config: ClassifierConfig):
        self.variance_threshold = config.variance_threshold  # 2.0 dBm²
        self.motion_threshold = config.motion_threshold      # 5.0 dBm²
        self.spectral_threshold = config.spectral_threshold  # 10.0
        self.confidence_min_receivers = config.min_receivers  # 2

    def classify(self, features: FeatureVector,
                 multi_receiver: list[FeatureVector] = None) -> SensingResult:

        # Presence: RSSI variance exceeds empty-room baseline
        presence = features.rssi_variance > self.variance_threshold

        # Motion level
        if features.rssi_variance > self.motion_threshold:
            motion = MotionLevel.ACTIVE
        elif features.rssi_variance > self.variance_threshold:
            motion = MotionLevel.PRESENT_STILL
        else:
            motion = MotionLevel.ABSENT

        # Confidence from spectral energy and receiver agreement
        spectral_conf = min(1.0, features.spectral_energy / self.spectral_threshold)
        if multi_receiver:
            agreeing = sum(1 for f in multi_receiver
                          if (f.rssi_variance > self.variance_threshold) == presence)
            receiver_conf = agreeing / len(multi_receiver)
        else:
            receiver_conf = 0.5  # Single receiver = lower confidence

        confidence = 0.6 * spectral_conf + 0.4 * receiver_conf

        return SensingResult(
            presence=presence,
            motion_level=motion,
            confidence=confidence,
            dominant_frequency=features.dominant_frequency,
            breathing_band_power=features.breathing_band_power,
        )

Capability Matrix (Honest Assessment)

CapabilitySingle Receiver3 Receivers6 ReceiversAccuracy
Binary presenceYesYesYes90-95%
Coarse motion (still/moving)YesYesYes85-90%
Room-level locationNoMarginalYes70-80%
Person countNoMarginalMarginal50-70%
Activity class (walk/sit/stand)MarginalMarginalYes60-75%
Respiration detectionNoMarginalMarginal40-60%
HeartbeatNoNoNoN/A
Body poseNoNoNoN/A

Bottom line: Feature-level sensing on commodity gear does presence and motion well. It does NOT do pose estimation, heartbeat, or reliable respiration. Any claim otherwise would be dishonest.

Decision Matrix: Option 2 (ESP32) vs Option 3 (Commodity)

FactorESP32 CSI (ADR-012)Commodity (ADR-013)
Headline capabilityRespiration + motionPresence + coarse motion
Hardware cost$54 (3-node kit)$0 (existing gear)
Setup time2-4 hours15 minutes
Technical barrierMedium (firmware flash)Low (pip install)
Data qualityReal CSI (amplitude + phase)RSSI only
Multi-personMarginalPoor
Pose estimationMarginalNo
ReproducibilityHigh (controlled hardware)Medium (varies by hardware)
Public credibilityHigh (real CSI artifact)Medium (RSSI is "obvious")

Proof Bundle for Commodity Sensing

v1/data/proof/commodity/
├── rssi_capture_30sec.json         # 30 seconds of RSSI from 3 receivers
├── rssi_capture_meta.json          # Hardware: Intel AX200, Router: TP-Link AX1800
├── scenario.txt                    # "Person walks through room at t=10s, sits at t=20s"
├── expected_features.json          # Feature extraction output
├── expected_classification.json    # Classification output
├── expected_features.sha256        # Verification hash
└── verify_commodity.py             # One-command verification

Integration with WiFi-DensePose Pipeline

The commodity sensing module outputs the same SensingResult type as the CSI pipeline, allowing graceful degradation:

python
class SensingBackend(Protocol):
    """Common interface for all sensing backends."""

    def get_features(self) -> FeatureVector: ...
    def get_capabilities(self) -> set[Capability]: ...

class CsiBackend(SensingBackend):
    """Full CSI pipeline (ESP32 or research NIC)."""
    def get_capabilities(self):
        return {Capability.PRESENCE, Capability.MOTION, Capability.RESPIRATION,
                Capability.LOCATION, Capability.POSE}

class CommodityBackend(SensingBackend):
    """RSSI-only commodity hardware."""
    def get_capabilities(self):
        return {Capability.PRESENCE, Capability.MOTION}

Consequences

Positive

  • Zero-cost entry: Works with existing WiFi hardware
  • 15-minute setup: pip install wifi-densepose && wdp sense --interface wlan0
  • Broad adoption: Any Linux laptop, Pi, or phone can participate
  • Honest capability reporting: get_capabilities() tells users exactly what works
  • Complements ESP32: Users start with commodity, upgrade to ESP32 for more capability
  • No mock data: Real RSSI from real hardware, deterministic pipeline

Negative

  • Limited capability: No pose, no heartbeat, marginal respiration
  • Hardware variability: RSSI calibration differs across chipsets
  • Environmental sensitivity: Commodity RSSI is more affected by interference than CSI
  • Not a "pose estimation" demo: This module honestly cannot do what the project name implies
  • Lower credibility ceiling: RSSI sensing is well-known; less impressive than CSI

Implementation Status

The full commodity sensing pipeline is implemented in v1/src/sensing/:

ModuleFileDescription
RSSI Collectorrssi_collector.pyLinuxWifiCollector (live hardware) + SimulatedCollector (deterministic testing) with ring buffer
Feature Extractorfeature_extractor.pyRssiFeatureExtractor with Hann-windowed FFT, band power (breathing 0.1-0.5 Hz, motion 0.5-3 Hz), CUSUM change-point detection
Classifierclassifier.pyPresenceClassifier with ABSENT/PRESENT_STILL/ACTIVE levels, confidence scoring
Backendbackend.pyCommodityBackend wiring collector → extractor → classifier, reports PRESENCE + MOTION capabilities

Test coverage: 36 tests in v1/tests/unit/test_sensing.py — all passing:

  • TestRingBuffer (4), TestSimulatedCollector (5), TestFeatureExtractor (8), TestCusum (4), TestPresenceClassifier (7), TestCommodityBackend (6), TestBandPower (2)

Dependencies: numpy, scipy (for FFT and spectral analysis)

Note: LinuxWifiCollector requires a connected Linux WiFi interface (/proc/net/wireless or iw). On Windows or disconnected interfaces, use SimulatedCollector for development and testing.

References