diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 252a47ee..55e0e186 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -2,6 +2,11 @@ name: Firmware CI on: push: + branches: + - '**' + tags: + # ESP32 firmware release tags — build + version-consistency guard (RuView#505). + - 'v*-esp32' paths: - 'firmware/**' - '.github/workflows/firmware-ci.yml' @@ -11,6 +16,27 @@ on: - '.github/workflows/firmware-ci.yml' jobs: + version-guard: + name: Verify version.txt matches release tag + runs-on: ubuntu-latest + if: github.ref_type == 'tag' + steps: + - uses: actions/checkout@v4 + - name: Check firmware version.txt == tag + run: | + # Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z + TAG="${GITHUB_REF_NAME}" + EXPECTED="${TAG#v}" + EXPECTED="${EXPECTED%-esp32}" + ACTUAL="$(tr -d '[:space:]' < firmware/esp32-csi-node/version.txt)" + echo "Tag: $TAG → expected version.txt: $EXPECTED | actual: $ACTUAL" + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "::error::firmware/esp32-csi-node/version.txt is '$ACTUAL' but tag '$TAG' expects '$EXPECTED'." + echo "::error::Bump version.txt and re-tag so esp_app_get_description()->version is correct (RuView#505)." + exit 1 + fi + echo "version.txt matches the release tag." + build: name: Build ESP32-S3 Firmware (${{ matrix.variant }}) runs-on: ubuntu-latest diff --git a/.github/workflows/fix-regression-guard.yml b/.github/workflows/fix-regression-guard.yml new file mode 100644 index 00000000..440ddab3 --- /dev/null +++ b/.github/workflows/fix-regression-guard.yml @@ -0,0 +1,54 @@ +name: Fix-Marker Regression Guard + +# Asserts that previously-shipped fixes are still present in the tree. +# Manifest: scripts/fix-markers.json Checker: scripts/check_fix_markers.py +# Run locally: python scripts/check_fix_markers.py (also --list / --json) +# +# This complements the heavyweight checks (firmware build, deterministic +# pipeline proof, witness bundle) with a fast per-PR "did someone revert a +# known fix?" gate — the CI analogue of the ruflo witness fix-marker system. + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + fix-markers: + name: Verify fix markers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate the manifest is well-formed JSON + run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')" + + - name: Check fix markers + run: python scripts/check_fix_markers.py + + - name: Emit machine-readable result (for the run summary) + if: always() + run: | + python scripts/check_fix_markers.py --json > fix-markers-result.json || true + { + echo '### Fix-marker regression guard' + echo '' + echo '```' + python scripts/check_fix_markers.py || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload result artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fix-markers-result + path: fix-markers-result.json + retention-days: 30 diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index c8d5eb7d..5fadb445 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -336,6 +336,21 @@ void csi_collector_init(void) /* Update the hop table's first channel to match. */ s_hop_channels[0] = csi_channel; + /* Disable WiFi modem sleep — reliable CSI capture needs the radio awake. + * The ESP-IDF STA default is WIFI_PS_MIN_MODEM, which lets the modem + * sleep between DTIM beacons; with the MGMT-only promiscuous filter + * (RuView#396) that starves the CSI callback and the per-second yield + * collapses toward 0 pps (RuView#521). Operators who want battery + * duty-cycling opt back in via power_mgmt_init() (provision.py + * --duty-cycle ), which runs after this and re-enables modem sleep. */ + esp_err_t ps_err = esp_wifi_set_ps(WIFI_PS_NONE); + if (ps_err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_ps(WIFI_PS_NONE) failed: %s — CSI yield may be low", + esp_err_to_name(ps_err)); + } else { + ESP_LOGI(TAG, "WiFi modem sleep disabled (WIFI_PS_NONE) for CSI capture"); + } + /* Enable promiscuous mode — required for reliable CSI callbacks. * Without this, CSI only fires on frames destined to this station, * which may be very infrequent on a quiet network. */ diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index b6160487..d2b13eb6 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.6.2 +0.6.4 diff --git a/scripts/check_fix_markers.py b/scripts/check_fix_markers.py new file mode 100644 index 00000000..ab21bad9 --- /dev/null +++ b/scripts/check_fix_markers.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Fix-marker regression guard for RuView. + +Reads ``scripts/fix-markers.json`` and asserts that every previously-shipped +fix is still present in the codebase: + +* every file listed in a marker must exist; +* every ``require`` pattern must appear in at least one of the marker's files + (a missing pattern means the fix was probably reverted); +* no ``forbid`` pattern may appear in any of the marker's files + (a re-appearing anti-pattern means the bug was re-introduced). + +A pattern is a literal substring by default. Wrap it in ``/.../`` to treat it +as a (multiline, case-sensitive) regular expression, e.g. ``"/fall_thresh\\s*=\\s*2\\.0/"``. + +This is a stdlib-only script — no dependencies, runs anywhere Python 3.8+ does. + +Usage:: + + python scripts/check_fix_markers.py # check everything (CI) + python scripts/check_fix_markers.py --list # list all markers + python scripts/check_fix_markers.py --json # machine-readable result + python scripts/check_fix_markers.py --only RuView#396 RuView#521 + +Exit codes: 0 = all markers OK, 1 = one or more regressions, 2 = bad manifest. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MANIFEST_PATH = REPO_ROOT / "scripts" / "fix-markers.json" + +# Best-effort UTF-8 stdout (Windows consoles default to cp1252); harmless on +# Linux/CI where it's already UTF-8. We still keep all symbols ASCII below so +# the script works even if reconfigure() is unavailable. +try: # pragma: no cover - environment-dependent + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +# ANSI colours — disabled automatically when stdout isn't a TTY (CI logs are +# plain either way, but keep them readable locally). +_TTY = sys.stdout.isatty() +def _c(code: str, s: str) -> str: + return f"\033[{code}m{s}\033[0m" if _TTY else s +GREEN = lambda s: _c("32", s) +RED = lambda s: _c("31", s) +YELLOW = lambda s: _c("33", s) +DIM = lambda s: _c("2", s) +BOLD = lambda s: _c("1", s) + +OK_MARK = "PASS" +BAD_MARK = "FAIL" +ARROW = "->" + + +class ManifestError(Exception): + pass + + +def load_manifest() -> dict: + if not MANIFEST_PATH.exists(): + raise ManifestError(f"manifest not found: {MANIFEST_PATH}") + try: + data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ManifestError(f"manifest is not valid JSON: {e}") from e + if not isinstance(data, dict) or not isinstance(data.get("markers"), list): + raise ManifestError("manifest must be an object with a 'markers' array") + ids = [m.get("id") for m in data["markers"]] + dupes = {i for i in ids if ids.count(i) > 1} + if dupes: + raise ManifestError(f"duplicate marker ids: {sorted(dupes)}") + return data + + +def _pattern_found(text: str, pattern: str) -> bool: + if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"): + return re.search(pattern[1:-1], text, re.MULTILINE) is not None + return pattern in text + + +def check_marker(marker: dict) -> tuple[bool, list[str]]: + """Return (ok, problems) for a single marker.""" + problems: list[str] = [] + files = marker.get("files", []) + require = marker.get("require", []) + forbid = marker.get("forbid", []) + + if not files: + problems.append("marker lists no files") + return False, problems + + contents: dict[str, str] = {} + for rel in files: + p = REPO_ROOT / rel + if not p.exists(): + problems.append(f"missing file: {rel}") + continue + try: + contents[rel] = p.read_text(encoding="utf-8", errors="replace") + except OSError as e: + problems.append(f"cannot read {rel}: {e}") + + haystack = "\n".join(contents.values()) + for pat in require: + if not _pattern_found(haystack, pat): + problems.append(f"required marker absent (fix likely reverted): {pat!r}") + for pat in forbid: + for rel, text in contents.items(): + if _pattern_found(text, pat): + problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}") + + return (len(problems) == 0), problems + + +def cmd_list(manifest: dict) -> int: + print(BOLD(f"{len(manifest['markers'])} fix markers tracked:\n")) + for m in manifest["markers"]: + print(f" {BOLD(m['id']):<28} {m.get('title', '')}") + if m.get("ref"): + print(DIM(f" {m['ref']}")) + for f in m.get("files", []): + print(DIM(f" - {f}")) + return 0 + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--list", action="store_true", help="list all markers and exit") + ap.add_argument("--json", action="store_true", help="emit a JSON result object") + ap.add_argument("--only", nargs="+", metavar="ID", help="only check the given marker ids") + args = ap.parse_args(argv) + + try: + manifest = load_manifest() + except ManifestError as e: + print(RED(f"[manifest error] {e}"), file=sys.stderr) + return 2 + + if args.list: + return cmd_list(manifest) + + markers = manifest["markers"] + if args.only: + wanted = set(args.only) + markers = [m for m in markers if m["id"] in wanted] + unknown = wanted - {m["id"] for m in markers} + if unknown: + print(RED(f"[error] unknown marker id(s): {sorted(unknown)}"), file=sys.stderr) + return 2 + + results = [] + failed = 0 + for m in markers: + ok, problems = check_marker(m) + results.append({"id": m["id"], "title": m.get("title", ""), "ok": ok, "problems": problems}) + if not ok: + failed += 1 + + if args.json: + print(json.dumps({"ok": failed == 0, "checked": len(markers), "failed": failed, "markers": results}, indent=2)) + return 0 if failed == 0 else 1 + + print(BOLD(f"Fix-marker regression guard - {len(markers)} marker(s)\n")) + for r in results: + if r["ok"]: + print(f" {GREEN('[' + OK_MARK + ']')} {r['id']:<28} {DIM(r['title'])}") + else: + print(f" {RED('[' + BAD_MARK + ']')} {BOLD(r['id']):<28} {r['title']}") + for p in r["problems"]: + print(f" {RED(ARROW)} {p}") + print() + if failed: + print(RED(BOLD(f"{failed}/{len(markers)} marker(s) regressed."))) + print(DIM(" A reverted fix is a regression. Restore the marker, or - if the change is")) + print(DIM(" intentional - update scripts/fix-markers.json in the same PR with a rationale.")) + return 1 + print(GREEN(BOLD(f"All {len(markers)} fix markers present."))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/fix-markers.json b/scripts/fix-markers.json new file mode 100644 index 00000000..4cb65b7e --- /dev/null +++ b/scripts/fix-markers.json @@ -0,0 +1,115 @@ +{ + "_comment": "Fix-marker regression guard for RuView. Each marker asserts that a previously-shipped fix is still present. CI (.github/workflows/fix-regression-guard.yml) fails if a `require` pattern is missing from all of a marker's `files` (the fix was likely reverted) or if a `forbid` pattern reappears (the bug was re-introduced). Run locally: `python scripts/check_fix_markers.py` (or `--list`, `--json`, `--only ID`). Patterns are literal substrings unless wrapped in /.../ (regex). Add a marker whenever you ship a fix that would be expensive to silently lose.", + "schema_version": 1, + "markers": [ + { + "id": "RuView#396", + "title": "ESP32-S3 CSI: MGMT-only promiscuous filter (SPI flash cache race crash fix)", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["WIFI_PROMIS_FILTER_MASK_MGMT", "RuView#396"], + "rationale": "Promiscuous MGMT+DATA produces 100-500 Hz HW interrupts that crash Core 0 in wDev_ProcessFiq (SPI flash cache race in the WiFi blob). Reverting to the full filter reintroduces the boot-loop / crash.", + "ref": "https://github.com/ruvnet/RuView/issues/396" + }, + { + "id": "RuView#521", + "title": "ESP32-S3 CSI: disable WiFi modem sleep (WIFI_PS_NONE) so the CSI callback isn't starved", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["esp_wifi_set_ps(WIFI_PS_NONE)", "RuView#521"], + "rationale": "The ESP-IDF STA default WIFI_PS_MIN_MODEM lets the modem sleep between DTIM beacons; combined with the MGMT-only filter the per-second CSI yield collapses toward 0 pps. csi_collector_init() must force WIFI_PS_NONE.", + "ref": "https://github.com/ruvnet/RuView/issues/521" + }, + { + "id": "RuView#517", + "title": "Aggregator classifies sibling RuView UDP packet magics instead of erroring on them", + "files": [ + "v2/crates/wifi-densepose-hardware/src/esp32_parser.rs", + "v2/crates/wifi-densepose-hardware/src/error.rs", + "v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs" + ], + "require": ["ruview_sibling_packet_name", "NonCsiPacket", "RUVIEW_VITALS_MAGIC"], + "rationale": "The firmware multiplexes 0xC5110002..0xC5110007 (vitals, feature, fused, compressed, feature-state, temporal) onto the CSI UDP port. The parser must report these as ParseError::NonCsiPacket so the aggregator can skip them, not log 'invalid magic' parse-error noise.", + "ref": "https://github.com/ruvnet/RuView/issues/517" + }, + { + "id": "RuView#505", + "title": "Firmware release: version.txt must match the release tag (firmware-ci version-guard)", + "files": [".github/workflows/firmware-ci.yml"], + "require": ["version-guard", "version.txt"], + "rationale": "v0.6.3-esp32 shipped a binary that internally identified as 0.6.2 because version.txt was never bumped. The version-guard job fails the release run when the tag's X.Y.Z doesn't match firmware/esp32-csi-node/version.txt.", + "ref": "https://github.com/ruvnet/RuView/issues/505" + }, + { + "id": "RuView#354", + "title": "Firmware embeds its version from version.txt and logs it at boot", + "files": [ + "firmware/esp32-csi-node/CMakeLists.txt", + "firmware/esp32-csi-node/main/main.c" + ], + "require": ["PROJECT_VER", "version.txt", "esp_app_get_description"], + "rationale": "esp_app_get_description()->version must derive from version.txt (CMake file(STRINGS ...)), and the boot log line surfaces it for fleet monitoring.", + "ref": "https://github.com/ruvnet/RuView/issues/354" + }, + { + "id": "RuView#263", + "title": "Fall detection: default threshold 15.0 rad/s2 + consecutive-frame debounce + cooldown", + "files": [ + "firmware/esp32-csi-node/main/nvs_config.c", + "firmware/esp32-csi-node/main/edge_processing.c", + "firmware/esp32-csi-node/main/edge_processing.h" + ], + "require": ["15.0f", "EDGE_FALL_CONSEC_MIN", "EDGE_FALL_COOLDOWN_MS"], + "forbid": ["/fall_thresh\\s*=\\s*2\\.0f\\b/"], + "rationale": "Default fall_thresh of 2.0 rad/s2 caused alert storms (false positives). 15.0 with a 3-consecutive-frame debounce + 5 s cooldown verified 0 false alerts in 600 frames on COM7.", + "ref": "https://github.com/ruvnet/RuView/issues/263" + }, + { + "id": "RuView#266-321", + "title": "Edge DSP task: batch limit so it can't starve IDLE1 and trip the task watchdog", + "files": ["firmware/esp32-csi-node/main/edge_processing.c", "firmware/esp32-csi-node/main/edge_processing.h"], + "require": ["EDGE_BATCH_LIMIT"], + "rationale": "On busy LANs the edge DSP task processed frames back-to-back with only 1-tick yields, starving IDLE1 enough to trip the 5-second task watchdog. The batch limit forces a longer yield every N frames.", + "ref": "https://github.com/ruvnet/RuView/issues/266" + }, + { + "id": "RuView#265", + "title": "4 MB flash variant: dual-OTA partition table + 4mb sdkconfig, built by firmware-ci", + "files": [ + "firmware/esp32-csi-node/partitions_4mb.csv", + "firmware/esp32-csi-node/sdkconfig.defaults.4mb", + ".github/workflows/firmware-ci.yml" + ], + "require": ["sdkconfig.defaults.4mb"], + "rationale": "Support for ESP32-S3-N16R8 / N8R2 and other 4 MB boards. The firmware-ci build matrix must keep building the 4mb variant so it doesn't bit-rot.", + "ref": "https://github.com/ruvnet/RuView/issues/265" + }, + { + "id": "RuView#232-375-385-386-390", + "title": "ESP32-S3 CSI: defensive early-capture of NVS config before wifi_init_sta() corrupts it", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["early capture", "s_filter_mac"], + "rationale": "wifi_init_sta() can clobber g_nvs_config (confirmed on device 80:b5:4e:c1:be:b8). Module-local statics must be captured before WiFi init and used by the CSI callback instead of g_nvs_config.", + "ref": "https://github.com/ruvnet/RuView/issues/390" + }, + { + "id": "ADR-028-proof", + "title": "Deterministic pipeline proof (Trust Kill Switch): artifacts present and re-run in CI", + "files": [ + "archive/v1/data/proof/verify.py", + "archive/v1/data/proof/expected_features.sha256", + "archive/v1/data/proof/sample_csi_data.json", + ".github/workflows/verify-pipeline.yml" + ], + "require": ["VERDICT", "expected_features.sha256", "verify.py"], + "rationale": "verify.py feeds a seeded reference signal through the production CSI pipeline and SHA-256-hashes the output; expected_features.sha256 pins it; verify-pipeline.yml re-runs it on every PR. Losing any of these removes the project's tamper-evidence guarantee (ADR-028).", + "ref": "docs/adr/ADR-028-esp32-capability-audit.md" + }, + { + "id": "ADR-028-witness-bundle", + "title": "Release-time witness bundle generator + self-verification script", + "files": ["scripts/generate-witness-bundle.sh"], + "require": ["VERIFY.sh", "witness-bundle"], + "rationale": "scripts/generate-witness-bundle.sh produces the self-contained, recipient-verifiable witness bundle (witness log + proof + test results + firmware hashes + VERIFY.sh). Part of the ADR-028 attestation chain.", + "ref": "docs/WITNESS-LOG-028.md" + } + ] +} diff --git a/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs b/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs index 8d176985..1558214b 100644 --- a/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs +++ b/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs @@ -10,7 +10,7 @@ use std::net::UdpSocket; use std::process; use clap::Parser; -use wifi_densepose_hardware::Esp32CsiParser; +use wifi_densepose_hardware::{Esp32CsiParser, ParseError}; /// UDP aggregator for ESP32 CSI nodes (ADR-018). #[derive(Parser)] @@ -65,6 +65,15 @@ fn main() { mean_amp, ); } + // The firmware sends several packet types on this UDP port + // (ADR-039 vitals, ADR-081 feature state, ADR-095 temporal, …) + // alongside ADR-018 CSI frames. Those are expected, not errors — + // this CSI-only aggregator just skips them. (RuView#517) + Err(ParseError::NonCsiPacket { kind, .. }) => { + if cli.verbose { + eprintln!(" [skipped {} packet — not a CSI frame]", kind); + } + } Err(e) => { if cli.verbose { eprintln!(" parse error: {}", e); diff --git a/v2/crates/wifi-densepose-hardware/src/error.rs b/v2/crates/wifi-densepose-hardware/src/error.rs index 7ccc07e7..17f5146c 100644 --- a/v2/crates/wifi-densepose-hardware/src/error.rs +++ b/v2/crates/wifi-densepose-hardware/src/error.rs @@ -19,6 +19,18 @@ pub enum ParseError { got: u32, }, + /// A recognized RuView wire packet was received that is *not* an + /// ADR-018 raw CSI frame (e.g. ADR-039 vitals, ADR-081 feature state, + /// ADR-095 temporal classification). The firmware multiplexes several + /// packet types onto the same UDP port, so a CSI parser will see these + /// interleaved with CSI frames — that is expected, not a corruption. + /// Consumers should route the packet to the matching decoder or skip it. + #[error("Non-CSI RuView packet on CSI socket: {kind} (magic {magic:#010x})")] + NonCsiPacket { + magic: u32, + kind: &'static str, + }, + /// The frame indicates more subcarriers than physically possible. #[error("Invalid subcarrier count: {count} (max {max})")] InvalidSubcarrierCount { diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index 22481215..f7ffedf7 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -35,7 +35,43 @@ use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, Subcarri use crate::error::ParseError; /// ESP32 CSI binary frame magic number (ADR-018). -const ESP32_CSI_MAGIC: u32 = 0xC5110001; +pub const ESP32_CSI_MAGIC: u32 = 0xC5110001; + +// ── Sibling RuView wire packets ────────────────────────────────────────────── +// The ESP32 firmware multiplexes several packet types onto the same UDP port +// as ADR-018 raw CSI frames. A CSI-only consumer will therefore see these +// interleaved with CSI frames. They are *not* corruption — they just need a +// different decoder (or can be skipped). See firmware `rv_feature_state.h`. + +/// ADR-039 edge vitals packet (32 bytes: HR/BR/presence). +pub const RUVIEW_VITALS_MAGIC: u32 = 0xC5110002; +/// ADR-069 feature-vector packet. +pub const RUVIEW_FEATURE_MAGIC: u32 = 0xC5110003; +/// ADR-063 fused-vitals packet (multi-sensor fusion). +pub const RUVIEW_FUSED_VITALS_MAGIC: u32 = 0xC5110004; +/// ADR-039 compressed-CSI packet. +pub const RUVIEW_COMPRESSED_CSI_MAGIC: u32 = 0xC5110005; +/// ADR-081 compact feature-state packet (the default upstream payload). +pub const RUVIEW_FEATURE_STATE_MAGIC: u32 = 0xC5110006; +/// ADR-095 / #513 on-device temporal-classification packet. +pub const RUVIEW_TEMPORAL_MAGIC: u32 = 0xC5110007; + +/// If `magic` is a recognized RuView wire packet other than the ADR-018 raw +/// CSI frame, return a human-readable name for it; otherwise `None`. +/// +/// Used by CSI consumers to distinguish "a sibling packet I should route or +/// skip" from "genuine garbage on the wire". +pub fn ruview_sibling_packet_name(magic: u32) -> Option<&'static str> { + match magic { + RUVIEW_VITALS_MAGIC => Some("ADR-039 edge vitals"), + RUVIEW_FEATURE_MAGIC => Some("ADR-069 feature vector"), + RUVIEW_FUSED_VITALS_MAGIC => Some("ADR-063 fused vitals"), + RUVIEW_COMPRESSED_CSI_MAGIC => Some("ADR-039 compressed CSI"), + RUVIEW_FEATURE_STATE_MAGIC => Some("ADR-081 feature state"), + RUVIEW_TEMPORAL_MAGIC => Some("ADR-095 temporal classification"), + _ => None, + } +} /// ADR-018 header size in bytes (before I/Q data). const HEADER_SIZE: usize = 20; @@ -55,6 +91,18 @@ impl Esp32CsiParser { /// The buffer must contain at least the header (20 bytes) plus the I/Q data. /// Returns the parsed frame and the number of bytes consumed. pub fn parse_frame(data: &[u8]) -> Result<(CsiFrame, usize), ParseError> { + // A recognized sibling packet (ADR-039 vitals, ADR-081 feature state, …) + // multiplexed onto the CSI UDP port should be reported as such — not as + // "insufficient data" or "invalid magic" — so callers can route or skip + // it. These packets are all >= 4 bytes; classify before the CSI-frame + // length gate. (RuView#517) + if data.len() >= 4 { + let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + if let Some(kind) = ruview_sibling_packet_name(magic) { + return Err(ParseError::NonCsiPacket { magic, kind }); + } + } + if data.len() < HEADER_SIZE { return Err(ParseError::InsufficientData { needed: HEADER_SIZE, @@ -310,12 +358,50 @@ mod tests { #[test] fn test_parse_invalid_magic() { let mut data = build_test_frame(1, 1, &[(10, 20)]); - // Corrupt magic - data[0] = 0xFF; + // Corrupt magic to a value that isn't any known RuView packet. + data[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); let result = Esp32CsiParser::parse_frame(&data); assert!(matches!(result, Err(ParseError::InvalidMagic { .. }))); } + #[test] + fn test_sibling_vitals_packet_is_not_invalid_magic() { + // RuView#517: a 32-byte ADR-039 vitals packet (magic 0xC5110002) + // arrives on the same UDP port as CSI frames. It must be reported as + // a recognized sibling packet, not a corrupt CSI frame. + let mut data = vec![0u8; 32]; + data[0..4].copy_from_slice(&RUVIEW_VITALS_MAGIC.to_le_bytes()); + match Esp32CsiParser::parse_frame(&data) { + Err(ParseError::NonCsiPacket { magic, kind }) => { + assert_eq!(magic, RUVIEW_VITALS_MAGIC); + assert_eq!(kind, "ADR-039 edge vitals"); + } + other => panic!("expected NonCsiPacket, got {other:?}"), + } + } + + #[test] + fn test_all_sibling_magics_classified() { + for m in [ + RUVIEW_VITALS_MAGIC, + RUVIEW_FEATURE_MAGIC, + RUVIEW_FUSED_VITALS_MAGIC, + RUVIEW_COMPRESSED_CSI_MAGIC, + RUVIEW_FEATURE_STATE_MAGIC, + RUVIEW_TEMPORAL_MAGIC, + ] { + assert!(ruview_sibling_packet_name(m).is_some(), "{m:#010x} unclassified"); + let mut data = vec![0u8; 24]; + data[0..4].copy_from_slice(&m.to_le_bytes()); + assert!( + matches!(Esp32CsiParser::parse_frame(&data), Err(ParseError::NonCsiPacket { .. })), + "{m:#010x} should parse as NonCsiPacket" + ); + } + // The CSI magic itself is not a "sibling". + assert!(ruview_sibling_packet_name(ESP32_CSI_MAGIC).is_none()); + } + #[test] fn test_amplitude_phase_from_known_iq() { let pairs = vec![(100i8, 0i8), (0, 50), (30, 40)]; diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index a54b8157..23838ad9 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -49,7 +49,11 @@ pub mod radio_ops; pub use csi_frame::{CsiFrame, CsiMetadata, SubcarrierData, Bandwidth, AntennaConfig}; pub use error::ParseError; -pub use esp32_parser::Esp32CsiParser; +pub use esp32_parser::{ + Esp32CsiParser, ruview_sibling_packet_name, ESP32_CSI_MAGIC, RUVIEW_VITALS_MAGIC, + RUVIEW_FEATURE_MAGIC, RUVIEW_FUSED_VITALS_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC, + RUVIEW_FEATURE_STATE_MAGIC, RUVIEW_TEMPORAL_MAGIC, +}; pub use bridge::CsiData; pub use radio_ops::{ RadioOps, RadioMode, CaptureProfile, RadioHealth, RadioError, MockRadio,