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:
commit
f2e3a6a392
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
0.6.2
|
||||
0.6.4
|
||||
|
|
|
|||
|
|
@ -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:]))
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue