Merge pull request #526 from ruvnet/fix/esp32-issues-505-517-521

fix: ESP32 CSI 0pps (#521), aggregator sibling magics (#517), version.txt (#505) + fix-marker CI guard
This commit is contained in:
rUv 2026-05-11 11:40:36 -04:00 committed by GitHub
commit f2e3a6a392
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 517 additions and 6 deletions

View File

@ -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

View File

@ -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

View File

@ -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 <N>), 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. */

View File

@ -1 +1 @@
0.6.2
0.6.4

View File

@ -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:]))

115
scripts/fix-markers.json Normal file
View File

@ -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"
}
]
}

View File

@ -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);

View File

@ -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 {

View File

@ -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)];

View File

@ -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,