mirror of
https://github.com/ruvnet/RuView
synced 2026-06-10 10:23:19 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5402b070f6 | |||
| 7659b0bbe2 | |||
| 75d4685d25 | |||
| 45c15b77a5 | |||
| 47223a98be |
@@ -0,0 +1,122 @@
|
||||
# ADR-049: Cross-Platform WiFi Interface Detection and Graceful Degradation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Proposed |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-013 (Feature-Level Sensing), ADR-025 (macOS CoreWLAN) |
|
||||
| Issue | [#148](https://github.com/ruvnet/wifi-densepose/issues/148) |
|
||||
|
||||
## Context
|
||||
|
||||
Users report `RuntimeError: Cannot read /proc/net/wireless` when running WiFi DensePose in environments where the Linux wireless proc filesystem is unavailable:
|
||||
|
||||
- **Docker containers** on macOS/Windows (Linux kernel detected, but no wireless subsystem)
|
||||
- **WSL2** without USB WiFi passthrough
|
||||
- **Headless Linux servers** without WiFi hardware
|
||||
- **Embedded Linux** boards without wireless-extensions support
|
||||
|
||||
The current architecture has two layers of defense:
|
||||
|
||||
1. **`ws_server.py`** (line 345-355) checks `os.path.exists("/proc/net/wireless")` before instantiating `LinuxWifiCollector` and falls back to `SimulatedCollector` if missing.
|
||||
2. **`rssi_collector.py`** `LinuxWifiCollector._validate_interface()` (line 178-196) raises a hard `RuntimeError` if `/proc/net/wireless` is missing or the interface isn't listed.
|
||||
|
||||
However, there are gaps:
|
||||
|
||||
- **Direct usage**: Any code that instantiates `LinuxWifiCollector` directly (outside `ws_server.py`) hits the unguarded `RuntimeError` with no fallback.
|
||||
- **Error message**: The RuntimeError message tells users to "use SimulatedCollector instead" but doesn't explain how.
|
||||
- **No auto-detection**: The collector selection logic is duplicated between `ws_server.py` and `install.sh` with no shared platform-detection utility.
|
||||
- **Partial `/proc/net/wireless`**: The file may exist (e.g., kernel module loaded) but contain no interfaces, producing a confusing "interface not found" error instead of a clean fallback.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Platform-Aware Collector Factory
|
||||
|
||||
Introduce a `create_collector()` factory function in `rssi_collector.py` that encapsulates the platform detection and fallback chain:
|
||||
|
||||
```python
|
||||
def create_collector(
|
||||
preferred: str = "auto",
|
||||
interface: str = "wlan0",
|
||||
sample_rate_hz: float = 10.0,
|
||||
) -> BaseCollector:
|
||||
"""
|
||||
Create the best available WiFi collector for the current platform.
|
||||
|
||||
Resolution order (when preferred="auto"):
|
||||
1. ESP32 CSI (if UDP port 5005 is receiving frames)
|
||||
2. Platform-native WiFi:
|
||||
- Linux: LinuxWifiCollector (requires /proc/net/wireless + active interface)
|
||||
- Windows: WindowsWifiCollector (netsh wlan)
|
||||
- macOS: MacosWifiCollector (CoreWLAN)
|
||||
3. SimulatedCollector (always available)
|
||||
|
||||
Raises nothing — always returns a usable collector.
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. Soft Validation in LinuxWifiCollector
|
||||
|
||||
Replace the hard `RuntimeError` in `_validate_interface()` with a class method that returns availability status without raising:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def is_available(cls, interface: str = "wlan0") -> tuple[bool, str]:
|
||||
"""Check if Linux WiFi collection is possible. Returns (available, reason)."""
|
||||
if not os.path.exists("/proc/net/wireless"):
|
||||
return False, "/proc/net/wireless not found (Docker, WSL, or no wireless subsystem)"
|
||||
with open("/proc/net/wireless") as f:
|
||||
content = f.read()
|
||||
if interface not in content:
|
||||
names = cls._parse_interface_names(content)
|
||||
return False, f"Interface '{interface}' not in /proc/net/wireless. Available: {names}"
|
||||
return True, "ok"
|
||||
```
|
||||
|
||||
The existing `_validate_interface()` continues to raise `RuntimeError` for direct callers who need fail-fast behavior, but `create_collector()` uses `is_available()` to probe without exceptions.
|
||||
|
||||
### 3. Structured Fallback Logging
|
||||
|
||||
When auto-detection skips a collector, log at `WARNING` level with actionable context:
|
||||
|
||||
```
|
||||
WiFi collector: LinuxWifiCollector unavailable (/proc/net/wireless not found — likely Docker/WSL).
|
||||
WiFi collector: Falling back to SimulatedCollector. For real sensing, connect ESP32 nodes via UDP:5005.
|
||||
```
|
||||
|
||||
### 4. Consolidate Platform Detection
|
||||
|
||||
Remove duplicated platform-detection logic from `ws_server.py` and `install.sh`. Both should use `create_collector()` (Python) or a shared `detect_wifi_platform()` shell function.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero-crash startup**: `create_collector("auto")` never raises — Docker, WSL, and headless users get `SimulatedCollector` automatically with a clear log message.
|
||||
- **Single detection path**: Platform logic lives in one place (`rssi_collector.py`), reducing drift between `ws_server.py`, `install.sh`, and future entry points.
|
||||
- **Better DX**: Error messages explain *why* a collector is unavailable and *what to do* (connect ESP32, install WiFi driver, etc.).
|
||||
|
||||
### Negative
|
||||
|
||||
- **SimulatedCollector may mask hardware issues**: Users with real WiFi hardware that fails detection might unknowingly run on simulated data. Mitigated by the `WARNING`-level log.
|
||||
- **Breaking change for direct `LinuxWifiCollector` callers**: Code that catches `RuntimeError` from `_validate_interface()` as a signal needs to migrate to `is_available()` or `create_collector()`. This is a minor change — there are no known external consumers.
|
||||
|
||||
### Neutral
|
||||
|
||||
- `_validate_interface()` behavior is unchanged for existing direct callers — this is additive.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Add `create_collector()` and `BaseCollector.is_available()` to `v1/src/sensing/rssi_collector.py`
|
||||
2. Refactor `ws_server.py` `_init_collector()` to call `create_collector()`
|
||||
3. Update `install.sh` `detect_wifi_hardware()` to use shared detection logic
|
||||
4. Add unit tests for each platform path (mock `/proc/net/wireless` presence/absence)
|
||||
5. Comment on issue #148 with the fix
|
||||
|
||||
## References
|
||||
|
||||
- Issue #148: RuntimeError: Cannot read /proc/net/wireless
|
||||
- ADR-013: Feature-Level Sensing on Commodity Gear
|
||||
- ADR-025: macOS CoreWLAN WiFi Sensing
|
||||
- [Linux /proc/net/wireless documentation](https://www.kernel.org/doc/html/latest/networking/statistics.html)
|
||||
@@ -0,0 +1,100 @@
|
||||
# ADR-050: Quality Engineering Response — Security Hardening & Code Quality
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-032 (Multistatic Mesh Security) |
|
||||
| Issue | [#170](https://github.com/ruvnet/wifi-densepose/issues/170) |
|
||||
|
||||
## Context
|
||||
|
||||
An independent quality engineering analysis ([issue #170](https://github.com/ruvnet/wifi-densepose/issues/170)) identified 7 critical findings across the Rust codebase. After verification against the source code, the following findings are confirmed and require action:
|
||||
|
||||
### Confirmed Critical Findings
|
||||
|
||||
| # | Finding | Location | Verified |
|
||||
|---|---------|----------|----------|
|
||||
| 1 | Fake HMAC in `secure_tdm.rs` — XOR fold with hardcoded key | `hardware/src/esp32/secure_tdm.rs:253` | YES — comments say "sufficient for testing" |
|
||||
| 2 | `sensing-server/main.rs` is 3,741 lines — CC=65, god object | `sensing-server/src/main.rs` | YES — confirmed 3,741 lines |
|
||||
| 3 | WebSocket server has zero authentication | Rust WS codebase | YES — no auth/token checks found |
|
||||
| 4 | Zero security tests in Rust codebase | Entire workspace | YES — no auth/injection/tampering tests |
|
||||
| 5 | 54K fps claim has no supporting benchmark | No criterion benchmarks | YES — no benchmarks exist |
|
||||
|
||||
### Findings Requiring Further Investigation
|
||||
|
||||
| # | Finding | Status |
|
||||
|---|---------|--------|
|
||||
| 6 | Unauthenticated OTA firmware endpoint | Not found in Rust code — may be ESP32 C firmware level |
|
||||
| 7 | WASM upload without mandatory signatures | Needs review of WASM loader |
|
||||
| 8 | O(n^2) autocorrelation in heart rate detection | Needs profiling to confirm impact |
|
||||
|
||||
## Decision
|
||||
|
||||
Address findings in 3 priority sprints as recommended by the report.
|
||||
|
||||
### Sprint 1: Security (Blocks Deployment)
|
||||
|
||||
1. **Replace fake HMAC with real HMAC-SHA256** in `secure_tdm.rs`
|
||||
- Use the `hmac` + `sha2` crates (already in `Cargo.lock`)
|
||||
- Remove XOR fold implementation
|
||||
- Add key derivation (no more hardcoded keys)
|
||||
|
||||
2. **Add WebSocket authentication**
|
||||
- Token-based auth on WS upgrade handshake
|
||||
- Optional API key for local-network deployments
|
||||
- Configurable via environment variable
|
||||
|
||||
3. **Add security test suite**
|
||||
- Auth bypass attempts
|
||||
- Malformed CSI frame injection
|
||||
- Protocol tampering (TDM beacon replay, nonce reuse)
|
||||
|
||||
### Sprint 2: Code Quality & Testability
|
||||
|
||||
4. **Decompose `main.rs`** (3,741 lines -> ~14 focused modules)
|
||||
- Extract HTTP routes, WebSocket handler, CSI pipeline, config, state
|
||||
- Target: no file over 500 lines
|
||||
|
||||
5. **Add criterion benchmarks**
|
||||
- CSI frame parsing throughput
|
||||
- Signal processing pipeline latency
|
||||
- WebSocket broadcast fanout
|
||||
|
||||
### Sprint 3: Functional Verification
|
||||
|
||||
6. **Vital sign accuracy verification**
|
||||
- Reference signal tests with known BPM
|
||||
- False-negative rate measurement
|
||||
|
||||
7. **Fix O(n^2) autocorrelation** (if confirmed by profiling)
|
||||
- Replace brute-force lag with FFT-based autocorrelation
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Addresses all critical security findings before any production deployment
|
||||
- `main.rs` decomposition enables unit testing of server components
|
||||
- Criterion benchmarks provide verifiable performance claims
|
||||
- Security test suite prevents regression
|
||||
|
||||
### Negative
|
||||
|
||||
- Sprint 1 security changes are breaking for any existing TDM mesh deployments (fake HMAC -> real HMAC requires firmware update)
|
||||
- `main.rs` decomposition is a large refactor with merge conflict risk
|
||||
|
||||
### Neutral
|
||||
|
||||
- The report correctly identifies that life-safety claims (disaster detection, vital signs) require rigorous verification — this is an ongoing process, not a single sprint
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
Thanks to [@proffesor-for-testing](https://github.com/proffesor-for-testing) for the thorough 10-report analysis. The full report is archived at the [original gist](https://gist.github.com/proffesor-for-testing/02321e3f272720aa94484fffec6ab19b).
|
||||
|
||||
## References
|
||||
|
||||
- Issue #170: Quality Engineering Analysis
|
||||
- ADR-032: Multistatic Mesh Security Hardening
|
||||
- ADR-028: ESP32 Capability Audit
|
||||
@@ -0,0 +1,109 @@
|
||||
# ADR-051: Sensing Server Decomposition — main.rs God Object Breakup
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Proposed |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-050 (Quality Engineering — Sprint 2) |
|
||||
| Issue | [#174](https://github.com/ruvnet/RuView/issues/174) |
|
||||
|
||||
## Context
|
||||
|
||||
`sensing-server/src/main.rs` is 3,765 lines with cyclomatic complexity ~65. It contains 12 structs, 60+ functions, 10 constants, and a 37-field `AppStateInner` god object. This violates the project's 500-line file limit (CLAUDE.md) and makes unit testing individual components impossible.
|
||||
|
||||
The file mixes concerns:
|
||||
- CLI argument parsing and server bootstrap
|
||||
- HTTP route handlers (health, models, recordings, training, pose, vitals)
|
||||
- WebSocket upgrade and client management
|
||||
- UDP CSI frame receiver and parser
|
||||
- Signal processing pipeline (feature extraction, classification, smoothing)
|
||||
- Simulated data generator
|
||||
- Windows WiFi scanning integration
|
||||
- Pose estimation from WiFi signals
|
||||
- Vital sign smoothing and filtering
|
||||
- Model/recording file management
|
||||
|
||||
## Decision
|
||||
|
||||
Decompose `main.rs` into 14 focused modules. Each module owns its types, constants, and functions. `main.rs` retains only CLI parsing, state initialization, router construction, and server startup (~250 lines).
|
||||
|
||||
### Module Extraction Plan
|
||||
|
||||
| Module | Source Lines | Contents | Target Size |
|
||||
|--------|-------------|----------|-------------|
|
||||
| `cli.rs` | 59-152 | `Args` struct, CLI parsing | ~100 |
|
||||
| `state.rs` | 154-370 | `AppStateInner`, all DTOs (`Esp32Frame`, `SensingUpdate`, `NodeInfo`, etc.), `SharedState` type alias | ~220 |
|
||||
| `signal.rs` | 542-890 | `generate_signal_field()`, `estimate_breathing_rate_hz()`, `compute_subcarrier_variances()`, `extract_features_from_frame()`, `raw_classify()` | ~350 |
|
||||
| `smoothing.rs` | 886-1060 | Classification smoothing, vital sign smoothing, `trimmed_mean()`, constants | ~180 |
|
||||
| `routes_health.rs` | 1660-2005 | `/health/*`, `/api/v1/info` endpoints | ~350 |
|
||||
| `routes_model.rs` | 2058-2230 | `/api/v1/models/*`, LoRA profiles, `scan_model_files()` | ~180 |
|
||||
| `routes_recording.rs` | 2233-2440 | `/api/v1/recording/*`, `scan_recording_files()` | ~210 |
|
||||
| `routes_training.rs` | 2443-2560 | `/api/v1/train/*`, `/api/v1/adaptive/*` | ~120 |
|
||||
| `routes_sensing.rs` | 2562-2710 | Vital signs, edge vitals, WASM events, model info, SONA endpoints | ~150 |
|
||||
| `routes_pose.rs` | 1701-1930, 2007-2055 | Pose estimation, `derive_single_person_pose()`, pose/stats/zones endpoints | ~280 |
|
||||
| `websocket.rs` | 1492-1660 | WS upgrade handlers, `handle_ws_client()`, `handle_ws_pose_client()` | ~170 |
|
||||
| `udp_receiver.rs` | 2725-2890 | UDP CSI frame receiver task, frame parsing | ~170 |
|
||||
| `data_sources.rs` | 1063-1465, 2888-3020 | Windows WiFi task, simulated data task, `probe_windows_wifi()`, `parse_netsh_interfaces_output()` | ~400 |
|
||||
| `router.rs` | (new) | `build_router()` function assembling all routes | ~80 |
|
||||
|
||||
### Extraction Order (6 Phases)
|
||||
|
||||
1. **Phase 1**: `cli.rs` + `state.rs` — Zero behavioral change, just move types
|
||||
2. **Phase 2**: `signal.rs` + `smoothing.rs` — Pure functions, easy to test
|
||||
3. **Phase 3**: `routes_health.rs` + `routes_model.rs` + `routes_recording.rs` — Stateless-ish handlers
|
||||
4. **Phase 4**: `routes_training.rs` + `routes_sensing.rs` + `routes_pose.rs` — Remaining HTTP handlers
|
||||
5. **Phase 5**: `websocket.rs` + `udp_receiver.rs` + `data_sources.rs` — Async tasks
|
||||
6. **Phase 6**: `router.rs` — Assemble all routes, slim `main.rs` to ~250 lines
|
||||
|
||||
### State Refactoring
|
||||
|
||||
`AppStateInner` (37 fields) will be split into domain-specific sub-states:
|
||||
|
||||
```rust
|
||||
pub struct AppStateInner {
|
||||
pub config: ServerConfig, // CLI args, ports, paths
|
||||
pub sensing: SensingState, // CSI frames, features, classification
|
||||
pub vitals: VitalsState, // Vital sign buffers, smoothing state
|
||||
pub models: ModelState, // Active model, discovered models, LoRA
|
||||
pub recording: RecordingState, // Active recording, file handles
|
||||
pub training: TrainingState, // Training status, adaptive model
|
||||
pub pose: PoseState, // Person detections, pose history
|
||||
pub broadcast_tx: broadcast::Sender<SensingUpdate>,
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Each module is independently unit-testable
|
||||
- No file exceeds 500 lines
|
||||
- Domain boundaries are explicit (state sub-structs)
|
||||
- New developers can find code by domain
|
||||
- Merge conflict surface reduced (parallel module edits)
|
||||
|
||||
### Negative
|
||||
|
||||
- Large refactor with ~3,700 lines touched — high merge conflict risk
|
||||
- `pub(crate)` visibility needed for cross-module state access
|
||||
- Some functions share mutable state, requiring careful `&mut` threading
|
||||
|
||||
### Neutral
|
||||
|
||||
- No behavioral change — all endpoints, WebSocket, UDP behavior stays identical
|
||||
- Existing integration tests (if any) continue to pass unchanged
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Each phase is a separate commit for easy revert
|
||||
2. Run `cargo test` and `cargo check` after each phase
|
||||
3. Use `pub(crate)` for internal types, keep public API surface minimal
|
||||
4. Add `#[cfg(test)] mod tests` to each new module with at least smoke tests
|
||||
5. Consider adding `tower` middleware for auth (Sprint 1 remaining item) during Phase 3
|
||||
|
||||
## References
|
||||
|
||||
- ADR-050: Quality Engineering Response (Sprint 2 plan)
|
||||
- Issue #170: Quality Engineering Analysis
|
||||
- CLAUDE.md: 500-line file limit rule
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
|
||||
static const char *TAG = "ota_update";
|
||||
|
||||
@@ -24,6 +26,52 @@ static const char *TAG = "ota_update";
|
||||
/** Maximum firmware size (900 KB — matches CI binary size gate). */
|
||||
#define OTA_MAX_SIZE (900 * 1024)
|
||||
|
||||
/** NVS namespace and key for the OTA pre-shared key. */
|
||||
#define OTA_NVS_NAMESPACE "security"
|
||||
#define OTA_NVS_KEY "ota_psk"
|
||||
|
||||
/** Maximum PSK length (hex-encoded SHA-256). */
|
||||
#define OTA_PSK_MAX_LEN 65
|
||||
|
||||
/** Cached PSK loaded from NVS at init time. Empty = auth disabled. */
|
||||
static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
|
||||
|
||||
/**
|
||||
* ADR-050: Verify the Authorization header contains the correct PSK.
|
||||
* Returns true if auth is disabled (no PSK provisioned) or if the
|
||||
* Bearer token matches the stored PSK.
|
||||
*/
|
||||
static bool ota_check_auth(httpd_req_t *req)
|
||||
{
|
||||
if (s_ota_psk[0] == '\0') {
|
||||
/* No PSK provisioned — auth disabled (permissive for dev). */
|
||||
return true;
|
||||
}
|
||||
|
||||
char auth_header[128] = {0};
|
||||
if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header,
|
||||
sizeof(auth_header)) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Expect "Bearer <psk>" */
|
||||
const char *prefix = "Bearer ";
|
||||
if (strncmp(auth_header, prefix, strlen(prefix)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *token = auth_header + strlen(prefix);
|
||||
/* Constant-time comparison to prevent timing attacks. */
|
||||
size_t psk_len = strlen(s_ota_psk);
|
||||
size_t tok_len = strlen(token);
|
||||
if (psk_len != tok_len) return false;
|
||||
volatile uint8_t result = 0;
|
||||
for (size_t i = 0; i < psk_len; i++) {
|
||||
result |= (uint8_t)(s_ota_psk[i] ^ token[i]);
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /ota/status — return firmware version and partition info.
|
||||
*/
|
||||
@@ -53,6 +101,14 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
*/
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
/* ADR-050: Authenticate before accepting firmware upload. */
|
||||
if (!ota_check_auth(req)) {
|
||||
ESP_LOGW(TAG, "OTA upload rejected: authentication failed");
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Authentication required. Use: Authorization: Bearer <psk>");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
|
||||
|
||||
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
|
||||
@@ -187,6 +243,20 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(s_ota_psk);
|
||||
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
|
||||
}
|
||||
nvs_close(nvs);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
|
||||
}
|
||||
|
||||
return ota_start_server(NULL);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Verify signature if wasm_verify is enabled. */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
/* ADR-050: Verify signature (default-on; skip only if
|
||||
* CONFIG_WASM_SKIP_SIGNATURE is explicitly set for dev/lab). */
|
||||
#ifndef CONFIG_WASM_SKIP_SIGNATURE
|
||||
{
|
||||
/* Load pubkey from NVS config (set via provision.py --wasm-pubkey). */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
@@ -173,11 +174,11 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
|
||||
} else if (rvf_is_raw_wasm(buf, (uint32_t)total)) {
|
||||
/* ── Raw WASM path (dev/lab only) ── */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
#ifndef CONFIG_WASM_SKIP_SIGNATURE
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Raw WASM upload rejected (wasm_verify enabled). "
|
||||
"Use RVF container with signature.");
|
||||
"Raw WASM upload rejected (signature verification enabled). "
|
||||
"Use RVF container with signature, or set CONFIG_WASM_SKIP_SIGNATURE for dev.");
|
||||
return ESP_FAIL;
|
||||
#else
|
||||
format = "raw";
|
||||
|
||||
Generated
+2
@@ -4579,10 +4579,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"criterion",
|
||||
"hmac",
|
||||
"midstreamer-quic",
|
||||
"midstreamer-scheduler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -101,7 +101,7 @@ csv = "1.3"
|
||||
indicatif = "0.17"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
clap = { version = "4.4", features = ["derive", "env"] }
|
||||
|
||||
# Testing
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -24,6 +24,9 @@ linux-wifi = []
|
||||
[dependencies]
|
||||
# CLI argument parsing (for bin/aggregator)
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Cryptographic HMAC (ADR-050: replace fake XOR-fold HMAC)
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
# Byte parsing
|
||||
byteorder = "1.5"
|
||||
# Time
|
||||
|
||||
+103
-10
@@ -33,9 +33,13 @@ use super::quic_transport::{
|
||||
QuicTransportHandle, QuicTransportError, SecurityMode,
|
||||
};
|
||||
use super::tdm::{SyncBeacon, TdmCoordinator, TdmSchedule, TdmSlotCompleted};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,19 +249,17 @@ impl AuthenticatedBeacon {
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the expected HMAC tag for this beacon using the given key.
|
||||
/// Compute the HMAC-SHA256 tag for this beacon, truncated to 8 bytes.
|
||||
///
|
||||
/// Uses a simplified HMAC approximation for testing. In production,
|
||||
/// this calls mbedtls HMAC-SHA256 via the ESP-IDF hardware accelerator
|
||||
/// or the `sha2` crate on aggregator nodes.
|
||||
/// Uses the `hmac` + `sha2` crates for cryptographically secure
|
||||
/// message authentication (ADR-050, Sprint 1).
|
||||
pub fn compute_tag(payload_and_nonce: &[u8], key: &[u8; 16]) -> [u8; HMAC_TAG_SIZE] {
|
||||
// Simplified HMAC: XOR key into payload hash. In production, use
|
||||
// real HMAC-SHA256 from sha2 crate. This is sufficient for
|
||||
// testing the protocol structure.
|
||||
let mut mac = HmacSha256::new_from_slice(key)
|
||||
.expect("HMAC-SHA256 accepts any key length");
|
||||
mac.update(payload_and_nonce);
|
||||
let result = mac.finalize().into_bytes();
|
||||
let mut tag = [0u8; HMAC_TAG_SIZE];
|
||||
for (i, byte) in payload_and_nonce.iter().enumerate() {
|
||||
tag[i % HMAC_TAG_SIZE] ^= byte ^ key[i % 16];
|
||||
}
|
||||
tag.copy_from_slice(&result[..HMAC_TAG_SIZE]);
|
||||
tag
|
||||
}
|
||||
|
||||
@@ -975,6 +977,97 @@ mod tests {
|
||||
assert_eq!(SecLevel::Enforcing as u8, 2);
|
||||
}
|
||||
|
||||
// ---- Security tests (ADR-050) ----
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_keys_produce_different_tags() {
|
||||
let msg = b"test payload with nonce";
|
||||
let key1: [u8; 16] = [0x01; 16];
|
||||
let key2: [u8; 16] = [0x02; 16];
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(msg, &key1);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(msg, &key2);
|
||||
assert_ne!(tag1, tag2, "Different keys must produce different HMAC tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_messages_produce_different_tags() {
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(b"message one", &key);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(b"message two", &key);
|
||||
assert_ne!(tag1, tag2, "Different messages must produce different HMAC tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_is_deterministic() {
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let msg = b"determinism test";
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(msg, &key);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(msg, &key);
|
||||
assert_eq!(tag1, tag2, "Same key + message must produce identical tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails_verification() {
|
||||
let beacon = SyncBeacon {
|
||||
cycle_id: 42,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
};
|
||||
let correct_key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let wrong_key: [u8; 16] = [0xFF; 16];
|
||||
let nonce = 1u32;
|
||||
|
||||
let mut msg = [0u8; 20];
|
||||
msg[..16].copy_from_slice(&beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
|
||||
let tag = AuthenticatedBeacon::compute_tag(&msg, &correct_key);
|
||||
|
||||
let auth = AuthenticatedBeacon { beacon, nonce, hmac_tag: tag };
|
||||
assert!(auth.verify(&wrong_key).is_err(), "Wrong key must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_bit_flip_in_payload_fails_verification() {
|
||||
let beacon = SyncBeacon {
|
||||
cycle_id: 42,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
};
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let nonce = 1u32;
|
||||
|
||||
let mut msg = [0u8; 20];
|
||||
msg[..16].copy_from_slice(&beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
|
||||
let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
|
||||
|
||||
let auth = AuthenticatedBeacon { beacon, nonce, hmac_tag: tag };
|
||||
let mut wire = auth.to_bytes();
|
||||
// Flip one bit in the beacon payload
|
||||
wire[0] ^= 0x01;
|
||||
let tampered = AuthenticatedBeacon::from_bytes(&wire).unwrap();
|
||||
assert!(tampered.verify(&key).is_err(), "Single bit flip must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enforcing_mode_rejects_unauthenticated() {
|
||||
let mut cfg = manual_config();
|
||||
cfg.sec_level = SecLevel::Enforcing;
|
||||
let mut coord = SecureTdmCoordinator::new(test_schedule(), cfg).unwrap();
|
||||
|
||||
// Raw 16-byte beacon without HMAC
|
||||
let raw = SyncBeacon {
|
||||
cycle_id: 1,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
}.to_bytes();
|
||||
|
||||
assert!(coord.verify_beacon(&raw).is_err());
|
||||
}
|
||||
|
||||
// ---- Error display tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,6 +77,10 @@ struct Args {
|
||||
#[arg(long, default_value = "100")]
|
||||
tick_ms: u64,
|
||||
|
||||
/// Bind address (default 127.0.0.1; set to 0.0.0.0 for network access)
|
||||
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
|
||||
bind_addr: String,
|
||||
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
@@ -2112,7 +2116,15 @@ async fn delete_model(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let path = PathBuf::from("data/models").join(format!("{}.rvf", id));
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
if safe_id.is_empty() || safe_id != id {
|
||||
return Json(serde_json::json!({ "error": "invalid model id", "success": false }));
|
||||
}
|
||||
let path = PathBuf::from("data/models").join(format!("{}.rvf", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete model file {:?}: {}", path, e);
|
||||
@@ -2363,7 +2375,15 @@ async fn delete_recording(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", id));
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
if safe_id.is_empty() || safe_id != id {
|
||||
return Json(serde_json::json!({ "error": "invalid recording id", "success": false }));
|
||||
}
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete recording {:?}: {}", path, e);
|
||||
@@ -3604,6 +3624,10 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-050: Parse bind address once, use for all listeners
|
||||
let bind_ip: std::net::IpAddr = args.bind_addr.parse()
|
||||
.expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)");
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
@@ -3611,7 +3635,7 @@ async fn main() {
|
||||
.route("/health", get(health))
|
||||
.with_state(ws_state);
|
||||
|
||||
let ws_addr = SocketAddr::from(([0, 0, 0, 0], args.ws_port));
|
||||
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
|
||||
let ws_listener = tokio::net::TcpListener::bind(ws_addr).await
|
||||
.expect("Failed to bind WebSocket port");
|
||||
info!("WebSocket server listening on {ws_addr}");
|
||||
@@ -3686,7 +3710,7 @@ async fn main() {
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from(([0, 0, 0, 0], args.http_port));
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
let http_listener = tokio::net::TcpListener::bind(http_addr).await
|
||||
.expect("Failed to bind HTTP port");
|
||||
info!("HTTP server listening on {http_addr}");
|
||||
|
||||
@@ -12,13 +12,15 @@ 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
|
||||
from typing import Deque, List, Optional, Protocol, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -173,27 +175,47 @@ class LinuxWifiCollector:
|
||||
"""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."""
|
||||
try:
|
||||
with open("/proc/net/wireless", "r") as f:
|
||||
content = f.read()
|
||||
if self._interface not in content:
|
||||
raise RuntimeError(
|
||||
f"WiFi interface '{self._interface}' not found in "
|
||||
f"/proc/net/wireless. Available interfaces may include: "
|
||||
f"{self._parse_interface_names(content)}. "
|
||||
f"Ensure the interface is up and associated with an AP."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"Cannot read /proc/net/wireless. "
|
||||
"This collector requires a Linux system with wireless-extensions support. "
|
||||
"If running in a container or VM without WiFi hardware, use "
|
||||
"SimulatedCollector instead."
|
||||
)
|
||||
available, reason = self.is_available(self._interface)
|
||||
if not available:
|
||||
raise RuntimeError(reason)
|
||||
|
||||
@staticmethod
|
||||
def _parse_interface_names(proc_content: str) -> List[str]:
|
||||
@@ -736,3 +758,86 @@ class MacosWifiCollector:
|
||||
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)
|
||||
|
||||
+18
-42
@@ -24,7 +24,6 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
@@ -38,10 +37,6 @@ import numpy as np
|
||||
|
||||
# Sensing pipeline imports
|
||||
from v1.src.sensing.rssi_collector import (
|
||||
LinuxWifiCollector,
|
||||
SimulatedCollector,
|
||||
WindowsWifiCollector,
|
||||
MacosWifiCollector,
|
||||
WifiSample,
|
||||
RingBuffer,
|
||||
)
|
||||
@@ -321,7 +316,13 @@ class SensingWebSocketServer:
|
||||
self._running = False
|
||||
|
||||
def _create_collector(self):
|
||||
"""Auto-detect data source: ESP32 UDP > Windows WiFi > Linux WiFi > simulated."""
|
||||
"""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):
|
||||
@@ -329,43 +330,18 @@ class SensingWebSocketServer:
|
||||
self.source = "esp32"
|
||||
return Esp32UdpCollector(port=ESP32_UDP_PORT, sample_rate_hz=10.0)
|
||||
|
||||
# 2. Platform-specific WiFi
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
try:
|
||||
collector = WindowsWifiCollector(sample_rate_hz=2.0)
|
||||
collector.collect_once() # test that it works
|
||||
logger.info("Using WindowsWifiCollector")
|
||||
self.source = "windows_wifi"
|
||||
return collector
|
||||
except Exception as e:
|
||||
logger.warning("Windows WiFi unavailable (%s), falling back", e)
|
||||
elif system == "Linux":
|
||||
# In Docker on Mac, Linux is detected but no wireless extensions exist.
|
||||
# Force SimulatedCollector if /proc/net/wireless doesn't exist.
|
||||
import os
|
||||
if os.path.exists("/proc/net/wireless"):
|
||||
try:
|
||||
collector = LinuxWifiCollector(sample_rate_hz=10.0)
|
||||
self.source = "linux_wifi"
|
||||
return collector
|
||||
except RuntimeError:
|
||||
logger.warning("Linux WiFi unavailable, falling back")
|
||||
else:
|
||||
logger.warning("Linux detected but /proc/net/wireless missing (likely Docker). Falling back.")
|
||||
elif system == "Darwin":
|
||||
try:
|
||||
collector = MacosWifiCollector(sample_rate_hz=10.0)
|
||||
logger.info("Using MacosWifiCollector")
|
||||
self.source = "macos_wifi"
|
||||
return collector
|
||||
except Exception as e:
|
||||
logger.warning("macOS WiFi unavailable (%s), falling back", e)
|
||||
# 2. Platform-specific WiFi (auto-detect with graceful fallback)
|
||||
collector = create_collector(preferred="auto", sample_rate_hz=10.0)
|
||||
|
||||
# 3. Simulated
|
||||
logger.info("Using SimulatedCollector")
|
||||
self.source = "simulated"
|
||||
return SimulatedCollector(seed=42, 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."""
|
||||
|
||||
@@ -702,3 +702,106 @@ class TestBandPower:
|
||||
# Band 0.21-0.39 has no power
|
||||
p = _band_power(freqs, psd, 0.21, 0.39)
|
||||
assert p == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LinuxWifiCollector.is_available() tests (ADR-049)
|
||||
# ===========================================================================
|
||||
|
||||
from unittest.mock import patch, mock_open
|
||||
from v1.src.sensing.rssi_collector import LinuxWifiCollector, create_collector
|
||||
|
||||
|
||||
class TestLinuxWifiCollectorAvailability:
|
||||
def test_unavailable_when_proc_missing(self):
|
||||
"""is_available returns False when /proc/net/wireless doesn't exist."""
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "/proc/net/wireless not found" in reason
|
||||
|
||||
def test_unavailable_when_interface_not_listed(self):
|
||||
"""is_available returns False when the interface isn't in proc."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan1: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "wlan0" in reason
|
||||
assert "wlan1" in reason
|
||||
|
||||
def test_available_when_interface_listed(self):
|
||||
"""is_available returns True when the interface is present."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan0: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is True
|
||||
assert reason == "ok"
|
||||
|
||||
def test_unavailable_when_file_unreadable(self):
|
||||
"""is_available returns False when /proc/net/wireless exists but can't be read."""
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "Cannot read" in reason
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# create_collector() factory tests (ADR-049)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCreateCollector:
|
||||
def test_returns_simulated_when_no_wifi(self):
|
||||
"""On Linux without /proc/net/wireless, should return SimulatedCollector."""
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Linux"):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
collector = create_collector(preferred="auto")
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
|
||||
def test_returns_simulated_for_explicit_preference(self):
|
||||
"""preferred='simulated' always returns SimulatedCollector."""
|
||||
collector = create_collector(preferred="simulated")
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
|
||||
def test_returns_linux_collector_when_available(self):
|
||||
"""On Linux with /proc/net/wireless, should return LinuxWifiCollector."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan0: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Linux"):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
collector = create_collector(preferred="auto", interface="wlan0")
|
||||
assert isinstance(collector, LinuxWifiCollector)
|
||||
|
||||
def test_never_raises(self):
|
||||
"""create_collector should never raise, regardless of platform."""
|
||||
for plat in ["Linux", "Windows", "Darwin", "FreeBSD", "SunOS"]:
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value=plat):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("not found")):
|
||||
try:
|
||||
collector = create_collector(preferred="auto")
|
||||
assert collector is not None
|
||||
except Exception as exc:
|
||||
pytest.fail(f"create_collector raised on {plat}: {exc}")
|
||||
|
||||
def test_windows_default_interface_mapping(self):
|
||||
"""On Windows with default interface='wlan0', should map to 'Wi-Fi'."""
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Windows"):
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("netsh not found")):
|
||||
collector = create_collector(preferred="auto", interface="wlan0")
|
||||
# Should fall back to SimulatedCollector since netsh isn't available
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
|
||||
Reference in New Issue
Block a user