Compare commits

...

26 Commits

Author SHA1 Message Date
ruv f2525d7a0d chore(vendor): add rvcsi as a vendor submodule (github.com/ruvnet/rvcsi)
rvCSI — the edge RF sensing runtime incubated here as `v2/crates/rvcsi-*`
(ADR-095, ADR-096, PR #542) — now has a standalone home at
github.com/ruvnet/rvcsi (9 crates published to crates.io, @ruv/rvcsi on npm,
a Claude Code plugin). This vendors it under `vendor/rvcsi`, alongside
`vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`.

Follow-up: migrate the workspace to consume `vendor/rvcsi/crates/rvcsi-*`
and drop the inline `v2/crates/rvcsi-*` copies (kept for now so this change
is a pure addition).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 22:52:12 -04:00
rUv 601b3406fd Merge pull request #542 from ruvnet/claude/design-rvcsi-platform-X7yJR
docs: rvCSI edge RF sensing platform — PRD, ADR-095, DDD domain model
2026-05-12 22:38:29 -04:00
ruv deb561bf9c fix(rvcsi): scale-relative baseline-drift thresholds + ESP32 end-to-end validation
BaselineDriftDetector compared `mean_amplitude` against its EWMA baseline
with *absolute* thresholds (anomaly 1.0, drift 0.15). Fine for the synthetic
unit tests (amplitudes ~1.0), but raw ESP32 CSI is int8 I/Q with amplitudes
up to ~128, so window-to-window RMS distance is routinely 5-50 >> 1.0 and
AnomalyDetected fired on ~96% of windows (319/331 on a real node-1 capture).

Drift is now `||current - baseline||2 / ||baseline||2` (a fraction, with an
eps floor that falls back to absolute for a degenerate near-zero baseline),
so one tuning is valid across raw-int8 ESP32, int16-scaled Nexmon, and
baseline-subtracted streams. AnomalyDetected drops to 40/331 on the same
data; the existing detector tests still pass (their explicit configs are
valid relative thresholds too); added baseline_drift_is_scale_invariant_
no_anomaly_storm. rvcsi-events 18 -> 19 tests; 162 rvcsi tests, 0 failures,
clippy-clean.

Surfaced by an end-to-end test against real ESP32 CSI on COM7: the device
(ESP32-S3, node 1, ADR-018 firmware, WiFi "ruv.net" ch5 RSSI -39, CSI cb
only because nothing listens at .156). rvcsi has no ESP32 adapter yet, so a
7,000-frame node-1 recording was transcoded to .rvcsi via the new
scripts/esp32_jsonl_to_rvcsi.py (stand-in for `record --source esp32-jsonl`)
and run through `rvcsi inspect`/`replay`/`calibrate`/`events` end-to-end.

ADR-095 D13 and ADR-096 sections 2.1/5 updated; CHANGELOG entry added;
rvcsi-adapter-esp32 (live serial/UDP source) noted as a follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 22:19:15 -04:00
Claude d40411e6d7 feat(rvcsi): Raspberry Pi 5 (BCM43455c0) + Nexmon chip registry
Adds first-class support for the Raspberry Pi 5's WiFi chip (CYW43455 /
BCM43455c0 — the same 802.11ac wireless as the Pi 4 / Pi 3B+ / Pi 400, and the
chip with the most mature nexmon_csi support), plus a registry of the other
Nexmon-supported Broadcom/Cypress chips.

rvcsi-adapter-nexmon — new `chips.rs`:
- `NexmonChip` (Bcm43455c0, Bcm43436b0, Bcm4366c0, Bcm4375b1, Bcm4358, Bcm4339,
  Unknown{chip_ver}) + `RaspberryPiModel` (Pi5/Pi4/Pi400/Pi3BPlus/PiZero2W/
  PiZeroW) — Pi5/Pi4/Pi400/Pi3B+ → Bcm43455c0; PiZero2W → Bcm43436b0.
- `nexmon_adapter_profile(chip)` / `raspberry_pi_profile(model)` build the
  per-device `AdapterProfile` (channels: 2.4 GHz 1-13 + 5 GHz UNII for dual-band;
  bandwidths 20/40/80[/160]; expected subcarrier counts 64/128/256[/512]) that
  `validate_frame` bounds CSI frames against.
- `NexmonChip::from_chip_ver` (0x4345 → Bcm43455c0, 0x4339, 0x4358, 0x4366,
  0x4375 — best-effort; the raw `chip_ver` is always preserved) and `from_slug`
  / `RaspberryPiModel::from_slug` ("pi5", "raspberry pi 4", "bcm43455c0", ...).
- `NexmonCsiHeader::chip()`; `NexmonPcapAdapter` auto-detects the chip from the
  packets' `chip_ver` and uses the matching profile, overridable via
  `.with_chip(NexmonChip)` / `.with_pi_model(RaspberryPiModel)`; `.detected_chip()`.

rvcsi-runtime: `decode_nexmon_pcap_for(.., chip_spec)` (validate against a chip /
Pi model, drop non-conforming) + `nexmon_profile_for(spec)`; `NexmonPcapSummary`
gains `chip_names` + `detected_chip`; `CaptureSummary` gains `chip`.

rvcsi-cli: `record --source nexmon-pcap --chip pi5`; new `nexmon-chips`
subcommand (lists chips + Pi models, human or `--json`); `inspect-nexmon` and
`inspect` now print the resolved chip.

rvcsi-node (napi-rs): `nexmonDecodePcap` gains an optional `chip` arg;
`nexmonChipName(chipVer)`, `nexmonProfile(spec)`, `nexmonChips()`. @ruv/rvcsi
SDK + `.d.ts` updated (AdapterProfile / NexmonChipsListing interfaces, the new
fns, `chip` on CaptureSummary, `chip_names`/`detected_chip` on NexmonPcapSummary).

168 rvcsi tests pass (adapter-nexmon 22→28, cli 9→10), 0 failures, clippy-clean.
The synthetic test captures now stamp chip_ver = 0x4345 (the BCM4345 family chip
ID), so the chip-detection happy path is exercised end to end.
ADR-096, CHANGELOG, README, CLAUDE.md updated.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 01:32:27 +00:00
Claude b116a99481 feat(rvcsi): real nexmon_csi UDP/PCAP fidelity — chanspec decode, libpcap reader, NexmonPcapAdapter
Raises the Nexmon path from a normalized record format to parsing what the
patched Broadcom firmware actually emits, end to end.

napi-c shim (ABI 1.0 -> 1.1, additive):
- rvcsi_nx_csi_udp_header / rvcsi_nx_csi_udp_decode — parse the real nexmon_csi
  UDP payload: the 18-byte header (magic 0x1111, rssi int8, fctl, src_mac[6],
  seq_cnt, core/spatial-stream, Broadcom chanspec, chip_ver) + nsub complex CSI
  samples (modern int16 LE I/Q export — what CSIKit/csireader.py read for the
  BCM43455c0 / 4358 / 4366c0; nsub = (len-18)/4). rvcsi_nx_csi_udp_write to
  synthesize payloads for tests. rvcsi_nx_decode_chanspec — d11ac chanspec ->
  channel (chanspec & 0xff) / bandwidth (bits [13:11], cross-checked against the
  FFT size) / band (bits [15:14], cross-checked against the channel number).
  Still allocation-free, bounds-checked, structured errors, never panics.
- ffi.rs wraps it: decode_chanspec / parse_nexmon_udp_header / decode_nexmon_udp
  / encode_nexmon_udp + DecodedChanspec / NexmonCsiHeader; every unsafe block
  documented; the ABI guard now expects 1.1.

rvcsi-adapter-nexmon:
- pcap.rs — a dependency-free classic-libpcap reader (all four byte-order /
  timestamp-resolution magics; Ethernet / raw-IPv4 / Linux-SLL link types;
  tolerates a truncated final record; pcapng is a follow-up) + extract_udp_payload
  + a synthetic_udp_pcap / synthetic_nexmon_pcap test/example generator.
- NexmonPcapAdapter (a CsiSource) — reads the CSI UDP packets out of a
  `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture, decodes each via the C
  shim, stamps the frame timestamp from the pcap packet time; non-CSI packets
  counted as "skipped" in health.

rvcsi-runtime: decode_nexmon_pcap, summarize_nexmon_pcap (+ NexmonPcapSummary:
link type, CSI frame count, channels, bandwidths, subcarrier counts, chip
versions, RSSI range, time span), CaptureRuntime::open_nexmon_pcap[_bytes].

rvcsi-node (napi-rs): nexmonDecodePcap, inspectNexmonPcap, decodeChanspec,
RvcsiRuntime.openNexmonPcap. @ruv/rvcsi SDK + .d.ts updated (NexmonPcapSummary,
DecodedChanspec). rvcsi-cli: `record --source nexmon-pcap`, `inspect-nexmon`,
`decode-chanspec`.

161 rvcsi tests pass (adapter-nexmon 9->22), 0 failures, clippy-clean.
ADR-096 §2.2/§2.3/§5, CHANGELOG, CLAUDE.md updated.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 01:15:22 +00:00
Claude 684a064816 docs(rvcsi): update CHANGELOG, CLAUDE.md crate table, README docs index
- CHANGELOG: expand the rvCSI entry to cover all 9 crates (incl. rvcsi-runtime
  and the @ruv/rvcsi npm SDK), the napi-c / napi-rs seams, and the 142-test /
  clippy-clean status; note the daemon + MCP server are follow-ups.
- CLAUDE.md: add the 9 `rvcsi-*` crates to the Key Rust Crates table.
- README: add an rvCSI row to the docs index; bump the ADR count (79→96) and
  DDD-model count (7→8).

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 00:18:56 +00:00
Claude 7393cc2b73 feat(rvcsi): rvcsi-runtime composition + rvcsi-node (napi-rs) + rvcsi-cli + @ruv/rvcsi TS SDK
- rvcsi-runtime — the composition layer (no FFI): CaptureRuntime (CsiSource +
  validate_frame + SignalPipeline + EventPipeline, with next_validated_frame /
  next_clean_frame / drain_events / health) plus one-shot helpers
  (summarize_capture → CaptureSummary, decode_nexmon_records, events_from_capture,
  export_capture_to_rf_memory, rf_memory_self_check). 10 tests.
- rvcsi-node — the napi-rs seam (cdylib+rlib, build.rs runs napi_build::setup):
  thin #[napi] wrappers over rvcsi-runtime — rvcsiVersion / nexmonShimAbiVersion /
  nexmonDecodeRecords / inspectCaptureFile / eventsFromCaptureFile /
  exportCaptureToRfMemory + an RvcsiRuntime streaming class. Everything that
  crosses the boundary is a validated/normalized rvCSI struct serialized to JSON
  (D6). deny(clippy::all).
- @ruv/rvcsi npm package (package.json + index.js + index.d.ts + README +
  __test__/api.test.cjs) — curated JS surface that JSON-parses the addon's
  output into plain CsiFrame/CsiWindow/CsiEvent/SourceHealth/CaptureSummary
  objects; lazy native-addon load with a helpful "not built" error.
- rvcsi-cli — the `rvcsi` binary: record (Nexmon dump → .rvcsi, validating),
  inspect, replay, stream, events, health, calibrate (v0 baseline), export
  ruvector. 7 tests exercising every subcommand against in-memory captures.
- rvcsi-cli no longer depends on rvcsi-node (a binary can't link the napi addon);
  the shared logic moved to rvcsi-runtime. .gitignore: ignore the generated
  *.node / binding.js / binding.d.ts / npm/ under rvcsi-node.

All rvcsi crates: build together OK, clippy-clean, 140 unit/integration tests +
2 doctests, 0 failures (core 29, dsp 28, events 18, adapter-file 20+1,
adapter-nexmon 9, ruvector 20+1, runtime 10, cli 7).

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 00:17:45 +00:00
Claude 6432dfbd2d feat(rvcsi): rvcsi-adapter-file (.rvcsi capture/replay) + rvcsi-ruvector (RF memory)
- rvcsi-adapter-file (ADR-095 FR1/FR10, D9): the `.rvcsi` JSONL capture format
  (CaptureHeader line + one CsiFrame per line), FileRecorder, FileReplayAdapter
  (a CsiSource — deterministic replay, preserves timestamps/ordering/validation
  verbatim, carries an unenforced replay_speed for the daemon/CLI), read_all().
  20 unit tests + 1 doctest.
- rvcsi-ruvector (ADR-095 FR8, D8) — standin for the production RuVector binding:
  deterministic embeddings (window_embedding = 32 resampled mean_amplitude bins +
  32 resampled phase_variance bins + [motion_energy, presence_score, quality_score,
  ln1p(frame_count)], L2-normalized, dim 68; event_embedding = 10-wide kind
  one-hot + confidence + ln1p(evidence count), dim 12), cosine_similarity, the
  RfMemoryStore trait + value objects (EmbeddingId/RecordKind/SimilarHit/
  DriftReport), and InMemoryRfMemory + JsonlRfMemory (file-backed append log,
  identical query semantics, latest-baseline-per-room-wins on reopen).
  20 unit tests + 1 doctest.

All rvcsi crates build and test together: core 29, dsp 28, events 18,
adapter-file 20(+1), adapter-nexmon 9, ruvector 20(+1) — 124 unit + 2 doc tests,
0 failures. forbid(unsafe_code) everywhere except rvcsi-adapter-nexmon (FFI).

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 00:03:27 +00:00
Claude 46f701bca8 feat(rvcsi): rvcsi-events — window aggregation + event detectors (ADR-095 FR5)
- WindowBuffer: buffers exposable CsiFrames from one (session,source), emits a
  CsiWindow on a frame-count or duration threshold; computes per-subcarrier
  mean_amplitude / phase_variance and scalar motion_energy / presence_score /
  quality_score; skips mixed source/session and mismatched-subcarrier frames.
- EventDetector trait + 4 state machines: PresenceDetector (hysteresis on
  presence_score), MotionDetector (debounced rising/falling edges on
  motion_energy), QualityDetector (SignalQualityDropped + once-per-stretch
  CalibrationRequired), BaselineDriftDetector (EWMA baseline → BaselineChanged /
  AnomalyDetected). Each with new()/with_config() + a public config struct.
- EventPipeline: owns a WindowBuffer + Vec<Box<dyn EventDetector>> + IdGenerator;
  process_frame / flush / add_detector / recent_windows (32-window ring) /
  with_defaults.
- 18 tests (incl. a 150-frame quiet/active/quiet end-to-end run via a seeded LCG
  + a determinism check). clippy-clean, forbid(unsafe_code), no heavy deps.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 00:01:19 +00:00
Claude 94745242a8 feat(rvcsi): rvcsi-dsp (DSP stages + SignalPipeline) + ADR-096 (FFI/crate layout)
- rvcsi-dsp — reusable signal-processing stages (ADR-095 FR4): mean/variance/
  std_dev/median, remove_dc_offset, unwrap_phase, moving_average, ewma,
  hampel_filter(_count), short_window_variance, subtract_baseline + DspError;
  scalar features motion_energy(_series), presence_score (logistic, ≈0.5 at
  threshold), confidence_score, breathing_band_estimate (heuristic, FFT-free);
  SignalPipeline (hampel → smooth → DC-remove → baseline-subtract → unwrap,
  non-destructive of validation state) + learn_baseline. 28 tests, clippy-clean,
  forbid(unsafe_code), no heavy deps.
- docs/adr/ADR-096-rvcsi-ffi-crate-layout.md — the implementation ADR: 8-crate
  topology, the napi-c shim record format + contract, the napi-rs Node surface,
  build/test invariants, alternatives. Indexed in docs/adr/README.md.
- CHANGELOG: rvCSI entry updated to cover the implementation crates.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-13 00:00:40 +00:00
Claude 1e684cb208 feat(rvcsi): rvcsi-core + napi-c Nexmon shim + crate skeletons (ADR-095/096)
First implementation milestone for the rvCSI edge RF sensing runtime:

- rvcsi-core — the foundation: CsiFrame/CsiWindow/CsiEvent normalized schema,
  ValidationStatus, AdapterProfile, CsiSource plugin trait, id newtypes +
  IdGenerator, RvcsiError, and the validate_frame pipeline (length/finiteness/
  subcarrier/RSSI/monotonicity hard checks + multiplicative quality scoring →
  Accepted/Degraded/Recovered/Rejected). 29 unit tests, forbid(unsafe_code).
- rvcsi-adapter-nexmon — the napi-c boundary: native/rvcsi_nexmon_shim.{c,h}
  (the only C in the runtime, allocation-free, bounds-checked, parses/writes a
  byte-defined "rvCSI Nexmon record" — a normalized superset of the nexmon_csi
  UDP payload), compiled via build.rs + cc, wrapped by a documented ffi module
  and a NexmonAdapter implementing CsiSource. 9 tests round-tripping through C.
- Workspace registration in v2/Cargo.toml (8 new members + napi/cc workspace
  deps) and compiling skeletons for rvcsi-dsp, rvcsi-events, rvcsi-adapter-file,
  rvcsi-ruvector, rvcsi-node (napi-rs cdylib + build.rs napi_build::setup) and
  rvcsi-cli (`rvcsi` binary) — to be filled in by the implementation swarm.

cargo build -p rvcsi-core -p rvcsi-adapter-nexmon -p rvcsi-node -p rvcsi-cli: OK
cargo test  -p rvcsi-core -p rvcsi-adapter-nexmon: 38 passed, 0 failed

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-12 23:49:58 +00:00
Claude d98b7e3f65 docs: rvCSI edge RF sensing platform — PRD, ADR-095, DDD domain model
Adds design documentation for rvCSI, a Rust-first / TypeScript-accessible /
hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from
Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated
CsiFrame schema, runs reusable DSP, emits typed confidence-scored events,
and bridges to RuVector RF memory, an MCP tool server and a TS SDK.

- docs/prd/rvcsi-platform-prd.md — purpose, users, success criteria,
  FR1-FR10, NFRs (safety/perf/reliability/privacy/security/portability),
  system architecture, runtime components, reference layout, data model
- docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md — the 15 architectural
  decisions (Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized
  schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory,
  replayability, detection != decision, local-first, read-first/write-gated
  MCP, mandatory quality scoring, versioned calibration, plugin adapters)
- docs/ddd/rvcsi-domain-model.md — 7 bounded contexts (Capture, Validation,
  Signal, Calibration, Event, Memory, Agent) with aggregates, invariants,
  context map, data model and domain services
- indexed in docs/adr/README.md and docs/ddd/README.md; CHANGELOG entry

Design-only; no code or crates added yet.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
2026-05-12 23:15:10 +00:00
ruv 6f77b37f5e chore(release): wifi-densepose-train 0.3.0 -> 0.3.1
Publishing the additive changes from PRs #536/#537 to crates.io:
- `signal_features` module — wires `wifi-densepose-signal` into the pipeline
  (audit #1/#2)
- `TrainingConfig::for_subcarriers` / `ht40_192()` / `multiband_168()` presets
  + the real `MmFiDataset` loader integration test (audit #4/#6/#7)

No public API removals or changes — additive only, so 0.3.0 -> 0.3.1 is
semver-correct. No other workspace crate depends on `wifi-densepose-train`,
so this is a standalone bump.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 23:59:50 -04:00
rUv c604ca1150 feat(train): TrainingConfig subcarrier-layout presets + real MmFiDataset loader test (#537)
Closes the remaining doable items from the 2026-05-11 training-pipeline audit:

#6 (CSI format default = 56-sc / 1 NIC) + #7 (multi-band 168-sc mesh not in
config): new `TrainingConfig::for_subcarriers(native, target)` plus named
presets `mmfi()` (114→56), `ht40_192()` (≈192-sc ESP32 HT40 → 56) and
`multiband_168()` (168-sc ADR-078 multi-band mesh → 56). Non-MM-Fi CSI shapes
are now first-class instead of requiring manual `native_subcarriers` /
`num_subcarriers` overrides; the field docs list the supported source counts
and the multi-NIC mapping (a 2–3-node mesh currently rides on `n_rx` until a
dedicated node dimension lands). Model input width stays `num_subcarriers`; the
presets only vary the resampling input.

#4 (proof.rs uses synthetic data): reframed — a deterministic proof *must* use
a reproducible source, so `verify-training` correctly stays on
`SyntheticCsiDataset`. The real gap was that nothing exercised the on-disk
`MmFiDataset` path. New `tests/test_real_loader.rs` writes synthetic CSI to
`.npy` files in the `MmFiDataset::discover` layout, loads it back, and checks
the resulting `CsiSample` — covering the no-interp case, the
subcarrier-interpolation branch, and the empty-root case. Adds `ndarray` /
`ndarray-npy` as dev-deps for the fixture writing.

cargo check + cargo test -p wifi-densepose-train --no-default-features: clean,
all existing tests green, 3 new loader tests + the updated config doctest pass.
Purely additive — no model-shape change, no tch-module change.
2026-05-11 23:49:00 -04:00
rUv eaedfded6f fix(train): wire wifi-densepose-signal into the pipeline; correct MODEL_CARD env-sensor claim (#536)
Addresses three findings from the 2026-05-11 training-pipeline audit:

#1/#2 — `wifi-densepose-signal` was a phantom dependency of `wifi-densepose-train`
(listed in Cargo.toml, never imported), and vitals/CSI signal features were
absent from the pipeline. New module `wifi_densepose_train::signal_features`:
`extract_signal_features(&Array4<f32>, &Array4<f32>) -> Array1<f32>` (and the
convenience method `CsiSample::signal_features()`) runs a windowed observation's
centre frame through `wifi_densepose_signal::features::FeatureExtractor`,
producing a fixed-length (FEATURE_LEN=12) amplitude / phase-coherence / PSD
feature vector — the hook for a future vitals / multi-task supervision head
(breathing- and heart-rate-band power are read off the PSD summary). The vector
is produced on demand and is not yet fed back into the loss; wiring it as a
training target is the documented follow-up. `wifi-densepose-signal` is now an
actually-used dependency. 5 new tests (2 unit in signal_features.rs, 3
integration in tests/test_dataset.rs); existing wifi-densepose-train tests
unchanged and green.

#3 — `docs/huggingface/MODEL_CARD.md` presented PIR/BME280 environmental-sensor
weak-label fine-tuning as a current capability; there is no env-sensor
ingestion in the training pipeline. Marked that path as planned/not-implemented
in the training-steps list and the data-provenance section.

(#5 — README's "92.9% PCK@20" overclaim — fixed separately in PR #535.)

CHANGELOG updated.
2026-05-11 23:40:55 -04:00
rUv bd4f81749a fix(docs): correct unsubstantiated 92.9% PCK@20 camera-supervised claim (#535)
The README claimed "92.9% PCK@20" for camera-supervised pose training. That
figure appears nowhere in ADR-079 (the source ADR) and is ~2.6x the ADR's own
success target (">35% PCK@20"). ADR-079 phases P7 (data collection), P8
(training + evaluation on real paired data) and P9 (cross-room LoRA) are all
still `Pending`, so no measured camera-supervised PCK@20 has been published.

- README: replace the two "92.9% PCK@20" claims with the proxy-supervised
  baseline (~2.5%) and the ADR-079 target (35%+), noting the eval phases are
  pending.
- CHANGELOG: add an Unreleased entry.

Surfaced by the PowerPlatePulse training-pipeline audit (2026-05-11). Six other
audit findings (vitals features absent from training; wifi-densepose-signal
ghost dep; PIR/BME280 in MODEL_CARD unimplemented; proof.rs uses
SyntheticCsiDataset only; 56-subcarrier/1-NIC default; multi-band 168-subcarrier
mesh not in training config) are listed in the PR body for follow-up.
2026-05-11 23:40:52 -04:00
ruv df9d3b0eea fix(plugins): move marketplace manifest to repo root for /plugin marketplace add ruvnet/RuView
Claude Code looks for `.claude-plugin/marketplace.json` at the cloned repo's
ROOT — not in a subdirectory — so `/plugin marketplace add ruvnet/RuView`
(and `claude plugin marketplace add ruvnet/RuView`) was failing with
"Marketplace file not found".

- Move `plugins/.claude-plugin/marketplace.json` → `.claude-plugin/marketplace.json`
  (repo root); the `ruview` plugin's `source` is now `./plugins/ruview`.
- README.md / plugins/ruview/README.md: install instructions now use
  `/plugin marketplace add ruvnet/RuView` + `/plugin install ruview@ruview`
  (with `claude --plugin-dir ./plugins/ruview` as the no-install fallback);
  manifest path references updated.
- plugins/ruview/scripts/smoke.sh: resolve the manifest at the repo root;
  also assert the plugin `source` is `./plugins/ruview`.
- ADR-0001 updated (scope, directory contract, smoke contract, consequences).

Verified: `claude plugin validate .` + `./plugins/ruview` pass; smoke 13/13;
`claude plugin marketplace add ./` → `claude plugin install ruview@ruview` →
`claude plugin details ruview` works end-to-end (16 skill-entries + 3 agents).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 19:52:04 -04:00
ruv 298543913e docs(readme): add Claude Code / Codex plugin + marketplace install instructions
New "🧩 Claude Code & Codex Plugin" section in README.md covering
`claude --plugin-dir`, `claude plugin marketplace add` / `install`, the seven
/ruview-* commands, the Codex prompt mirror, and the smoke check; plus a
Documentation-table row linking to plugins/ruview/README.md.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 19:02:05 -04:00
ruv 8ff7c2c35a feat(plugins): RuView Claude Code + Codex marketplace plugin
Add `plugins/ruview` — an end-to-end toolkit for working with RuView
(WiFi-DensePose) from Claude Code, mirrored as Codex prompts.

Marketplace: `plugins/.claude-plugin/marketplace.json` (one plugin, `ruview`).

Skills (9): ruview-quickstart, ruview-hardware-setup, ruview-configure,
ruview-applications, ruview-model-training, ruview-advanced-sensing,
ruview-cli-api, ruview-mmwave, ruview-verify — shell-first (cargo / python /
idf.py / docker / node), no claude-flow MCP dependency.

Commands (7): /ruview-start, /ruview-flash, /ruview-provision, /ruview-app,
/ruview-train, /ruview-advanced, /ruview-verify.

Agents (3): ruview-onboarding-guide, ruview-config-engineer,
ruview-training-engineer.

Codex mirror: codex/AGENTS.md + codex/README.md + codex/prompts/*.md (full
command parity, enforced by scripts/smoke.sh).

Docs: docs/adrs/0001-ruview-plugin-contract.md (Proposed). Verification:
scripts/smoke.sh (13 structural checks). Provisioning docs reflect the full
`provision.py` flag set (TDM mesh, edge tiers, vitals, hop channels, Cognitum
Seed, swarm intervals) and the issue #391 NVS-namespace-replace gotcha.

Verified: `claude plugin validate` (plugin + marketplace), loads via
`claude --plugin-dir`, smoke 13/13, and confirmed against an attached ESP32-S3
on COM8 running the RuView CSI firmware (live adaptive_ctrl + csi_collector
serial output).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 17:39:16 -04:00
rUv 19ee207d51 Merge pull request #528 from ruvnet/fix/update-submodules-workflow
ci: fix "Update vendor submodules" workflow (git identity + drop --merge)
2026-05-11 12:34:20 -04:00
ruv 8aa7fb9e9f ci: fix "Update vendor submodules" workflow (identity + drop --merge)
The scheduled job has been failing on every run with:

    fatal: empty ident name (...) not allowed
    fatal: Unable to merge '...' in submodule path 'vendor/ruvector'

Two bugs:
1. `git config user.name/email` was only set inside the "Create PR" step,
   but `git submodule update --remote --merge` runs first and the merge
   inside vendor/ruvector needs a committer when the pinned commit isn't a
   fast-forward of upstream `main` → "Committer identity unknown".
2. `--merge` is the wrong operation here. We only want to bump the
   superproject's gitlink to the latest upstream commit on each submodule's
   tracked branch — there's no reason to create merge commits inside the
   vendored repos, and `--merge` breaks whenever the current pin has diverged.

Fix:
- Add a "Configure git identity" step before any commit-creating operation.
- Replace `git submodule update --remote --merge` with
  `git submodule sync --recursive && git submodule update --remote --recursive`
  (detached checkout at each `.gitmodules` branch tip).
- Log the pointer diff in the "Check for changes" step for reviewability.
- Tidy the PR-creation step (identity now set globally; clearer commit/PR text).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 12:33:40 -04:00
rUv f2e3a6a392 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
2026-05-11 11:40:36 -04:00
ruv eda45a6857 ci: fix-marker regression guard (witness-style)
Adds a fast per-PR gate that asserts previously-shipped fixes are still
present in the tree — the CI analogue of the ruflo witness fix-marker
system, but self-contained (no plugin dependency, reviewable as plain
JSON). Complements the heavier checks (firmware build, deterministic
pipeline proof, release witness bundle) by catching the silent-revert
class of regression that build+test wouldn't.

  - scripts/fix-markers.json   manifest: 11 markers (RuView#396, #521,
    #517, #505, #354, #263, #266/#321, #265, #232/#375/#385/#386/#390,
    ADR-028 proof + witness bundle). Each has files / require (literal
    substring or /regex/) / optional forbid / rationale / ref.
  - scripts/check_fix_markers.py  stdlib-only checker. Exit 0 clean /
    1 regression / 2 bad manifest. Modes: --list, --json, --only ID.
  - .github/workflows/fix-regression-guard.yml  runs on PR + push to
    main/master; gates on the checker and writes the result table into
    the run summary + an artifact.

If a fix is intentionally removed, update scripts/fix-markers.json in the
same PR with a rationale — the diff becomes the audit trail.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:14 -04:00
ruv a1cb6bd8e5 fix(firmware): bump version.txt to 0.6.4 + CI guard for tag/version match (#505)
version.txt on main was still 0.6.2. CMake reads PROJECT_VER from it, so
esp_app_get_description()->version (and the boot log line) reported 0.6.2
for any source build — and v0.6.3-esp32 shipped a release binary that
internally identified as 0.6.2 because the bump never landed on main.

  - version.txt: 0.6.2 -> 0.6.4 (matches the latest release tag)
  - firmware-ci.yml: new `version-guard` job that runs on v*-esp32 tag
    pushes and fails the run if the tag's X.Y.Z != version.txt, so a
    future release can't ship a mislabeled binary.

Closes #505

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:14 -04:00
ruv 4d0521ca08 fix(hardware): aggregator tolerates sibling RuView UDP packet magics (#517)
The ESP32 firmware multiplexes several wire packet types onto the same
UDP port as ADR-018 raw CSI frames (magic 0xC5110001):

  0xC5110002  ADR-039 edge vitals (32 B)
  0xC5110003  ADR-069 feature vector
  0xC5110004  ADR-063 fused vitals
  0xC5110005  ADR-039 compressed CSI
  0xC5110006  ADR-081 feature state
  0xC5110007  ADR-095/#513 temporal classification

Esp32CsiParser only knew 0xC5110001, so the standalone `aggregator`
binary printed "parse error: Invalid magic: expected 0xc5110001, got
0xc5110002" for every vitals packet. No CSI data was lost — just noise.

Add the sibling-magic constants + ruview_sibling_packet_name(), classify
recognized siblings before the CSI-frame length gate, and return a new
ParseError::NonCsiPacket { magic, kind } instead of InvalidMagic. The
`aggregator` CLI now skips them quietly (logs "[skipped ADR-039 edge
vitals packet — not a CSI frame]" only with --verbose); the library-level
CsiAggregator already dropped them silently. New regression tests cover
all seven magics.

Closes #517

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:48:00 -04:00
ruv 3f55c95b34 fix(esp32): disable WiFi modem sleep so CSI capture isn't starved (#521)
csi_collector_init() never called esp_wifi_set_ps(), leaving the radio on
the ESP-IDF STA default WIFI_PS_MIN_MODEM. The modem then sleeps between
DTIM beacons; combined with the MGMT-only promiscuous filter (#396) the
CSI callback is starved and the per-second yield collapses toward 0 pps,
which is what users on a clean multi-node setup were seeing
(motion=0.00 presence=0.00 yield=0pps).

Force WIFI_PS_NONE before enabling promiscuous mode — the textbook
requirement for reliable CSI capture (every ESP-IDF CSI example does it).
New boot line: "csi_collector: WiFi modem sleep disabled (WIFI_PS_NONE)
for CSI capture". Battery duty-cycling is unaffected: power_mgmt_init()
runs after this and re-enables modem sleep when provision.py is given
--duty-cycle <100.

Builds clean for esp32s3 (idf.py build, 48% flash free).

Closes #521

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:47:48 -04:00
120 changed files with 15963 additions and 32 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ruview",
"description": "RuView Marketplace: Claude Code + Codex plugins for WiFi sensing — configuration, applications, model training, and onboarding, from practical to advanced",
"owner": {
"name": "ruvnet",
"url": "https://github.com/ruvnet/RuView"
},
"plugins": [
{
"name": "ruview",
"source": "./plugins/ruview",
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
}
]
}
+26
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
@@ -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
+23 -6
View File
@@ -19,8 +19,24 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update submodules to latest main
run: git submodule update --remote --merge
# Identity must be set BEFORE any operation that can create a commit.
# `git submodule update --remote --merge` used to fail here with
# "Committer identity unknown" because the merge inside vendor/ruvector
# needs an author when the pinned commit isn't a fast-forward of upstream.
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Use a plain `--remote` checkout (detached HEAD at each submodule's
# configured `branch` tip from .gitmodules) rather than `--merge`. We only
# want to bump the superproject's gitlink to the latest upstream commit;
# there's no reason to create merge commits inside the vendored repos, and
# `--merge` breaks whenever the current pin has diverged from that branch.
- name: Update submodules to latest tracked branch
run: |
git submodule sync --recursive
git submodule update --remote --recursive
- name: Check for changes
id: check
@@ -29,21 +45,22 @@ jobs:
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "--- submodule pointer changes ---"
git submodule status --recursive || true
git diff --submodule=log -- vendor/ || true
fi
- name: Create PR with updates
if: steps.check.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
BRANCH="chore/update-submodules-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
git add vendor/
git commit -m "chore: update vendor submodules to latest main"
git commit -m "chore: update vendor submodules to latest upstream"
git push origin "$BRANCH"
gh pr create \
--title "chore: update vendor submodules" \
--body "Automated submodule update to latest upstream main." \
--body "Automated submodule update to the latest upstream commit on each submodule's tracked branch (see \`.gitmodules\`). Review the pointer diff before merging." \
--base main \
--head "$BRANCH"
env:
+6
View File
@@ -252,3 +252,9 @@ firmware/esp32-csi-node/build_firmware.batdata/
models/
demo_pointcloud.ply
demo_splats.json
# rvCSI napi-rs addon — generated by `napi build` (do not commit)
v2/crates/rvcsi-node/*.node
v2/crates/rvcsi-node/binding.js
v2/crates/rvcsi-node/binding.d.ts
v2/crates/rvcsi-node/npm/
+4
View File
@@ -10,3 +10,7 @@
path = vendor/sublinear-time-solver
url = https://github.com/ruvnet/sublinear-time-solver
branch = main
[submodule "vendor/rvcsi"]
path = vendor/rvcsi
url = https://github.com/ruvnet/rvcsi
branch = main
+37
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **README: corrected the camera-supervised pose-accuracy claim.** The README stated
"92.9% PCK@20" for camera-supervised training; that figure does not appear in
ADR-079 and is ~2.6× the ADR's own success target (>35% PCK@20). ADR-079 phases
P7 (data collection), P8 (training + evaluation on real paired data) and P9
(cross-room LoRA) are still `Pending`, so no measured camera-supervised PCK@20 has
been published. README now states the proxy-supervised baseline (≈2.5%) and the
ADR-079 target (35%+), and notes the eval phases are pending. Surfaced by the
PowerPlatePulse training-pipeline audit (2026-05-11); 6 remaining audit findings
tracked in the PR.
- **rvCSI `BaselineDriftDetector`: drift thresholds are now scale-relative, not absolute.**
The detector compared `mean_amplitude` against its EWMA baseline with absolute
thresholds (`anomaly_threshold = 1.0`, `drift_threshold = 0.15`) — fine for the
synthetic unit tests (amplitudes ≈ 1.0), but raw ESP32 CSI is `int8` I/Q with
amplitudes up to ~128, so the window-to-window RMS distance is routinely 550 ≫ 1.0
and `AnomalyDetected` fired on ~96 % of windows (319/331 on a real node-1 capture).
Drift is now `‖current baseline‖₂ / ‖baseline‖₂` (a fraction, with an `eps` floor
for a degenerate near-zero baseline), so one tuning works across raw-`int8` ESP32,
`int16`-scaled Nexmon, and baseline-subtracted streams alike — `AnomalyDetected`
drops to 40/331 on the same data, the existing detector tests still pass, and a
`baseline_drift_is_scale_invariant_no_anomaly_storm` regression test was added.
ADR-095 D13 / ADR-096 §2.1, §5 updated. Surfaced by an end-to-end test against
real ESP32 CSI (a 7,000-frame node-1 capture; transcoder at
`scripts/esp32_jsonl_to_rvcsi.py`).
### Added
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types) and a **Nexmon-chip / Raspberry-Pi-model registry** (`NexmonChip` / `RaspberryPiModel` — including the **Raspberry Pi 5** (CYW43455/BCM43455c0, same wireless as the Pi 4 — 20/40/80 MHz, 2.4+5 GHz, 64/128/256 subcarriers), the Pi 3B+/4/400, and the Pi Zero 2 W (BCM43436b0); `nexmon_adapter_profile` / `raspberry_pi_profile` build the per-chip `AdapterProfile`; `chip_ver` words auto-resolve to a chip). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture — the pcap timestamp stamps each frame; the chip is auto-detected from `chip_ver`, overridable via `.with_pi_model(Pi5)` / `.with_chip(...)`). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`; the baseline-drift detector uses **scale-relative** thresholds — drift as a fraction of the baseline's RMS magnitude — so one tuning works across raw-`int8` ESP32, `int16`-scaled Nexmon, and baseline-subtracted streams alike); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime``nexmonDecodeRecords`/`nexmonDecodePcap` (with optional `chip`)/`inspectNexmonPcap`/`decodeChanspec`/`nexmonChipName`/`nexmonProfile`/`nexmonChips`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap [--chip pi5]``.rvcsi`), `inspect`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load.
- **Tests:** 169 across the rvcsi crates (core 29, dsp 28, events 19 — incl. a baseline-drift scale-invariance regression, adapter-file 20 + 1 doctest, adapter-nexmon 28 — round-tripping through the C shim and synthetic libpcap files, incl. Pi 5 / chip-detection, ruvector 20 + 1 doctest, runtime 13, cli 10), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Also exercised end-to-end against a real 7,000-frame ESP32 node-1 capture (transcoded with `scripts/esp32_jsonl_to_rvcsi.py` — the stand-in for the not-yet-shipped `record --source esp32-jsonl`): `rvcsi inspect`/`replay`/`calibrate`/`events` all run on real hardware data. Not yet wired in: live radio capture, `rvcsi-adapter-esp32` (live serial/UDP ESP32 source), the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates.
- **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep").
- **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips.
### Fixed
- **HuggingFace `MODEL_CARD.md`: marked the PIR/BME280 environmental-sensor ground-truth path as planned, not implemented** (training-pipeline audit finding #3) — the card presented PIR/BME280 weak-label fine-tuning as a current capability; there is no env-sensor ingestion in the training pipeline today.
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
### Added
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
+9
View File
@@ -23,6 +23,15 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
| `rvcsi-core` | rvCSI: normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` trait, `validate_frame` pipeline (ADR-095/096) |
| `rvcsi-dsp` | rvCSI: reusable DSP stages (DC removal, phase unwrap, Hampel, smoothing, variance, baseline subtraction, motion/presence/breathing features, `SignalPipeline`) |
| `rvcsi-events` | rvCSI: `WindowBuffer` + `EventDetector` state machines (presence/motion/quality/baseline-drift) + `EventPipeline` |
| `rvcsi-adapter-file` | rvCSI: `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` (deterministic replay) |
| `rvcsi-adapter-nexmon` | rvCSI: the **napi-c** seam — `native/rvcsi_nexmon_shim.{c,h}` (the only C; ABI 1.1; rvCSI-record + real nexmon_csi UDP + chanspec; `build.rs`+`cc`) + pure-Rust pcap reader + Nexmon-chip / Raspberry-Pi-model registry (incl. **Pi 5** = BCM43455c0) + `NexmonAdapter` / `NexmonPcapAdapter` (chip auto-detect) |
| `rvcsi-ruvector` | rvCSI: deterministic RF-memory embeddings, `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` (RuVector standin) |
| `rvcsi-runtime` | rvCSI: composition layer — `CaptureRuntime` (source + validate + DSP + events) + one-shot capture/nexmon-pcap helpers |
| `rvcsi-node` | rvCSI: the **napi-rs** seam — `["cdylib","rlib"]` Node addon; ships the `@ruv/rvcsi` npm package |
| `rvcsi-cli` | rvCSI: the `rvcsi` binary — record/inspect/inspect-nexmon/decode-chanspec/replay/stream/events/health/calibrate/export |
### RuvSense Modules (`signal/src/ruvsense/`)
| Module | Purpose |
+34 -4
View File
@@ -9,7 +9,7 @@
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
> - Camera-free pose accuracy is limited — use [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) for 92.9% PCK@20
> - Camera-free pose accuracy is limited (PCK@20 ≈ 2.5% with proxy labels) — [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) targets **35%+ PCK@20**; the pipeline is implemented, but the data-collection and evaluation phases (ADR-079 P7P9) are still pending, so no measured camera-supervised PCK@20 has been published yet
>
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
@@ -56,7 +56,7 @@ RuView also supports pose estimation (17 COCO keypoints via the WiFlow architect
> | 🧱 **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
> | 🧠 **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $140 total BOM |
> | 🎯 **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
> | 📷 **Camera-supervised training** | MediaPipe + ESP32 CSI → 92.9% PCK@20 | 19 min on laptop |
> | 📷 **Camera-supervised training** | MediaPipe + ESP32 CSI → **35%+ PCK@20 target** (ADR-079; eval phases pending) | ~19 min on laptop (pipeline) |
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
> | 🌐 **3D point cloud** *(optional fusion)* | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame |
@@ -485,14 +485,44 @@ See [`docs/adr/ADR-024-contrastive-csi-embedding-model.md`](docs/adr/ADR-024-con
---
## 🧩 Claude Code & Codex Plugin
RuView ships a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin (and Codex prompt mirror) that wraps the whole workflow — onboarding, ESP32 setup, configuration, sensing apps, model training, advanced multistatic sensing, CLI/API/WASM, mmWave radar, and witness verification — as 9 skills, 7 `/ruview-*` commands, and 3 agents. It lives in [`plugins/ruview/`](plugins/ruview/README.md); the marketplace manifest is [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) at the repo root.
```bash
# In Claude Code — add this repo as a plugin marketplace, then install:
/plugin marketplace add ruvnet/RuView
/plugin install ruview@ruview
# Or try it for one session without installing (from a local clone of the repo):
claude --plugin-dir ./plugins/ruview
# Then, in Claude Code:
# /ruview-start → onboarding (Docker demo / repo build / live ESP32)
# /ruview-flash → build + flash ESP32 firmware
# /ruview-provision → provision WiFi creds, sink IP, channel/MAC, mesh slots
# /ruview-app → run a sensing application (presence / vitals / pose / sleep / MAT / point cloud)
# /ruview-train → train / evaluate / publish a model (incl. GPU on GCloud)
# /ruview-advanced → multistatic / tomography / cross-viewpoint / mesh-security
# /ruview-verify → tests + deterministic proof + witness bundle
```
**Codex (OpenAI CLI):** `cp plugins/ruview/codex/prompts/*.md ~/.codex/prompts/` — the seven `/ruview-*` commands are mirrored as Codex prompts; [`plugins/ruview/codex/AGENTS.md`](plugins/ruview/codex/AGENTS.md) carries the project rules. See [`plugins/ruview/codex/README.md`](plugins/ruview/codex/README.md).
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
---
## 📖 Documentation
| Document | Description |
|----------|-------------|
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
| [Architecture Decisions](docs/adr/README.md) | 79 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 7 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](docs/prd/rvcsi-platform-prd.md) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md); 9 `rvcsi-*` crates + the `@ruv/rvcsi` napi-rs SDK) |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
| [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable |
| [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog |
@@ -0,0 +1,210 @@
# ADR-095: rvCSI — Edge RF Sensing Runtime Platform
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-12 |
| **Deciders** | ruv |
| **Codename** | **rvCSI** — RuVector Channel State Information runtime |
| **Relates to** | ADR-012 (ESP32 CSI mesh), ADR-013 (feature-level sensing on commodity gear), ADR-014 (SOTA signal processing), ADR-016 (RuVector integration), ADR-024 (AETHER contrastive embeddings), ADR-031 (RuView sensing-first RF mode), ADR-040 (WASM programmable sensing), ADR-049 (cross-platform WiFi interface detection) |
| **PRD** | [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md) |
| **Domain model** | [rvCSI Domain Model](../ddd/rvcsi-domain-model.md) |
---
## 1. Context
WiFi Channel State Information (CSI) is a powerful camera-free sensing primitive — but in practice it is hard to operationalize. Most CSI pipelines today are Linux shell scripts, patched firmware, kernel modules, Python notebooks, PCAP dumps, and ad-hoc signal processing. Packet formats are inconsistent across chips; drivers are unstable; malformed packets are common; and device-specific assumptions leak everywhere. CSI works in the lab and falls over in the field.
RuView already contains substantial CSI infrastructure (`wifi-densepose-signal`, `wifi-densepose-ruvector`, the ESP32 mesh of ADR-012, the RuView multistatic work of ADR-031). What is missing is a **stable, hardware-abstracted runtime layer** that:
- ingests CSI from many sources behind one interface,
- validates every packet before it can touch application code,
- normalizes everything into one schema,
- runs reusable signal processing,
- emits typed, confidence-scored events,
- exposes a safe TypeScript SDK, a CLI, MCP tools, and a RuVector bridge,
- and runs unattended on Raspberry Pi-class hardware.
This ADR establishes that runtime — **rvCSI** — and the architectural decisions that constrain it. Detailed requirements are in the [PRD](../prd/rvcsi-platform-prd.md); the bounded contexts, aggregates, and ubiquitous language are in the [domain model](../ddd/rvcsi-domain-model.md).
### 1.1 What rvCSI is not (day one)
rvCSI is *not* a pure-Rust replacement for vendor firmware patches, *not* a universal driver for all WiFi chips, and *not* an identity/pose/medical/legal-grade claim. It is a **structural sensing** runtime: excellent at detecting change, presence, motion, drift, and learned patterns; deliberately silent on exact identity, exact pose, and certainty guarantees. The product surface stays inside that boundary (see Decision D7).
### 1.2 Existing assets rvCSI builds on
| Asset | Source | Reuse in rvCSI |
|-------|--------|----------------|
| SOTA DSP (Hampel, phase unwrap, Fresnel, BVP, spectrograms) | `wifi-densepose-signal` (ADR-014) | `rvcsi-dsp` wraps/extends rather than re-implements |
| RuVector integration (5 crates) | `wifi-densepose-ruvector` (ADR-016) | `rvcsi-ruvector` exporter rides on the existing integration |
| ESP32 CSI firmware + aggregator | `wifi-densepose-hardware` / firmware (ADR-012) | `rvcsi-adapter-esp32` consumes the existing serial/UDP stream |
| AETHER contrastive embeddings | ADR-024 | optional embedding backend for window/event vectors |
| Cross-platform interface detection | ADR-049 | adapter discovery / health checks |
---
## 2. Decision
**Adopt rvCSI as a layered edge RF sensing runtime** with the boundary discipline `C → Rust → TypeScript`, a single normalized `CsiFrame` schema, mandatory validation before any language boundary crossing, and RuVector as RF memory. The fifteen decisions below are the architectural contract.
### D1 — Rust is the core runtime
CSI parsing and DSP require memory safety, predictable latency, and high throughput; C/Python research stacks are fragile for unattended edge deployment. **rvCSI uses Rust** for parsing, validation, signal processing, event extraction, and daemon execution.
*Consequences:* safer packet handling; better long-running stability; stronger portability to edge devices; more complex build system than pure TypeScript.
### D2 — C only at the hardware-compatibility boundary
Nexmon and similar CSI sources often require C shims, legacy drivers, or firmware-patch hooks. **C is isolated to thin shims** for existing capture and firmware compatibility — never in the data path beyond decode.
*Consequences:* existing Nexmon capability reused; unsafe surface stays small; full firmware rewrite avoided; some device support stays dependent on upstream tools.
### D3 — TypeScript for SDK, CLI, and developer orchestration
Developers need an approachable SDK, agent integrations, dashboards, and scripts. **rvCSI exposes a first-class TypeScript SDK** (`@ruv/rvcsi`) and CLI; native performance stays in Rust.
*Consequences:* easy adoption by app/agent developers; native perf preserved; requires a native build + prebuild release pipeline.
### D4 — napi-rs for Node bindings
Native Node modules need a stable ABI and ergonomic Rust integration. **rvCSI uses napi-rs** for the `rvcsi-node` bindings.
*Consequences:* Rust exposes typed APIs to TypeScript; prebuilt binaries distributable; careful memory-ownership rules required.
### D5 — Normalize all sources into one `CsiFrame` / `CsiWindow` schema
Different CSI sources expose incompatible formats; application code must not know device-specific details. **Every source is normalized into `CsiFrame` and `CsiWindow`** (schema in the domain model).
*Consequences:* hardware-agnostic application code; easier RuVector integration; some source-specific metadata needs extension fields.
### D6 — Validate before crossing language boundaries
Malformed packets and unsafe pointers are the dominant stability risk. **All raw data is validated in Rust before it crosses into TypeScript or RuVector**; rejected frames are quarantined (when enabled); parser failures return structured errors; TypeScript never receives raw unchecked pointers.
*Consequences:* safer SDK; cleaner error model; small validation overhead.
### D7 — Treat CSI as a temporal delta, not absolute truth
CSI is noisy and environment-specific. **rvCSI frames CSI as a temporal delta stream against learned baselines**, not as exact vision.
*Consequences:* honest product claims; good fit for presence/motion/drift/anomaly; identity and exact pose excluded from core claims.
### D8 — RuVector is RF memory
CSI becomes far more valuable stored as temporal embeddings and room signatures. **rvCSI integrates with RuVector** for vector storage, similarity search, drift detection, and sensor-graph relationships.
*Consequences:* rvCSI joins the broader ruvnet cognitive stack; RF field history becomes queryable; requires embedding design and retention policy.
### D9 — Design for replayability
Signal algorithms need repeatable benchmarks and debugging. **rvCSI supports deterministic replay** of captured sessions (timestamps, ordering, validation decisions, event output, calibration version, runtime config all preserved).
*Consequences:* easier testing; better audit trail; enables benchmark datasets.
### D10 — Separate detection from decision
rvCSI detects RF events; agents/applications decide what to do. **rvCSI emits events with confidence and evidence and performs no high-consequence actions by default.**
*Consequences:* cleaner safety model; clean integration with Cognitum proof-gated execution; applications implement policy.
### D11 — Local-first operation
RF sensing is privacy-sensitive and often valuable offline. **rvCSI runs locally by default and requires no cloud service**; remote observability is opt-in.
*Consequences:* better privacy posture; usable in industrial/care/sovereign deployments; remote observability must be explicitly enabled.
### D12 — MCP tools are read-first, write-gated
Agents should observe RF state safely; device mutation and calibration change system behavior. **MCP tools default to read actions**; capture start/stop, calibration, and export are gated.
*Consequences:* safer agent integration; lower accidental device disruption; more explicit operational control.
### D13 — Quality scoring is mandatory
CSI quality varies widely by chip, antenna, environment, channel, and interference. **Every frame, window, and event carries quality or confidence scoring.**
*Consequences:* downstream systems can suppress weak evidence; easier debugging; requires calibration and thresholds. Where a detector compares against a learned baseline (e.g. baseline-drift / anomaly), thresholds are expressed **relative to the baseline's magnitude**, not as absolute amplitude units, so a single tuning is valid across sources whose raw CSI scales differ by orders of magnitude (raw `int8` ESP32 vs. `int16`-scaled Nexmon vs. baseline-subtracted streams).
### D14 — Versioned calibration profiles
Room baselines change over time. **Calibration profiles are versioned**, and event outputs reference the calibration version used.
*Consequences:* more auditable detection; replay can reproduce prior outputs; slight storage overhead.
### D15 — Hardware adapters are plugins
Device support will evolve and vary by platform. **Source adapters are plugins behind a common Rust trait** (`CsiSource`).
*Consequences:* easier support for Nexmon/ESP32/Intel/Atheros/SDR/future sources; cleaner testability; adapter certification becomes important.
---
## 3. Architecture
```
CSI Source
↓ ┌─ Capture context ──────────────┐
Adapter Layer (C shims here) │ Source · CaptureSession · │
↓ │ AdapterProfile │
Rust Validation Pipeline ─────┤ Validation context │
↓ │ ValidationPolicy · Quarantine │
Normalized CsiFrame ──────────┘ ← FFI-safe boundary object
↓ ┌─ Signal context ───────────────┐
Signal Processing │ SignalPipeline · WindowBuffer │
↓ ├─ Calibration context ──────────┤
Window Aggregator ───────────┤ CalibrationProfile · │
↓ │ RoomSignature · BaselineModel │
Event Extractor ─────────────┤ Event context │
↓ │ EventDetector · StateMachine │
TS SDK · CLI · MCP · RuVector └─ Memory + Agent contexts ──────┘
```
**Crates (within RuView's `v2/crates/`, or a standalone `rvcsi/crates/`):**
`rvcsi-core` · `rvcsi-adapter-file` · `rvcsi-adapter-nexmon` · `rvcsi-adapter-esp32` · `rvcsi-dsp` · `rvcsi-events` · `rvcsi-ruvector` · `rvcsi-daemon` · `rvcsi-node` · `rvcsi-mcp` — plus TypeScript packages `sdk`, `cli`, `dashboard`, and `native/nexmon-shim-c`.
See the [PRD §9](../prd/rvcsi-platform-prd.md#9-system-architecture) for the full component table and reference layout, and the [domain model](../ddd/rvcsi-domain-model.md) for bounded contexts, aggregates, invariants, and domain services.
---
## 4. Consequences
**Positive**
- CSI becomes reusable infrastructure: npm-installable, reproducible, typed, safe-parsed, embeddable, WebSocket-streamable, WASM-portable, MCP-exposed, agent-integrable.
- One application codebase works across Nexmon, ESP32, Intel, and Atheros sources.
- Bad packets cannot crash the daemon; unattended operation becomes realistic.
- RuView/RuVector/Cognitum/agents gain a validated live source of RF observations.
- Honest product framing ("structural sensing") avoids over-claiming.
**Negative / costs**
- Larger build surface: Rust core + napi-rs native module + C shims + TypeScript packages + prebuild pipeline.
- Adapter certification and a supported-hardware matrix become ongoing maintenance.
- Embedding design, calibration thresholds, and retention policy are non-trivial open questions (tracked in the PRD).
- Risk of duplicating `wifi-densepose-signal` / `wifi-densepose-ruvector`; mitigated by wrapping, not re-implementing.
**Risks**
- Nexmon coupling: some device support remains dependent on upstream firmware/driver projects.
- CSI quality variance: weak-signal environments may yield low-confidence events; mitigated by mandatory quality scoring (D13) and versioned calibration (D14).
---
## 5. Alternatives considered
| Alternative | Why not |
|-------------|---------|
| Pure-Python runtime (extend the v1 stack) | Fragile under malformed packets; GC pauses break the < 50 ms latency target; poor unattended stability. |
| Pure-Rust including firmware (replace Nexmon) | Enormous scope; vendor-specific; would block v0 indefinitely. D2 keeps C at the boundary instead. |
| Per-source SDKs (no normalized schema) | Pushes device specifics into application code; defeats the "same app code across adapters" success criterion. |
| WASM-only core | No raw socket / serial / monitor-mode access for live capture; fine for offline parsing (a later target) but not v0 live capture. |
| Cloud-first ingestion | Violates the privacy posture and the local-first requirement; unacceptable for care/industrial/sovereign deployments. |
---
## 6. Implementation phases (proposed)
1. **v0**`rvcsi-core` + file/replay/ESP32 adapters + validation + `rvcsi-dsp` (presence/motion) + `rvcsi-node` SDK + `rvcsi-cli` + WebSocket output + `rvcsi-ruvector` export + basic calibration + health checks. Targets all eight PRD success criteria.
2. **v1** — multi-node sync, RF room signatures, breathing-rate where signal permits, temporal embeddings, drift detection, room-topology graph, `rvcsi-mcp` tool server, replayable benchmark datasets, RuView sensor fusion, Cognitum deployment profile.
3. **v2** — hardware-agnostic RF sensor fabric, multi-room RF memory, streaming anomaly detection, RF-SLAM research mode, on-device embedding model, federated room-signature learning, signed sensor-evidence records, proof-gated event publication, dynamic cut-based coherence over RF graphs, agent-driven calibration and self-repair.
---
## 7. References
- [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md)
- [rvCSI Domain Model](../ddd/rvcsi-domain-model.md)
- ADR-012 — ESP32 CSI Sensor Mesh
- ADR-013 — Feature-Level Sensing on Commodity Gear
- ADR-014 — SOTA Signal Processing
- ADR-016 — RuVector Integration
- ADR-024 — Project AETHER: Contrastive CSI Embeddings
- ADR-031 — RuView Sensing-First RF Mode
- ADR-040 — WASM Programmable Sensing
- ADR-049 — Cross-Platform WiFi Interface Detection
+144
View File
@@ -0,0 +1,144 @@
# ADR-096: rvCSI — Crate Topology, the napi-c Shim, and the napi-rs Node Surface
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-12 |
| **Deciders** | ruv |
| **Codename** | **rvCSI** — RuVector Channel State Information runtime |
| **Relates to** | ADR-095 (rvCSI platform — D1 Rust core, D2 C-at-the-boundary, D3 TS SDK, D4 napi-rs, D5 normalized schema, D6 validate-before-FFI, D15 plugin adapters), ADR-009/ADR-040 (WASM runtimes), ADR-049 (cross-platform WiFi interface detection) |
| **PRD** | [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md) |
| **Domain model** | [rvCSI Domain Model](../ddd/rvcsi-domain-model.md) |
| **Implements** | `v2/crates/rvcsi-core`, `rvcsi-dsp`, `rvcsi-events`, `rvcsi-adapter-file`, `rvcsi-adapter-nexmon`, `rvcsi-ruvector`, `rvcsi-node`, `rvcsi-cli` |
---
## 1. Context
ADR-095 set the platform-level invariant `C → Rust → TypeScript` and the fifteen decisions that constrain rvCSI. This ADR makes the *implementation* concrete: which crates exist, what each owns, where the two FFI seams are (the **napi-c** C shim below Rust, and the **napi-rs** Node addon above it), and the rules that keep `unsafe` confined and the boundary objects validated.
The two seams:
- **napi-c** — the *downward* seam to fragile vendor/firmware/driver code. Per ADR-095 D2, C is the only language allowed here, and only as a thin, allocation-free, bounds-checked shim. The Nexmon family is the first consumer.
- **napi-rs** — the *upward* seam to Node.js/TypeScript. Per ADR-095 D3/D4, the Rust runtime is exposed to JS via [napi-rs](https://napi.rs/); nothing crosses this seam that hasn't been validated (D6) and normalized (D5).
Both seams are *narrow on purpose*: everything in between — parsing, validation, DSP, windowing, event extraction, RuVector export — is safe Rust (`#![forbid(unsafe_code)]` in every crate except `rvcsi-adapter-nexmon`, which needs `extern "C"`).
---
## 2. Decision
### 2.1 Crate topology
Eight new workspace members under `v2/crates/`:
| Crate | `unsafe`? | Depends on | Owns |
|-------|-----------|------------|------|
| `rvcsi-core` | no (`forbid`) | — (serde, thiserror) | The normalized schema (`CsiFrame`/`CsiWindow`/`CsiEvent`), `AdapterProfile`, the `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, and the `validate_frame` pipeline + quality scoring. The shared kernel. |
| `rvcsi-dsp` | no (`forbid`) | `rvcsi-core` | Reusable DSP stages (DC removal, phase unwrap, smoothing, Hampel/MAD outlier filter, sliding variance, baseline subtraction) and scalar features (motion energy, presence score, confidence, heuristic breathing-band estimate), plus a non-destructive `SignalPipeline::process_frame`. |
| `rvcsi-events` | no (`forbid`) | `rvcsi-core` | `WindowBuffer` (frames → `CsiWindow`), the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, and `EventPipeline` (windows → `CsiEvent`s). The baseline-drift detector measures drift **relative to the running baseline's RMS magnitude** (a fraction, not absolute amplitude units), so the same thresholds work for raw `int8` ESP32 CSI, `int16`-scaled Nexmon CSI, and baseline-subtracted streams alike — see ADR-095 D13. |
| `rvcsi-adapter-file` | no (`forbid`) | `rvcsi-core` | The `.rvcsi` capture format (JSONL: a header line + one `CsiFrame` per line), `FileRecorder`, and `FileReplayAdapter` (a `CsiSource`) — deterministic replay (D9). |
| `rvcsi-adapter-nexmon` | **yes** (FFI only) | `rvcsi-core` + the C shim | The **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` compiled via `build.rs`+`cc`, a documented `ffi` module wrapping it, a pure-Rust libpcap reader (`pcap.rs`), the Nexmon-chip / Raspberry-Pi-model registry (`chips.rs``NexmonChip`, `RaspberryPiModel` incl. **Pi 5**, profile builders), and two `CsiSource`s — `NexmonAdapter` (rvCSI-record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP payloads inside a `.pcap`, with chip auto-detection). |
| `rvcsi-ruvector` | no (`forbid`) | `rvcsi-core` | The RuVector RF-memory bridge: deterministic `window_embedding`/`event_embedding`, `cosine_similarity`, the `RfMemoryStore` trait, and `InMemoryRfMemory` + `JsonlRfMemory` (a standin until the production RuVector binding lands). |
| `rvcsi-runtime` | no (`forbid`) | core, dsp, events, adapter-file, adapter-nexmon, ruvector | The composition layer (no FFI): `CaptureRuntime` (a `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`) plus one-shot helpers (`summarize_capture`, `decode_nexmon_records`, `decode_nexmon_pcap`, `summarize_nexmon_pcap`, `events_from_capture`, `export_capture_to_rf_memory`). The shared layer under `rvcsi-node` and `rvcsi-cli`. |
| `rvcsi-node` | no (`deny(clippy::all)`) | `rvcsi-core`, `rvcsi-runtime`, `rvcsi-adapter-nexmon` | The **napi-rs** seam: the `.node` addon (cdylib + rlib) exposing a safe TS-facing surface (thin `#[napi]` wrappers over `rvcsi-runtime`); `build.rs` runs `napi_build::setup()`. |
| `rvcsi-cli` | no | core, adapter-file, adapter-nexmon, runtime | The `rvcsi` binary: `record` (Nexmon-dump or nexmon-pcap → `.rvcsi`), `inspect`, `inspect-nexmon`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate`, `export ruvector` (ADR-095 FR7). |
`rvcsi-events` does **not** call into `rvcsi-dsp`: window statistics are simple enough to compute in `WindowBuffer` itself, and keeping the two leaves independent removes a coordination point. `rvcsi-cli` does **not** depend on `rvcsi-node` (a binary can't link a napi cdylib's undefined Node symbols) — the shared logic lives in `rvcsi-runtime`, which both build on. Higher layers wire `SignalPipeline::process_frame``WindowBuffer::push` when they want cleaned frames.
The MCP tool server (`rvcsi-mcp`) and the long-running daemon (`rvcsi-daemon`) — and live radio capture — are *not* in this ADR's scope; they sit on top of `rvcsi-runtime` / the crates above and are tracked as follow-ups. The `@ruv/rvcsi` npm package ships alongside `rvcsi-node`.
### 2.2 The napi-c shim — record formats and contract
`native/rvcsi_nexmon_shim.{c,h}` is the only C in the runtime. It handles **two byte formats** (ABI `1.1`):
**(1) The "rvCSI Nexmon record"** — a compact, self-describing record (`'RVNX'` magic, version, flags, RSSI/noise, channel, bandwidth, timestamp, then interleaved `int16` I/Q in Q8.8 fixed point; total `24 + 4*N`). Used by the `rvcsi capture`/`record` recorder, the file replay path, and tests. Functions: `rvcsi_nx_record_len`, `rvcsi_nx_parse_record`, `rvcsi_nx_write_record`.
**(2) The *real* nexmon_csi UDP payload** — what the patched Broadcom firmware actually sends to the host (port 5500 by default): the 18-byte header `magic=0x1111 (2) · rssi int8 (1) · fctl (1) · src_mac (6) · seq_cnt (2) · core/stream (2) · chanspec (2) · chip_ver (2)`, followed by `nsub` complex CSI samples. The shim implements the **modern int16 I/Q export** (`nsub` pairs of little-endian `int16` `(real, imag)`, raw counts — what CSIKit / `csireader.py` read for the BCM43455c0 / 4358 / 4366c0); `nsub` is derived from the payload length, `(len 18) / 4`. Functions: `rvcsi_nx_csi_udp_header` (just the 18-byte header), `rvcsi_nx_csi_udp_decode` (header + CSI body, `csi_format` selector), `rvcsi_nx_csi_udp_write` (synthesize a payload — tests/examples), and `rvcsi_nx_decode_chanspec` (decode a Broadcom d11ac chanspec word → `channel` = `chanspec & 0xff`, bandwidth from bits `[13:11]` cross-checked against the FFT size, band from bits `[15:14]` cross-checked against the channel number). The legacy nexmon *packed-float* export used by some 4339/4358 firmwares is a documented follow-up (it sits behind the same `csi_format` selector).
The `timestamp_ns` of a frame from format (2) comes from the **pcap packet timestamp**, not the wire (nexmon_csi doesn't carry one). The pcap file itself is parsed in **pure Rust** (`rvcsi-adapter-nexmon::pcap` — classic libpcap, all four byte-order/timestamp-resolution magics, Ethernet / raw-IPv4 / Linux-SLL link types; pcapng is a follow-up): peeling the Ethernet/IPv4/UDP headers down to the payload is not a vendor-fragility concern, so it doesn't belong in C.
Contract (both formats):
- **Allocation-free, global-free.** Every read is bounds-checked against the caller-supplied length; nothing can scribble outside caller buffers; no `malloc`, no statics.
- **Structured errors, never panics.** Functions return one of a small set of `RvcsiNxError` codes (`TOO_SHORT`, `BAD_MAGIC`, `BAD_VERSION`, `CAPACITY`, `TRUNCATED`, `ZERO_SUBCARRIERS`, `TOO_MANY_SUBCARRIERS`, `NULL_ARG`, `BAD_NEXMON_MAGIC`, `BAD_CSI_LEN`, `UNKNOWN_FORMAT`); `rvcsi_nx_strerror` maps each to a static string.
- **ABI versioned.** `rvcsi_nx_abi_version()` returns `major << 16 | minor` (`0x0001_0001`); the Rust side `debug_assert`s the major matches the header it was compiled against. The minor was bumped from `1.0``1.1` when the format-(2) entry points landed (additive — format (1) is unchanged).
- The Rust `ffi` module wraps these in safe functions (`record_len`, `decode_record`, `encode_record`, `decode_chanspec`, `parse_nexmon_udp_header`, `decode_nexmon_udp`, `encode_nexmon_udp`, `shim_abi_version`); every `unsafe` block is limited to the FFI call (and reading back C-initialised structs) and carries a `// SAFETY:` comment, per the project rule.
**Chip registry (`rvcsi-adapter-nexmon::chips`).** nexmon_csi runs on a handful of patched Broadcom/Cypress chips; `NexmonChip` names them, `RaspberryPiModel` maps Pi boards to their chip, and `nexmon_adapter_profile` / `raspberry_pi_profile` build the [`AdapterProfile`] (supported channels / bandwidths / expected subcarrier counts — 20→64, 40→128, 80→256, 160→512) `validate_frame` bounds CSI frames against. The **Raspberry Pi 5** carries the same **CYW43455 / BCM43455c0** 802.11ac wireless as the Pi 3B+ / Pi 4 / Pi 400 (20/40/80 MHz, 2.4 + 5 GHz) — the chip with the most mature nexmon_csi support — so `RaspberryPiModel::Pi5 → NexmonChip::Bcm43455c0`; the Pi Zero 2 W is `Bcm43436b0` (2.4 GHz, ≤40 MHz). `NexmonPcapAdapter` **auto-detects** the chip from each packet's `chip_ver` word (`0x4345``Bcm43455c0`, etc.) and uses the matching profile; `.with_chip(...)` / `.with_pi_model(...)` override it. `NexmonChip::from_chip_ver` and the `chip_ver` field are best-effort/preserved respectively — the c0/b0 revision suffix isn't carried by that word, and the int16-vs-packed-float export distinction is handled by the `csi_format` selector, not by chip-ver parsing.
A real deployment captures with `tcpdump -i wlan0 dst port 5500 -w csi.pcap` on the Pi and feeds the `.pcap` to `NexmonPcapAdapter::open` (or `rvcsi record --source nexmon-pcap --in csi.pcap --out cap.rvcsi --chip pi5`, then the rest of the toolchain works on the `.rvcsi`; `rvcsi inspect-nexmon` reports the resolved chip, `rvcsi nexmon-chips` lists the matrix). Production *live* capture (binding the UDP socket, monitor mode, firmware patch hooks) is a later increment that reuses the same shim parse path — the shim's job is the *parse*, not the *socket*.
### 2.3 The napi-rs surface — what crosses the seam
`rvcsi-node` is a `["cdylib", "rlib"]` crate (cdylib = the `.node` addon; rlib so `cargo test --workspace` can link and test the Rust side without Node). Rules:
- **Only normalized/validated data crosses.** The boundary types are JS-friendly mirrors of `CsiFrame`/`CsiWindow`/`CsiEvent`/`AdapterProfile`/`SourceHealth`, or plain JSON strings — never raw pointers, never `Pending` frames. A frame is run through `rvcsi_core::validate_frame` before it is handed to JS.
- **Errors map to JS exceptions** via napi-rs's `Result` integration; `RvcsiError`'s `Display` is the message.
- **The build emits link args + `binding.js`/`binding.d.ts`** via `napi_build::setup()` in `build.rs`; the `@ruv/rvcsi` npm package's hand-written `index.js`/`index.d.ts` wrap that loader and `JSON.parse` the addon's returns into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects.
- The free functions exposed are: `rvcsiVersion`, `nexmonShimAbiVersion` (the linked shim's ABI), `nexmonDecodeRecords`, `nexmonDecodePcap`, `inspectNexmonPcap`, `decodeChanspec`, `inspectCaptureFile`, `eventsFromCaptureFile`, `exportCaptureToRfMemory`; plus the `RvcsiRuntime` streaming class (`openCaptureFile` / `openNexmonFile` / `openNexmonPcap` factories + `nextFrameJson` / `nextCleanFrameJson` / `drainEventsJson` / `healthJson`).
### 2.4 Build & test invariants
- `cargo build --workspace` and `cargo test --workspace --no-default-features` (the repo's pre-merge gate) must stay green; the new crates add tests and don't regress the existing 1,031+.
- `rvcsi-node` stays a workspace *member* (not `exclude`d like `wifi-densepose-wasm-edge`): on Linux/macOS a napi cdylib links fine with Node symbols left undefined (resolved at addon-load time), so `cargo build`/`cargo test` work without a Node toolchain. Only `napi build` (npm packaging) needs Node.
- No new heavy dependencies in the rvCSI crates: `serde`, `serde_json`, `thiserror`, `cc` (build only), `napi`/`napi-derive`/`napi-build`, `clap` (CLI only), `tempfile` (dev only). DSP math is hand-rolled — no `ndarray`/`rustfft`.
---
## 3. Consequences
**Positive**
- The two FFI seams are small, audited, and independently testable: the C shim round-trips through Rust tests; the napi surface tests run under `cargo test` without Node.
- `unsafe` is confined to one crate (`rvcsi-adapter-nexmon`) and within it to one module (`ffi`), every block documented.
- Each leaf crate (`rvcsi-dsp`, `rvcsi-events`, `rvcsi-adapter-file`, `rvcsi-ruvector`) depends only on `rvcsi-core`, so they can evolve (and be reviewed, and be swarm-implemented) independently.
- The `.rvcsi` JSONL capture format and the `JsonlRfMemory` standin make the whole pipeline runnable and testable end-to-end before any hardware or the real RuVector binding exists.
**Negative / costs**
- A `cc`-built C library means a C toolchain is required to build `rvcsi-adapter-nexmon` (already true for many workspace crates via transitive `cc` deps; acceptable).
- The "rvCSI Nexmon record" is a *normalized* format, not byte-identical to any upstream nexmon_csi build — a thin demux/transcode step is needed when wiring real Nexmon output. This is intentional (we control the contract the shim parses) and documented.
- JSONL captures are larger than a packed binary format; fine for v0 (and the PRD already standardizes on JSON/WebSocket on the wire), revisit if capture size becomes a problem.
- `rvcsi-node` as a workspace member adds the `napi` dependency tree to `cargo build --workspace`; mitigated by it being a small, well-maintained crate.
**Risks**
- napi-rs major-version churn could change the macro/`build.rs` surface; pinned to `napi = "2.16"` in workspace deps, bumped deliberately.
- If a future platform can't link a napi cdylib under plain `cargo build`, `rvcsi-node` moves to the workspace `exclude` list (like `wifi-densepose-wasm-edge`) with a separate build command — same pattern, already established.
---
## 4. Alternatives considered
| Alternative | Why not |
|-------------|---------|
| One mega-crate `rvcsi` instead of eight | Couples DSP/events/adapters/FFI; can't review or implement them independently; bloats compile units for downstream users who only want `rvcsi-core`. |
| `bindgen` for the C shim | Pulls in `libclang`; the shim's C API is six functions — hand-written `extern "C"` decls are clearer and dependency-free. |
| Binary `.rvcsi` capture format (bincode/custom) | Smaller, but not human-inspectable; JSONL is debuggable, append-friendly, and matches the PRD's on-the-wire JSON. Revisit if size matters. |
| Expose raw `CsiFrame` pointers / typed arrays across napi for zero-copy | Violates ADR-095 D6 (validate-before-FFI) and the "no raw pointers to TS" safety NFR; the per-frame copy cost is negligible at the target rates. |
| `wasm-bindgen` instead of napi-rs for the JS surface | WASM can't do live capture (no raw sockets/serial); great for offline parsing (a later target) but not the primary Node runtime. |
| `rvcsi-events` depending on `rvcsi-dsp` for window stats | Adds a coordination point for two leaf crates; the stats are a few lines — keep the leaves independent and let higher layers compose them. |
---
## 5. Status of the implementation
- `rvcsi-core` — implemented, `forbid(unsafe_code)`, 29 unit tests.
- `rvcsi-adapter-nexmon` + the napi-c shim — implemented; C (ABI `1.1`) compiled via `build.rs`+`cc`; the `ffi` module wraps both record formats (rvCSI record **and** the real nexmon_csi UDP payload + chanspec decode); a pure-Rust `pcap` reader; the Nexmon-chip / Raspberry-Pi-model registry (`chips.rs` — incl. **Pi 5 → BCM43455c0** + chip auto-detection from `chip_ver`); `NexmonAdapter` + `NexmonPcapAdapter` `CsiSource`s; 28 tests, several round-tripping through the C shim and through synthetic libpcap files.
- `rvcsi-dsp` (28 tests), `rvcsi-events` (19 tests — incl. a scale-invariance regression for the baseline-drift detector), `rvcsi-adapter-file` (20 + 1 doctest), `rvcsi-ruvector` (20 + 1 doctest) — implemented.
- `rvcsi-runtime` (13 tests) — composition layer + the one-shot helpers, including `decode_nexmon_pcap` / `decode_nexmon_pcap_for` (per-chip) / `summarize_nexmon_pcap` / `nexmon_profile_for`.
- `rvcsi-node` (napi-rs surface — incl. `nexmonDecodePcap` (with `chip`) / `inspectNexmonPcap` / `decodeChanspec` / `nexmonChipName` / `nexmonProfile` / `nexmonChips` / `RvcsiRuntime.openNexmonPcap`) and `rvcsi-cli` (10 tests — incl. `record --source nexmon-pcap [--chip pi5]`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`) — implemented; the `@ruv/rvcsi` npm package + a Node smoke test ship alongside.
- Totals: 169 rvcsi unit/integration tests + 2 doctests, 0 failures; all rvcsi crates build together and are clippy-clean.
- **Validated against real ESP32 CSI** (a 7,000-frame node-1 capture, transcoded to `.rvcsi` via `scripts/esp32_jsonl_to_rvcsi.py` — the stand-in for the not-yet-shipped `record --source esp32-jsonl`): `rvcsi inspect` / `replay` / `calibrate` / `events` all run end-to-end. This surfaced and fixed the baseline-drift over-trigger (absolute → relative thresholds, above).
- `rvcsi-adapter-esp32` (live serial/UDP ESP32 source — ADR-095 §1.2 / D15), `rvcsi-mcp` (MCP tool server), `rvcsi-daemon` (live capture + WebSocket), and the legacy nexmon *packed-float* CSI export — not in this PR; tracked as follow-ups.
---
## 6. References
- [ADR-095 — rvCSI Edge RF Sensing Platform](ADR-095-rvcsi-edge-rf-sensing-platform.md)
- [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md)
- [rvCSI Domain Model](../ddd/rvcsi-domain-model.md)
- napi-rs — https://napi.rs/
- nexmon_csi — the upstream Broadcom CSI extractor the record format normalizes
+2
View File
@@ -105,6 +105,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-011](ADR-011-python-proof-of-reality-mock-elimination.md) | Proof-of-Reality and Mock Elimination | Proposed |
| [ADR-026](ADR-026-survivor-track-lifecycle.md) | Survivor Track Lifecycle (MAT crate) | Accepted |
| [ADR-038](ADR-038-sublinear-goal-oriented-action-planning.md) | Sublinear GOAP for Roadmap Optimization | Proposed |
| [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) | rvCSI — Edge RF Sensing Runtime Platform | Proposed |
| [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) | rvCSI — Crate Topology, the napi-c Shim, and the napi-rs Node Surface | Proposed |
---
+1
View File
@@ -15,6 +15,7 @@ DDD organizes the codebase around the problem being solved — not around techni
| [Sensing Server](sensing-server-domain-model.md) | Single-binary Axum server: CSI ingestion, model management, recording, training, visualization | 5 contexts: CSI Ingestion, Model Management, CSI Recording, Training Pipeline, Visualization |
| [WiFi-Mat](wifi-mat-domain-model.md) | Disaster response: survivor detection, START triage, mass casualty assessment | 3 contexts: Detection, Localization, Alerting |
| [CHCI](chci-domain-model.md) | Coherent Human Channel Imaging: sub-millimeter body surface reconstruction | 3 contexts: Sounding, Channel Estimation, Imaging |
| [rvCSI](rvcsi-domain-model.md) | Edge RF sensing runtime: multi-source CSI ingestion, validation, normalization, event extraction, RuVector RF memory, agent/MCP integration | 7 contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent |
## How to read these
+395
View File
@@ -0,0 +1,395 @@
# rvCSI — Edge RF Sensing Runtime Domain Model
## Domain-Driven Design Specification
> Companion documents: [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md) · [ADR-095 — rvCSI Edge RF Sensing Platform](../adr/ADR-095-rvcsi-edge-rf-sensing-platform.md)
### Domain
Camera-free RF spatial sensing from WiFi Channel State Information (CSI).
### Core domain
**RF field interpretation.** rvCSI converts noisy radio channel measurements into validated events and temporal embeddings that represent changes in physical space. CSI is treated as a *temporal delta stream* against learned baselines — not as exact vision.
### Supporting subdomains
Hardware adapter management · packet parsing · signal processing · calibration · event extraction · temporal memory · agent integration · replay and audit.
### Generic subdomains
Logging · configuration · CLI parsing · WebSocket streaming · package publishing · dashboard visualization.
---
## Ubiquitous Language
| Term | Definition |
|------|------------|
| **CSI** | Channel State Information — per-subcarrier complex channel response measured by a WiFi receiver |
| **Source** | A physical or replayed producer of CSI frames (a NIC, an ESP32 node, a PCAP file, a recorded capture) |
| **Adapter** | A software module that knows how to receive and decode source-specific CSI and normalize it into a `CsiFrame` |
| **Frame** | One CSI observation at a timestamp — the unit of ingestion |
| **Window** | A bounded sequence of frames from one source/session, used for analysis |
| **Baseline** | The learned normal RF-field state for a space |
| **Delta** | The measured difference of the current field from baseline |
| **Event** | A semantic interpretation of one or more windows (presence started, motion detected, anomaly, …) |
| **Quality score** | Confidence, in [0, 1], that a signal/frame/window is usable |
| **Calibration** | The process of learning a stable baseline for a space |
| **Room signature** | A vector representation of a space under normal conditions |
| **Drift** | Slow movement of the field away from baseline |
| **Anomaly** | A significant, unexplained deviation from baseline |
| **RF memory** | Persisted temporal vectors and events for a physical space (stored in RuVector) |
| **Coherence** | Consistency among sources, windows, and learned baselines |
| **Quarantine** | A holding store for rejected/corrupt frames, kept for audit rather than discarded |
| **Adapter profile** | A capability descriptor for a source (chip, firmware/driver versions, supported channels/bandwidths, expected subcarrier counts, capture/injection/monitor-mode support) |
| **Calibration version** | An immutable identifier for a particular learned baseline; every event references the calibration version it was detected against |
| **Evidence window set** | The set of `WindowId`s an event references as its justification — an event with no evidence is invalid |
---
## Bounded Contexts
```
┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ Capture │──▶│ Validation │──▶│ Signal │──▶│ Calibration │
│ context │ │ context │ │ context │ │ context │
└─────────────┘ └──────────────┘ └─────┬──────┘ └──────┬───────┘
│ │
▼ │
┌────────────┐ │
│ Event │◀──────────┘
│ context │
└─────┬──────┘
┌─────────────┴─────────────┐
▼ ▼
┌────────────┐ ┌────────────┐
│ Memory │ │ Agent │
│ context │ │ context │
└────────────┘ └────────────┘
```
- **Capture** upstreams raw input from sources.
- **Validation** protects every downstream context — nothing crosses into SDK/DSP/memory/agents unvalidated.
- **Signal** turns frames into windows.
- **Calibration** gives windows a room-specific baseline.
- **Event** converts deltas into meaning.
- **Memory** stores time, similarity, drift, and coherence (RuVector).
- **Agent** exposes safe actions and queries (MCP / TypeScript).
---
### 1. Capture context
**Responsibility:** connect to CSI sources and produce raw frames.
```
┌──────────────────────────────────────────────────────────────┐
│ Capture Context │
├──────────────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Source │ │ CaptureSession │ │ AdapterProfile │ │
│ │ (adapter │ │ (aggregate root)│ │ (capability │ │
│ │ plugin) │ │ │ │ descriptor) │ │
│ └────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ CsiSource trait: open · start · next_frame · stop · health │
└──────────────────────────────────────────────────────────────┘
```
| Element | Kind | Notes |
|---------|------|-------|
| `Source` | Entity | A configured adapter instance bound to a device or file |
| `CaptureSession` | Entity / **aggregate root** | Owns exactly one `AdapterProfile` and one runtime configuration |
| `AdapterProfile` | Entity | Chip, firmware/driver versions, supported channels/bandwidths, expected subcarrier counts, capability flags |
| `Channel`, `Bandwidth`, `FirmwareVersion`, `DriverVersion` | Value objects | Immutable |
**Commands:** `StartCapture` · `StopCapture` · `RestartCapture` · `InspectSource`
**Domain events:** `CaptureStarted` · `CaptureStopped` · `SourceDisconnected` · `AdapterUnsupported`
---
### 2. Validation context
**Responsibility:** make frames safe and trustworthy before any language-boundary crossing.
| Element | Kind | Notes |
|---------|------|-------|
| `ValidationPolicy` | Entity | Bounds, monotonicity rules, finiteness checks, quarantine on/off |
| `QuarantineStore` | Entity | Holds rejected/corrupt frames for audit |
| `ValidatedFrame` | **Aggregate root** | The frame once it has passed (or been degraded by) validation |
| `ValidationError`, `QualityScore`, `FrameBounds` | Value objects | `QualityScore` ∈ [0, 1] |
**Commands:** `ValidateFrame` · `QuarantineFrame`
**Domain events:** `FrameAccepted` · `FrameRejected` · `QualityDropped`
---
### 3. Signal context
**Responsibility:** DSP and window features.
```
Frame stream ─▶ SignalPipeline ─▶ WindowBuffer ─▶ CsiWindow
(DC removal, phase unwrap, (mean amplitude,
smoothing, Hampel filter, phase variance,
variance, baseline subtraction, motion energy,
motion energy, presence score) presence/quality scores)
```
| Element | Kind | Notes |
|---------|------|-------|
| `SignalPipeline` | Entity | Ordered DSP stages; reuses `wifi-densepose-signal` primitives |
| `WindowBuffer` | Entity | Accumulates frames into bounded windows |
| `CsiWindow` | **Aggregate root** | Frames from exactly one source/session |
| `AmplitudeVector`, `PhaseVector`, `MotionEnergy`, `PresenceScore` | Value objects | |
**Commands:** `ProcessFrame` · `BuildWindow` · `EstimateBaselineDelta`
**Domain events:** `WindowReady` · `BaselineDeltaMeasured`
---
### 4. Calibration context
**Responsibility:** learn and version the normal RF state and room signatures.
| Element | Kind | Notes |
|---------|------|-------|
| `CalibrationProfile` | **Aggregate root** | Linked to source, room, adapter profile, configuration |
| `RoomSignature` | Entity | Vector representation of a space under normal conditions |
| `BaselineModel` | Entity | Statistical model of the baseline field; carries version history |
| `CalibrationVersion`, `StabilityScore`, `RoomId` | Value objects | Calibration cannot complete if `StabilityScore` < threshold |
**Commands:** `StartCalibration` · `CompleteCalibration` · `UpdateBaseline` · `RejectUnstableCalibration`
**Domain events:** `CalibrationStarted` · `CalibrationCompleted` · `CalibrationFailed` · `BaselineUpdated`
---
### 5. Event context
**Responsibility:** semantic event extraction with confidence and evidence.
| Element | Kind | Notes |
|---------|------|-------|
| `EventDetector` | Entity | One per event family (presence, motion, breathing, anomaly, …) |
| `EventStateMachine` | Entity | Holds the per-source detection state; emits transitions |
| `CsiEvent` | **Aggregate root** | Must reference ≥ 1 evidence window; confidence ∈ [0, 1]; references calibration version |
| `Confidence`, `EvidenceWindowSet`, `EventKind` | Value objects | |
**Commands:** `DetectEvents` · `PublishEvent` · `SuppressEvent`
**Domain events (the `CsiEventKind` enum):** `PresenceStarted` · `PresenceEnded` · `MotionDetected` · `MotionSettled` · `BaselineChanged` · `SignalQualityDropped` · `DeviceDisconnected` · `BreathingCandidate` · `AnomalyDetected` · `CalibrationRequired`
---
### 6. Memory context
**Responsibility:** RuVector storage and retrieval — RF memory.
| Element | Kind | Notes |
|---------|------|-------|
| `RfMemoryCollection` | Entity | A RuVector collection scoped to a deployment |
| `TemporalEmbedding` | Entity | Frame / window / event embedding with timestamp |
| `SensorGraph` | Entity | Graph of sources and their topological relationships |
| `RoomMemory` | **Aggregate root** | Stored embeddings must be traceable to frame windows or event windows |
| `EmbeddingVector`, `DriftScore`, `CoherenceScore` | Value objects | `DriftScore` must include the baseline version |
**Commands:** `StoreWindowEmbedding` · `StoreEventEmbedding` · `QuerySimilarWindows` · `ComputeDrift`
**Domain events:** `EmbeddingStored` · `DriftDetected` · `SimilarPatternFound`
Data stored: frame embeddings · window embeddings · room baseline vectors · event vectors · drift snapshots · sensor-topology graph edges · source health records. Retention policy applies at collection level. No orphan embeddings.
---
### 7. Agent context
**Responsibility:** MCP and TypeScript agent interaction — safe actions and queries.
| Element | Kind | Notes |
|---------|------|-------|
| `AgentSubscription` | Entity | An agent's filtered stream of events |
| `McpToolSession` | Entity | A tool invocation context with permissions |
| `AgentSession` | **Aggregate root** | |
| `ToolPermission`, `EventFilter`, `AgentIntent` | Value objects | `ToolPermission` distinguishes read vs. write-gated |
**Commands:** `SubscribeToEvents` · `RequestStatus` · `RequestCalibration` · `QueryMemory`
**Domain events:** `AgentSubscribed` · `ToolExecuted` · `PermissionDenied`
**MCP tools** (read by default; write-gated marked `*`): `rvcsi_status` · `rvcsi_list_sources` · `rvcsi_start_capture *` · `rvcsi_stop_capture *` · `rvcsi_get_presence` · `rvcsi_get_recent_events` · `rvcsi_calibrate_room *` · `rvcsi_export_window *` · `rvcsi_query_ruvector` · `rvcsi_health_report`.
---
## Context Map
| Upstream → Downstream | Relationship | ACL / contract |
|-----------------------|--------------|----------------|
| Capture → Validation | Customer/Supplier | Raw frames pass through `ValidationPolicy`; only `Accepted`/`Degraded` continue |
| Validation → Signal | Conformist (Signal accepts `ValidatedFrame` as-is) | `CsiFrame` schema is the published language |
| Signal → Calibration | Customer/Supplier | Windows + baseline-delta measurements feed baseline modeling |
| Calibration → Event | Customer/Supplier | Detectors declare which `CalibrationVersion` they used |
| Signal/Event → Memory | Published Language (`EmbeddingVector`, event metadata) | `rvcsi-ruvector` ACL translates to RuVector's API |
| Event → Agent | Open Host Service (event stream + MCP tools) | `EventFilter` + `ToolPermission` enforced at the boundary |
| Capture → Agent | Conformist (health/status only, via MCP read tools) | No raw frames cross to agents |
The **`CsiFrame` schema is the shared kernel** between Capture, Validation, Signal, and the language-boundary (napi-rs) layer. It is the FFI-safe object; nothing device-specific leaks past it.
---
## Aggregates and Invariants
### `CaptureSession` aggregate
**Invariant:** a capture session has exactly one source profile and one runtime configuration.
1. A session cannot emit frames before it is started.
2. A session cannot change channel without restart unless the adapter supports dynamic retune.
3. A session must emit `SourceDisconnected` before stopping due to device loss.
### `ValidatedFrame` aggregate
**Invariant:** no frame crosses into SDK, DSP, memory, or agents unless its validation status is `Accepted` or `Degraded`.
1. Rejected frames go to quarantine when quarantine is enabled.
2. Degraded frames must carry quality-reason metadata.
3. Missing *optional* hardware metadata must not invalidate a frame.
### `CsiWindow` aggregate
**Invariant:** a window contains frames from exactly one source and one session.
1. Mixed-source windows are not allowed.
2. Window start time must be strictly less than end time.
3. Window quality is bounded in [0, 1].
### `CalibrationProfile` aggregate
**Invariant:** a calibration profile is linked to source, room, adapter profile, and configuration.
1. Calibration cannot complete if `StabilityScore` is below threshold.
2. Baseline updates must preserve version history.
3. Event detectors must declare which calibration version they used.
### `CsiEvent` aggregate
**Invariant:** an event must have evidence.
1. Every event references at least one evidence window.
2. Confidence is bounded in [0, 1].
3. Event suppression must be explainable by policy.
### `RoomMemory` aggregate
**Invariant:** stored embeddings are traceable to frame windows or event windows.
1. No orphan embeddings.
2. Retention policy applies at collection level.
3. Drift scores must include the baseline version.
---
## Data Model
```rust
pub struct CsiFrame {
pub frame_id: FrameId,
pub session_id: SessionId,
pub source_id: SourceId,
pub adapter_kind: AdapterKind,
pub timestamp_ns: u64,
pub channel: u16,
pub bandwidth_mhz: u16,
pub rssi_dbm: Option<i16>,
pub noise_floor_dbm: Option<i16>,
pub antenna_index: Option<u8>,
pub tx_chain: Option<u8>,
pub rx_chain: Option<u8>,
pub subcarrier_count: u16,
pub i_values: Vec<f32>,
pub q_values: Vec<f32>,
pub amplitude: Vec<f32>,
pub phase: Vec<f32>,
pub validation: ValidationStatus,
pub quality_score: f32,
pub calibration_version: Option<String>,
}
pub struct CsiWindow {
pub window_id: WindowId,
pub session_id: SessionId,
pub source_id: SourceId,
pub start_ns: u64,
pub end_ns: u64,
pub frame_count: u32,
pub mean_amplitude: Vec<f32>,
pub phase_variance: Vec<f32>,
pub motion_energy: f32,
pub presence_score: f32,
pub quality_score: f32,
}
pub enum CsiEventKind {
PresenceStarted,
PresenceEnded,
MotionDetected,
MotionSettled,
BaselineChanged,
SignalQualityDropped,
DeviceDisconnected,
BreathingCandidate,
AnomalyDetected,
CalibrationRequired,
}
pub struct CsiEvent {
pub event_id: EventId,
pub kind: CsiEventKind,
pub session_id: SessionId,
pub source_id: SourceId,
pub timestamp_ns: u64,
pub confidence: f32,
pub evidence_window_ids: Vec<WindowId>,
pub metadata_json: String,
}
pub struct AdapterProfile {
pub adapter_kind: AdapterKind,
pub chip: Option<String>,
pub firmware_version: Option<String>,
pub driver_version: Option<String>,
pub supported_channels: Vec<u16>,
pub supported_bandwidths_mhz: Vec<u16>,
pub expected_subcarrier_counts: Vec<u16>,
pub supports_live_capture: bool,
pub supports_injection: bool,
pub supports_monitor_mode: bool,
}
pub enum ValidationStatus { Accepted, Degraded, Rejected, Recovered }
```
---
## Domain Services
| Service | Input | Output | Responsibility |
|---------|-------|--------|----------------|
| `FrameValidationService` | `RawFrame`, `AdapterProfile`, `ValidationPolicy` | `ValidatedFrame` or `RejectedFrame` | Enforce bounds, finiteness, monotonicity; assign initial `QualityScore`; route rejects to quarantine; emit structured errors |
| `SignalProcessingService` | `ValidatedFrame` stream | `CsiWindow` stream | Run the DSP pipeline; build bounded windows; compute motion energy, presence score, window quality |
| `BaselineDeltaService` | `CsiWindow`, `BaselineModel` | `BaselineDelta` | Subtract the calibrated baseline; measure deviation magnitude |
| `CalibrationService` | `CsiWindow` stream over a calibration window | `CalibrationProfile` (new version) or `CalibrationFailed` | Learn a stable baseline; compute `StabilityScore`; reject unstable calibrations; preserve version history |
| `EventDetectionService` | `CsiWindow` + `BaselineDelta` + `CalibrationVersion` | `CsiEvent` stream | Drive per-source state machines; attach confidence + evidence windows + calibration version; apply suppression policy |
| `EmbeddingService` | `CsiWindow` / `CsiEvent` | `TemporalEmbedding` | Produce frame/window/event vectors (v0: deterministic DSP feature vector; later: AETHER / on-device model) |
| `RfMemoryService` | `TemporalEmbedding`, query | `EmbeddingStored` / similar windows / `DriftScore` | Store to RuVector; similarity search; drift computation against a baseline version |
| `ReplayService` | A captured session bundle | A deterministic frame/window/event stream | Replay preserving timestamps, ordering, validation decisions, event output, calibration version, runtime config |
| `AdapterRegistryService` | — | List of available adapters + `AdapterProfile`s | Discover sources (reuses ADR-049 interface detection); report health; flag unsupported firmware/driver state |
| `AgentGatewayService` | MCP tool call / SDK subscription | Tool result / filtered event stream | Enforce `ToolPermission` (read vs. write-gated), apply `EventFilter`, audit `ToolExecuted` / `PermissionDenied` |
---
## Related
- [rvCSI Platform PRD](../prd/rvcsi-platform-prd.md) — requirements, success criteria, scope
- [ADR-095 — rvCSI Edge RF Sensing Platform](../adr/ADR-095-rvcsi-edge-rf-sensing-platform.md) — the fifteen architectural decisions
- [RuvSense Domain Model](ruvsense-domain-model.md) — adjacent multistatic sensing context
- [Signal Processing Domain Model](signal-processing-domain-model.md) — the DSP primitives `rvcsi-dsp` reuses
- [ADR Index](../adr/README.md)
+3 -3
View File
@@ -168,14 +168,14 @@ The training process works like this:
1. **Collect** raw CSI frames from ESP32-S3 nodes placed in a room
2. **Extract** 8-dimensional feature vectors from sliding windows of CSI data
3. **Contrast** -- the model learns that features from nearby time windows should produce similar embeddings, while features from different scenarios should produce different embeddings
4. **Fine-tune** task heads using weak labels from environmental sensors (PIR motion, temperature, pressure) on the Cognitum Seed companion device
4. **Fine-tune** task heads *planned:* weak labels from environmental sensors (PIR motion, temperature, pressure) on the Cognitum Seed companion device. **This environmental-sensor ground-truth path is not yet implemented** (no PIR/BME280 ingestion in the training pipeline today); current task-head supervision uses the proxy/camera labels described elsewhere.
### Data provenance
- **Source:** Live CSI from 2x ESP32-S3 nodes (802.11n, HT40, 114 subcarriers)
- **Volume:** ~360,000 CSI frames (~3,600 feature vectors) per collection run
- **Environment:** Residential room, ~4x5 meters
- **Ground truth:** Environmental sensors on Cognitum Seed (PIR, BME280, light)
- **Ground truth:** *Planned* — environmental sensors on the Cognitum Seed (PIR, BME280, light). Not yet wired into training; treat the PIR/BME280 references in this card as the intended design, not a current capability.
- **Attestation:** Every collection run produces a cryptographic witness chain (`collection-witness.json`) that proves data provenance and integrity
### Witness chain
@@ -208,7 +208,7 @@ Add a second ESP32-S3 to enable cross-node signal fusion for better accuracy and
| USB-C cables (x3) | Power + data | ~$9 |
| **Total** | | **~$27** |
The Cognitum Seed runs the ONNX models on-device, orchestrates the ESP32 nodes over USB serial, and provides environmental ground truth via its onboard PIR and BME280 sensors.
The Cognitum Seed runs the ONNX models on-device and orchestrates the ESP32 nodes over USB serial. (Using its onboard PIR/BME280 sensors as training ground truth is planned but not yet implemented — see "Data provenance" above.)
---
+376
View File
@@ -0,0 +1,376 @@
# rvCSI — Edge RF Sensing Runtime
## Product Design Requirements (PRD)
| Field | Value |
|-------|-------|
| **Product name** | rvCSI |
| **Category** | Edge RF sensing runtime and developer platform |
| **Status** | Proposed (v0 design) |
| **Date** | 2026-05-12 |
| **Owner** | ruv |
| **Relates to** | [ADR-095](../adr/ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI platform), [ADR-012](../adr/ADR-012-esp32-csi-sensor-mesh.md) (ESP32 mesh), [ADR-013](../adr/ADR-013-feature-level-sensing-commodity-gear.md) (feature-level sensing), [ADR-014](../adr/ADR-014-sota-signal-processing.md) (SOTA signal processing), [ADR-016](../adr/ADR-016-ruvector-integration.md) (RuVector integration), [ADR-024](../adr/ADR-024-contrastive-csi-embedding-model.md) (AETHER embeddings), [ADR-031](../adr/ADR-031-ruview-sensing-first-rf-mode.md) (RuView sensing-first RF mode), [ADR-040](../adr/ADR-040-wasm-programmable-sensing.md) (WASM programmable sensing) |
| **Domain model** | [rvCSI Domain Model](../ddd/rvcsi-domain-model.md) |
---
## 1. Purpose
rvCSI is a **Rust-first, TypeScript-accessible, hardware-abstracted Channel State Information (CSI) platform** for WiFi-based spatial sensing.
The goal is to convert CSI from fragile research data into a durable edge sensing runtime that can feed RuView, RuVector, Cognitum, and agentic systems with validated live radio-field observations.
rvCSI does **not** try to replace Nexmon on day one. It wraps, validates, normalizes, streams, embeds, and learns from CSI produced by Nexmon, ESP32 CSI, Intel CSI, Atheros CSI, SDR pipelines, and future RF sensor sources.
### 1.1 System framing
CSI is treated as a **physical-world delta stream**.
A room, hallway, vehicle, warehouse, machine bay, or care facility has a radio-field baseline. Human motion, breathing, door movement, equipment vibration, device movement, and environmental change perturb that baseline. rvCSI captures those perturbations, normalizes them into tensors, converts them into events, stores them as temporal memory, and exposes them to agents.
The core invariant:
| Layer | Owns |
|-------|------|
| **C** | Fragile vendor and firmware compatibility |
| **Rust** | Safety, validation, signal processing, memory discipline, deterministic runtime behavior |
| **TypeScript** | Developer experience, orchestration, dashboards, SDKs, agent integration |
| **RuVector** | Memory, similarity, drift, graph relationships, coherence over time |
| **Cognitum** | Low-power event-driven deployment, local decision loops |
### 1.2 Strategic framing
Most CSI projects today are Linux shell scripts, kernel patching, Python notebooks, PCAP dumps, and ad-hoc signal processing. A Rust + TypeScript + napi-rs architecture turns CSI into **real-time sensor infrastructure**: npm-installable, reproducible, typed, safe-parsed, embeddable, WebSocket-streamable, WASM-portable, MCP-exposed, agent-integrable, and edge/cloud-federated.
The right framing is **structural sensing**, not "magic X-ray vision". CSI is excellent for detecting change, presence, and learned patterns; it is weak for exact identity, exact pose, legal/security certainty, and highly dynamic RF spaces. rvCSI's product claims stay inside that boundary (see Non-goals, §6).
---
## 2. Users
| User | Need |
|------|------|
| AI engineers building physical-world agents | A stable sensing primitive that emits typed events agents can react to |
| Researchers working with WiFi CSI and RF sensing | Reproducible ingestion, replay, and benchmark datasets |
| Smart-building and elder-care solution builders | Privacy-preserving presence/motion/breathing without cameras |
| Industrial monitoring teams | Camera-free movement/anomaly detection that runs unattended |
| Developers using RuView / RuVector / Cognitum | A drop-in source of RF observations for the broader ruvnet stack |
---
## 3. Problem & Hypothesis
**Problem.** WiFi CSI is useful but hard to operationalize. Most CSI pipelines are built from fragile scripts, patched firmware, lab notebooks, inconsistent packet formats, unstable drivers, and device-specific assumptions. This makes CSI difficult to deploy outside research settings. The system needs a production-grade runtime that can ingest CSI from multiple sources, validate packets, normalize formats, stream typed events, support signal processing, and feed vector-based learning systems.
**Hypothesis.** If rvCSI provides a stable Rust core with TypeScript APIs and hardware adapters, then CSI can become a reusable sensing primitive for camera-free spatial intelligence.
---
## 4. Success criteria
1. A developer can install rvCSI and parse recorded CSI files in **under five minutes**.
2. A supported live device can stream **validated** CSI frames into TypeScript.
3. Bad packets **cannot crash** the process.
4. The same application code consumes CSI from Nexmon, ESP32, Intel, or Atheros adapters.
5. Presence and motion detection work from **normalized tensors**, not device-specific raw packets.
6. rvCSI can publish embeddings and event summaries into **RuVector**.
7. rvCSI can run as a **local daemon on Raspberry Pi-class hardware**.
8. rvCSI can expose events to **MCP tools and local agents**.
---
## 5. Scope
### 5.1 Version zero — safe ingestion, normalized data, live streaming, SDK usability, RuVector integration
1. Recorded CSI file parser
2. Live capture adapter for existing Nexmon CSI output where supported
3. ESP32 CSI adapter
4. Unified CSI frame schema
5. Rust validation pipeline
6. TypeScript SDK through napi-rs
7. CLI for capture, inspect, replay, stream
8. WebSocket output
9. Presence and motion baseline detectors
10. RuVector export interface
11. Basic calibration model
12. Hardware and driver health checks
### 5.2 Version one
1. Multi-node synchronization
2. RF room signatures
3. Breathing-rate estimation where signal quality permits
4. Temporal embeddings
5. Drift detection
6. Graph-based room topology
7. Local MCP tool server
8. Replayable benchmark datasets
9. Sensor fusion with RuView
10. Deployment profile for Cognitum Seed and Appliance
### 5.3 Version two
1. Hardware-agnostic RF sensor fabric
2. Multi-room RF memory
3. Streaming anomaly detection
4. RF SLAM research mode
5. On-device embedding model
6. Federated learning of room signatures
7. Secure signed sensor-evidence records
8. Proof-gated event publication
9. Dynamic cut-based coherence over RF graphs
10. Agent-driven calibration and self-repair
---
## 6. Non-goals (version zero)
1. Pure-Rust replacement for Broadcom firmware patches
2. Universal support for all WiFi chips
3. Identity recognition from RF signals
4. Medical-grade vital-sign diagnosis
5. Legal-grade occupancy proof
6. Guaranteed through-wall pose detection
7. Cloud dependency
8. Camera-replacement claims
---
## 7. Functional requirements
### FR1 — CSI ingestion
rvCSI shall ingest CSI from multiple sources. Initial source types: recorded binary dump, PCAP file, Nexmon CSI live stream, ESP32 CSI serial/UDP stream, Intel CSI logs (where supported), Atheros CSI logs (where supported). **Output:** a normalized `CsiFrame` object.
### FR2 — Packet validation
rvCSI shall validate every frame before exposing it to TypeScript or RuVector:
1. Frame length must match declared schema.
2. Subcarrier count must be inside adapter-profile limits.
3. Timestamp must be monotonic within a capture session unless marked as recovered.
4. RSSI must be within plausible device bounds.
5. Complex values must be finite.
6. Corrupt frames must be rejected or quarantined.
7. Parser failures must return structured errors.
### FR3 — Normalized frame schema
rvCSI shall normalize all hardware output into a common schema. Required fields: `frame_id`, `session_id`, `source_id`, `adapter_kind`, `timestamp_ns`, `channel`, `bandwidth_mhz`, `rssi_dbm`, `noise_floor_dbm` (when available), `antenna_index` (when available), `tx_chain` (when available), `rx_chain` (when available), `subcarrier_count`, `i_values`, `q_values`, `amplitude`, `phase`, `validation_status`, `quality_score`, `calibration_version`.
### FR4 — Signal processing
rvCSI shall provide reusable Rust signal-processing stages: DC offset removal, phase unwrap, amplitude smoothing, Hampel/median outlier filter, short-window variance, baseline subtraction, motion energy, presence score, breathing-band estimator (where supported), confidence scoring.
### FR5 — Event extraction
rvCSI shall convert frame streams into typed events: `PresenceStarted`, `PresenceEnded`, `MotionDetected`, `MotionSettled`, `BaselineChanged`, `SignalQualityDropped`, `DeviceDisconnected`, `BreathingCandidate`, `AnomalyDetected`, `CalibrationRequired`.
### FR6 — TypeScript SDK
rvCSI shall expose a TypeScript SDK:
```ts
import { RvCsi } from "@ruv/rvcsi";
const sensor = await RvCsi.open({
source: "nexmon",
iface: "wlan0",
channel: 6,
bandwidthMHz: 20,
});
sensor.on("frame", (frame) => {
console.log(frame.qualityScore);
});
sensor.on("presence", (event) => {
console.log(event.confidence);
});
await sensor.start();
```
### FR7 — CLI
```bash
rvcsi inspect file sample.csi
rvcsi capture start --source nexmon --iface wlan0 --channel 6
rvcsi replay sample.csi --speed 1x
rvcsi stream --format json --port 8787
rvcsi calibrate --room livingroom --duration 60
rvcsi health --source nexmon
rvcsi export ruvector --collection room_rf
```
### FR8 — RuVector integration
rvCSI shall export temporal RF embeddings and event metadata to RuVector. Data stored: frame embeddings, window embeddings, room baseline vectors, event vectors, drift snapshots, sensor-topology graph edges, source health records.
### FR9 — MCP integration
rvCSI shall expose MCP tools for local agents: `rvcsi_status`, `rvcsi_list_sources`, `rvcsi_start_capture`, `rvcsi_stop_capture`, `rvcsi_get_presence`, `rvcsi_get_recent_events`, `rvcsi_calibrate_room`, `rvcsi_export_window`, `rvcsi_query_ruvector`, `rvcsi_health_report`. Tools default to read actions; capture start/stop, calibration, and export are write-gated.
### FR10 — Replay and audit
rvCSI shall support deterministic replay of captured sessions, preserving: original timestamps, frame ordering, validation decisions, event-extraction output, calibration version, runtime configuration.
---
## 8. Non-functional requirements
### 8.1 Safety
1. TypeScript shall never receive raw unchecked pointers.
2. Rust shall validate all frames before the FFI boundary export.
3. C shims shall be minimal and isolated.
4. All `unsafe` blocks shall be documented.
5. Fuzz tests shall cover parsers.
### 8.2 Performance (v0 targets)
1. Parse one CSI frame in **< 1 ms** on Raspberry Pi 5.
2. Sustain **≥ 1000 frames/s** on Pi 5 for normalized parsing.
3. Keep memory **< 256 MB** for one active source.
4. Keep event latency **< 50 ms** for presence and motion.
5. Avoid heap growth during steady capture.
### 8.3 Reliability
1. Bad packets shall not crash the daemon.
2. Device disconnect shall produce a typed event.
3. Capture sessions shall be restartable.
4. Logs shall include source, adapter, session, and validation details.
5. Health checks shall identify unsupported firmware or driver state.
### 8.4 Privacy
1. rvCSI shall operate locally by default.
2. No cloud endpoint shall be required.
3. Raw CSI export shall be disableable by policy.
4. Event-level export shall be supported for privacy-preserving deployments.
5. Retention policies shall be configurable.
### 8.5 Security
1. Device-control operations shall require explicit permission.
2. Firmware-installation operations shall be separated from capture operations.
3. Signed capture profiles shall be supported in later versions.
4. MCP tools shall mark write actions as gated.
5. File parsing shall be fuzzed and sandbox-friendly.
### 8.6 Portability
1. Linux first.
2. Raspberry Pi first among edge devices.
3. macOS and Windows support for file replay and SDK development.
4. Live-capture support depends on adapter and driver capability.
5. WASM support for offline parsing and visualization is a later target.
---
## 9. System architecture
### 9.1 High-level pipeline
```
CSI Source
Adapter Layer (vendor-specific decode, C shims isolated here)
Rust Validation Pipeline (bounds, finiteness, monotonicity, quarantine)
Normalized CSI Frame (CsiFrame schema — the FFI-safe boundary object)
Signal Processing (DC removal, phase unwrap, smoothing, motion energy …)
Window Aggregator (bounded frame sequences → CsiWindow)
Event Extractor (state machines → CsiEvent with confidence + evidence)
TypeScript SDK · CLI · MCP · RuVector
```
### 9.2 Runtime components
| # | Component | Role |
|---|-----------|------|
| 1 | `rvcsi-core` | Frame types, parser traits, validation, quality scoring, shared abstractions |
| 2 | `rvcsi-adapter-*` | Rust/C-backed adapters: Nexmon, ESP32, Intel, Atheros, files, replay |
| 3 | `rvcsi-dsp` | Rust signal-processing primitives |
| 4 | `rvcsi-events` | Windowing, baseline modeling, event extraction, state machines |
| 5 | `rvcsi-node` | napi-rs bindings exposing safe APIs to Node.js |
| 6 | `rvcsi-sdk` | TypeScript SDK |
| 7 | `rvcsi-cli` | Command-line interface |
| 8 | `rvcsi-daemon` | Long-running capture and event service |
| 9 | `rvcsi-mcp` | MCP tool server |
| 10 | `rvcsi-ruvector` | Exporter and query bridge |
### 9.3 Reference repository layout
```
rvcsi/
crates/
rvcsi-core/
rvcsi-adapter-file/
rvcsi-adapter-nexmon/
rvcsi-adapter-esp32/
rvcsi-dsp/
rvcsi-events/
rvcsi-ruvector/
rvcsi-daemon/
rvcsi-node/
rvcsi-mcp/
packages/
sdk/
cli/
dashboard/
native/
nexmon-shim-c/
docs/
adr/
ddd/
prd/
benchmarks/
testdata/
captures/
malformed/
replay/
```
> Within the RuView monorepo, rvCSI would be introduced as a new bounded context (see the [domain model](../ddd/rvcsi-domain-model.md)) and a small set of `v2/crates/rvcsi-*` crates, reusing existing `wifi-densepose-signal` DSP and `wifi-densepose-ruvector` integration where they overlap rather than duplicating them.
---
## 10. Data model (summary)
The authoritative definitions live in the [rvCSI domain model](../ddd/rvcsi-domain-model.md). Summary:
- **`CsiFrame`** — one validated CSI observation at a timestamp (the FFI-safe object). Carries I/Q, amplitude, phase, RSSI, channel/bandwidth, optional antenna/chain metadata, validation status, quality score, calibration version.
- **`CsiWindow`** — a bounded sequence of frames from one source/session, with mean amplitude, phase variance, motion energy, presence score, quality score.
- **`CsiEvent`** — a semantic interpretation of one or more windows, with `kind`, confidence, evidence window IDs, and metadata.
- **`AdapterProfile`** — capability descriptor for a source: chip, firmware/driver versions, supported channels/bandwidths, expected subcarrier counts, capture/injection/monitor-mode support.
---
## 11. Open questions
1. **Embedding model.** What produces frame/window embeddings in v0 — a fixed DSP feature vector, the existing AETHER contrastive model (ADR-024), or a lightweight on-device model? v0 leans on a deterministic DSP feature vector; v2 targets an on-device model.
2. **Calibration UX.** How long must a calibration window be before `StabilityScore` is trustworthy, and how is that surfaced in the SDK/CLI?
3. **Nexmon coupling.** Which Nexmon-supported chips/firmwares are in the v0 "supported" matrix vs. "best effort"?
4. **Monorepo vs. standalone.** Does rvCSI ship as `v2/crates/rvcsi-*` inside RuView or as a separate `rvcsi/` repo? This PRD assumes monorepo crates that reuse `wifi-densepose-signal` and `wifi-densepose-ruvector`.
5. **MCP transport.** stdio-only for v1, or also a local socket for multi-agent fan-out?
---
## 12. References
- [ADR-095 — rvCSI Edge RF Sensing Platform](../adr/ADR-095-rvcsi-edge-rf-sensing-platform.md)
- [rvCSI Domain Model](../ddd/rvcsi-domain-model.md)
- [ADR-013 — Feature-Level Sensing on Commodity Gear](../adr/ADR-013-feature-level-sensing-commodity-gear.md)
- [ADR-014 — SOTA Signal Processing](../adr/ADR-014-sota-signal-processing.md)
- [ADR-016 — RuVector Integration](../adr/ADR-016-ruvector-integration.md)
- [ADR-024 — Project AETHER: Contrastive CSI Embeddings](../adr/ADR-024-contrastive-csi-embedding-model.md)
- [ADR-031 — RuView Sensing-First RF Mode](../adr/ADR-031-ruview-sensing-first-rf-mode.md)
- [ADR-040 — WASM Programmable Sensing](../adr/ADR-040-wasm-programmable-sensing.md)
@@ -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
View File
@@ -1 +1 @@
0.6.2
0.6.4
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ruview",
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, and witness verification — from practical to advanced.",
"version": "0.1.0",
"author": {
"name": "ruvnet",
"url": "https://github.com/ruvnet/RuView"
},
"homepage": "https://github.com/ruvnet/RuView",
"license": "MIT",
"keywords": [
"ruview",
"wifi-densepose",
"wifi-sensing",
"csi",
"esp32",
"pose-estimation",
"vital-signs",
"edge-ai",
"model-training",
"onboarding"
]
}
+80
View File
@@ -0,0 +1,80 @@
# ruview — Claude Code + Codex plugin for WiFi sensing
End-to-end toolkit for **RuView** (WiFi-DensePose): onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, and witness verification — from practical to advanced.
Part of the **`ruview` marketplace** — manifest at the repo root: `.claude-plugin/marketplace.json` (this plugin's `source` is `./plugins/ruview`).
## Install / test
```bash
# In Claude Code — add this repo as a plugin marketplace, then install:
/plugin marketplace add ruvnet/RuView
/plugin install ruview@ruview
# Or try it locally without installing (from a clone of the repo):
claude --plugin-dir ./plugins/ruview
```
For Codex (OpenAI CLI), see [`codex/`](codex/) — all seven `/ruview-*` commands mirrored as Codex prompts, plus an `AGENTS.md` and install instructions in [`codex/README.md`](codex/README.md).
## What's inside
### Skills (auto-discovered from `skills/`)
| Skill | What it does |
|-------|--------------|
| `ruview-quickstart` | Onboarding & first run — Docker demo, repo build, fastest path to a live dashboard |
| `ruview-hardware-setup` | ESP32-S3 / C6 firmware build, flash, WiFi provisioning, serial monitoring |
| `ruview-configure` | sdkconfig variants, NVS provisioning, channel/MAC overrides (ADR-060), edge modules (ADR-041), sensing-server flags, mesh, Cognitum Seed |
| `ruview-applications` | Run presence, vitals, pose (WiFlow), sleep, environment mapping, MAT, point-cloud fusion, novel RF apps |
| `ruview-model-training` | Camera-free pose, camera-supervised pose (92.9% PCK@20, ADR-079), RuVector embeddings (AETHER), domain generalization (MERIDIAN), local SNN, GPU on GCloud, HF publishing |
| `ruview-advanced-sensing` | RuvSense multistatic, cross-viewpoint fusion, RF tomography, persistent field model, intention signals, adversarial detection, mesh security |
| `ruview-cli-api` | `wifi-densepose` CLI binary (incl. MAT subcommands), REST API (`wifi-densepose-api`), browser/WASM (`wifi-densepose-wasm`, `wifi-densepose-wasm-edge`) |
| `ruview-mmwave` | mmWave / FMCW radar — ESP32-C6 + MR60BHA2 (60 GHz HR/BR/presence), HLK-LD2410 (24 GHz), mmWave↔CSI fusion (48-byte fused vitals) |
| `ruview-verify` | Rust tests, deterministic Python proof, firmware hashes, ADR-028 witness bundle + self-verification, pre-merge checklist |
### Commands (`commands/`)
| Command | Purpose |
|---------|---------|
| `/ruview-start` | Get started — pick Docker / build / hardware and walk through it |
| `/ruview-flash` | Build + flash ESP32 firmware (8MB / 4MB), confirm CSI stream |
| `/ruview-provision` | Provision WiFi creds, sink IP, channel / MAC-filter onto a node |
| `/ruview-app` | Run a sensing application |
| `/ruview-train` | Train / evaluate / publish a model (incl. GPU) |
| `/ruview-advanced` | Use multistatic / tomography / cross-viewpoint / mesh-security features |
| `/ruview-verify` | Run the trust pipeline + pre-merge checklist |
### Agents (`agents/`)
| Agent | Role |
|-------|------|
| `ruview-onboarding-guide` | Walks a newcomer from zero to a working setup |
| `ruview-config-engineer` | Sets up / tunes a deployment (firmware, NVS, edge modules, mesh, Seed) |
| `ruview-training-engineer` | Trains, evaluates, and ships models |
## Compatibility
- **Claude Code** — skills, commands, and agents are auto-discovered; no `claude-flow` MCP server required (skills drive RuView's own tooling: `cargo`, `python`, `idf.py`, `docker`, `node`). Optional: `npx @claude-flow/cli@latest security scan` is referenced for security changes.
- **Codex (OpenAI CLI)** — workflows mirrored under `codex/prompts/`; drop them in `~/.codex/prompts/` (or point Codex at `codex/`). `codex/AGENTS.md` carries the project rules.
- **Target repo** — assumes the [`ruvnet/RuView`](https://github.com/ruvnet/RuView) / `wifi-densepose` layout: `v2/crates/`, `firmware/esp32-csi-node/`, `archive/v1/`, `scripts/`, `docs/adr/`. On Windows, ESP-IDF builds go through the Python-subprocess pattern in `CLAUDE.local.md`.
## Namespace coordination
This plugin claims the kebab-case `ruview-*` namespace for its skills, commands, and agents (skills: `ruview-quickstart`, `ruview-hardware-setup`, `ruview-configure`, `ruview-applications`, `ruview-model-training`, `ruview-advanced-sensing`, `ruview-cli-api`, `ruview-mmwave`, `ruview-verify`; commands: `/ruview-start`, `/ruview-flash`, `/ruview-provision`, `/ruview-app`, `/ruview-train`, `/ruview-advanced`, `/ruview-verify`; agents: `ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`). It does not write to any `claude-flow` memory namespace. If combined with the `ruflo` marketplace, defer to `ruflo-agentdb` ADR-0001 §"Namespace convention" — there is no overlap (`ruview-*` vs. `ruflo-*`).
## Verification
```bash
bash plugins/ruview/scripts/smoke.sh
```
Structural contract: plugin.json has `version` + `keywords` and does **not** enumerate skills/commands/agents; every skill/command/agent file exists with valid frontmatter; README has a Compatibility section and a Namespace coordination block; ADR-0001 exists with status `Proposed`; no wildcard tools in skills; Codex mirror present **and parity** — every `commands/<name>.md` has a matching `codex/prompts/<name>.md`.
## Architecture Decisions
- [`docs/adrs/0001-ruview-plugin-contract.md`](docs/adrs/0001-ruview-plugin-contract.md) — plugin contract (Proposed): structure, namespace, compatibility surface, smoke scope, Codex mirror policy.
## Hardware note
`COM8` is the default ESP32 serial port in this plugin's docs — confirmed against an attached **ESP32-S3** (USB VID:PID `303A:1001`, Espressif) running the RuView CSI firmware (live `adaptive_ctrl` ticks + `csi_collector: CSI cb #… len=128 …` on the serial monitor). The repo's `CLAUDE.local.md` historically referenced `COM7`; some README snippets reference `COM9`. Always confirm the actual port (`python -c "import serial.tools.list_ports as l; print([p.device for p in l.comports()])"`, or Device Manager) before flashing. On Windows, `provision.py --help` needs `PYTHONUTF8=1` to print (non-ASCII in the help text); the build/flash path goes through the Python-subprocess pattern in `CLAUDE.local.md` (ESP-IDF v5.4 ≠ Git Bash).
@@ -0,0 +1,29 @@
---
name: ruview-config-engineer
description: Configures RuView deployments — ESP32 firmware variants (8MB/4MB/Heltec), sdkconfig, NVS provisioning, WiFi channel / MAC-filter overrides (ADR-060), edge intelligence modules (ADR-041), sensing-server flags, multi-node mesh, and Cognitum Seed integration. Use to set up or tune a RuView system without changing source code.
model: sonnet
---
# RuView Config Engineer
You own everything tunable in a RuView deployment — from a single provision flag to a full mesh + Cognitum Seed.
## What you do
- **Firmware build config:** pick the sdkconfig variant (`sdkconfig.defaults.template` for 8MB no-mock, `sdkconfig.defaults.4mb`, `sdkconfig.defaults.heltec_n16r2`), copy it to `sdkconfig.defaults`, rebuild via the Windows Python-subprocess command (`CLAUDE.local.md`). **Never test in mock mode.**
- **Device runtime config (`provision.py`):** writes the `csi_cfg` NVS namespace over serial. Always check `python firmware/esp32-csi-node/provision.py --help` first (on Windows: `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python …` — non-ASCII help text). Flags: WiFi/sink (`--ssid` `--password` `--target-ip` `--target-port` 5005 `--node-id`), TDM mesh (`--tdm-slot` `--tdm-total`), edge (`--edge-tier 0|1|2`), thresholds (`--pres-thresh` `--fall-thresh` 15000≈15 rad/s²), vitals (`--vital-win` `--vital-int` `--subk-count`), channel/hop (`--channel` `--filter-mac` `--hop-channels` `--hop-dwell`), Cognitum Seed (`--seed-url` `--seed-token` `--zone`), swarm (`--swarm-hb` `--swarm-ingest`), mode (`--dry-run` `--force-partial`). ⚠️ **Issue #391:** a flash replaces the *entire* `csi_cfg` namespace — keys not on the CLI are erased; pass the full set, warn before re-provisioning a working node. Fleet: `scripts/generate_nvs_matrix.py`.
- **Sensing server flags:** `cargo run -p wifi-densepose-sensing-server -- --help`; modes: live sink, `--pretrain`, `--train --save-rvf`, `--model X --embed`, `--model X --build-index env`.
- **Edge modules (ADR-041):** which modules ship in a build + their NVS thresholds; host-side mirrors in `scripts/*.js` (apnea, gait, material, passive-radar, mincut, fingerprint).
- **Multi-node mesh:** TDM + channel hopping (`wifi-densepose-hardware/src/esp32/`); all nodes → same sink IP.
- **Cognitum Seed:** bridge ESP32 → Seed for RVF memory / kNN / Ed25519 witness chain; `scripts/rf-scan.js`, `scripts/snn-csi-processor.js`; `docs/tutorials/cognitum-seed-pretraining.md`.
## Workflow
1. Run the `ruview-configure` skill for the canonical procedures; use `ruview-hardware-setup` for the actual flash/monitor loop.
2. Make the smallest config change that achieves the goal; verify on real hardware (COM8) with real WiFi CSI.
3. After any firmware/config change that affects behaviour, run `cd v2 && cargo test --workspace --no-default-features` and `python archive/v1/data/proof/verify.py`, then regenerate the witness bundle if needed (`/ruview-verify`).
## Ground rules
- Read before edit. No new files unless required. No secrets / `.env` in commits.
- Reference ADR-022, 028, 041, 060, 061, 081; `CLAUDE.md` / `CLAUDE.local.md`; `example.env`.
@@ -0,0 +1,28 @@
---
name: ruview-onboarding-guide
description: Walks a newcomer through RuView (WiFi-DensePose) from zero to a working sensing setup — picks the right path (Docker demo / repo build / live ESP32), explains the physics and the hardware caveats, and points to the next steps. Use when someone is new to the project or asks "how do I get started".
model: sonnet
---
# RuView Onboarding Guide
You help people get started with **RuView** — WiFi-based human sensing from Channel State Information (CSI). Be concrete and friendly; assume the person has not used the project before.
## Your job
1. **Figure out what they have.** No hardware? → Docker demo. Want to build? → Rust workspace + Python proof. Have an ESP32-S3/C6? → flash + provision + sensing server.
2. **Run the `ruview-quickstart` skill** for the canonical steps. For hardware, hand to `ruview-hardware-setup`.
3. **Set expectations honestly:**
- ESP32-C3 and the original ESP32 are **not supported** (single-core).
- One node = limited spatial resolution; 2+ nodes (or a Cognitum Seed) for good results.
- Camera-free pose is modest; camera-supervised training reaches 92.9% PCK@20 (ADR-079).
- Everything runs on the edge — no cloud, no cameras, no internet required.
4. **Explain the idea in one breath:** WiFi already fills the room with radio waves; people moving/breathing perturb them measurably; ESP32 captures CSI; RuView turns it into who's there / what they're doing / are they okay.
5. **Hand off** to the right next skill/command: `ruview-configure`, `ruview-applications` (`/ruview-app`), `ruview-model-training` (`/ruview-train`), `ruview-advanced-sensing` (`/ruview-advanced`), `ruview-verify` (`/ruview-verify`).
## Ground rules
- Read a file before editing it. Don't create files unless asked.
- Don't commit secrets or `.env`.
- Use the project's own tooling: `cargo`, `python`, `idf.py` (via the Python-subprocess on Windows — see `CLAUDE.local.md`), `docker`, `node` scripts.
- Reference, don't paraphrase: `README.md`, `docs/user-guide.md`, `docs/build-guide.md`, `docs/TROUBLESHOOTING.md`, `docs/tutorials/`, `examples/`.
@@ -0,0 +1,40 @@
---
name: ruview-training-engineer
description: Trains, evaluates, and ships RuView models — camera-free WiFlow pose, camera-supervised pose (MediaPipe + ESP32 CSI → 92.9% PCK@20, ADR-079), RuVector contrastive embeddings (AETHER, ADR-024), domain generalization (MERIDIAN, ADR-027), local SNN environment adaptation, GPU training on GCloud, and Hugging Face publishing. Use for any model-building task.
model: sonnet
---
# RuView Training Engineer
You build and ship RuView models. Know the tracks, the data layout, and the validation gate.
## Tracks
- **A — camera-free WiFlow pose:** `cargo run -p wifi-densepose-sensing-server -- --pretrain --dataset data/csi/ --pretrain-epochs 50``-- --train --dataset data/mmfi/ --epochs 100 --save-rvf model.rvf`. ~84 s on M4 Pro; modest accuracy. Bench: `node scripts/benchmark-wiflow.js`; eval: `node scripts/eval-wiflow.js`.
- **B — camera-supervised pose (ADR-079):** `python scripts/collect-ground-truth.py` (MediaPipe), `python scripts/collect-training-data.py` (CSI), `node scripts/align-ground-truth.js`, train on `data/paired/`, eval `eval-wiflow.js` → reports PCK@20. ~19 min on a laptop; 92.9% PCK@20. Needs `data/pose_landmarker_lite.task`.
- **C — RuVector embeddings (AETHER ADR-024):** `wifi-densepose-train` + `wifi-densepose-ruvector` (RuVector v2.0.4); `-- --model model.rvf --embed`, `-- --build-index env`. Spectrogram embeddings: ADR-076.
- **D — domain generalization (MERIDIAN ADR-027):** domain-gen options in the training pipeline; `ruview_metrics`.
- **E — local SNN adaptation:** `node scripts/snn-csi-processor.js --port 5006`; adapts <30 s; ADR-084/085 (RaBitQ), ADR-086 (novelty gate); `docs/tutorials/cognitum-seed-pretraining.md`.
## GPU & publishing
- GCloud (project `cognitum-20260110`, L4/A100/H100): `bash scripts/gcloud-train.sh [--dry-run] [--gpu l4|a100|h100] [--hours N] [--config FILE] [--sweep] [--keep-vm]`. VM auto-deletes. Local Mac: `bash scripts/mac-mini-train.sh`. Bench: `python scripts/benchmark-model.py`.
- Publish: `python scripts/publish-huggingface.py` (or the `.sh`); `docs/huggingface/`.
## Data
`data/recordings/` raw CSI · `data/csi/` pretrain · `data/mmfi/` MM-Fi · `data/paired/` camera↔CSI · `data/ground-truth/` MediaPipe landmarks · `data/pose_landmarker_lite.task` · `models/`. Record more: `python scripts/record-csi-udp.py`.
## Validation gate (always, after a training change)
1. `cd v2 && cargo test --workspace --no-default-features` — 1,400+ pass, 0 fail.
2. `cd .. && python archive/v1/data/proof/verify.py` — VERDICT: PASS.
3. Regenerate the witness bundle if tests/proof changed (`bash scripts/generate-witness-bundle.sh`; self-verify 7/7).
## Workflow
Run the `ruview-model-training` skill for canonical commands. Make the change, train, evaluate with the right metric (PCK@20 for pose), run the validation gate, then hand off to `/ruview-verify`. Read before edit; no new files unless required; no secrets in commits.
## Reference
ADRs 015, 016, 017, 024, 027, 076, 079, 084, 085, 095, 096; crates `wifi-densepose-train`, `-nn`, `-ruvector`, `-sensing-server`; `CLAUDE.md` build/test section.
+55
View File
@@ -0,0 +1,55 @@
# AGENTS.md — RuView (WiFi-DensePose)
Project rules for Codex (and any agent) working in the `ruvnet/RuView` / `wifi-densepose` repo. Mirrors the Claude Code `ruview` plugin.
## What this repo is
WiFi-based human sensing from Channel State Information (CSI). Dual codebase: Rust port in `v2/` (15 crates), Python v1 in `archive/v1/`. ESP32-S3 / ESP32-C6 firmware in `firmware/esp32-csi-node/`. 96 ADRs in `docs/adr/`.
## Hard rules
- Do exactly what's asked — nothing more, nothing less.
- Never create files (especially `*.md`/README) unless required for the task. Prefer editing an existing file.
- Never save working files/tests/notes to the repo root — use `v2/crates/`, `tests/`, `docs/`, `scripts/`, `examples/`.
- Read a file before editing it.
- Never commit secrets, credentials, or `.env`.
- Validate user input at system boundaries; sanitize file paths.
- ESP32-C3 and the original ESP32 are **not supported** (single-core). Use ESP32-S3 (8MB/4MB) or ESP32-C6.
## Build & test
```bash
# Rust workspace (1,400+ tests, ~2 min)
cd v2 && cargo test --workspace --no-default-features
# Single crate, no GPU
cargo check -p wifi-densepose-train --no-default-features
# Deterministic Python pipeline proof (SHA-256 Trust Kill Switch)
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
# Python v1 tests
cd archive/v1 && python -m pytest tests/ -x -q
```
## ESP32 firmware (Windows)
ESP-IDF v5.4 does **not** work under Git Bash/MSYS2 and `cmd.exe /C` hangs when called from bash. Build/flash via the **Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped** — the exact command is in `CLAUDE.local.md`. Default ESP32 serial port: **COM8** (confirm with `mode` / Device Manager — older docs say COM7 or COM9). Provision WiFi: `python firmware/esp32-csi-node/provision.py --port COM8 --ssid ... --password ... --target-ip ... [--channel N] [--filter-mac MAC]`. Serial monitor via pyserial, not `idf.py monitor`. Always test with real WiFi CSI, never mock mode.
## Witness verification (ADR-028)
After significant changes: run the Rust tests + Python proof, then `bash scripts/generate-witness-bundle.sh`, then `cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh` (7/7 PASS). Pre-merge checklist lives in `CLAUDE.md`.
## Prompt files in `codex/prompts/`
| Prompt | Purpose |
|--------|---------|
| `ruview-start` | Onboarding — Docker demo / repo build / live ESP32 |
| `ruview-flash` | Build + flash ESP32 firmware (8MB / 4MB) |
| `ruview-provision` | Provision WiFi creds + sink IP + channel/MAC overrides |
| `ruview-app` | Run a sensing application (presence / vitals / pose / sleep / MAT / point cloud) |
| `ruview-train` | Train / evaluate / publish a model (incl. GPU on GCloud) |
| `ruview-verify` | Run the trust pipeline + pre-merge checklist |
Install: copy `codex/prompts/*.md` into `~/.codex/prompts/`, or run Codex with this directory on its prompt path.
## Reference
`README.md`, `docs/user-guide.md`, `docs/wifi-mat-user-guide.md`, `docs/build-guide.md`, `docs/TROUBLESHOOTING.md`, `docs/adr/`, `docs/tutorials/`, `examples/`, `CLAUDE.md`, `CLAUDE.local.md`.
+47
View File
@@ -0,0 +1,47 @@
# RuView prompts for Codex (OpenAI CLI)
This directory mirrors the Claude Code `ruview` plugin's operator commands as Codex prompts, plus an `AGENTS.md` carrying the RuView project rules.
## Contents
| File | Purpose |
|------|---------|
| `AGENTS.md` | Project rules — repo layout, hard rules, build/test, ESP32 firmware on Windows, witness verification |
| `prompts/ruview-start.md` | Onboarding — Docker demo / repo build / live ESP32 |
| `prompts/ruview-flash.md` | Build + flash ESP32 firmware (8MB / 4MB) |
| `prompts/ruview-provision.md` | Provision WiFi creds + sink IP + channel/MAC overrides |
| `prompts/ruview-app.md` | Run a sensing application (presence / vitals / pose / sleep / MAT / point cloud) |
| `prompts/ruview-train.md` | Train / evaluate / publish a model (incl. GPU on GCloud) |
| `prompts/ruview-advanced.md` | Multistatic / tomography / cross-viewpoint / field-model / mesh-security |
| `prompts/ruview-verify.md` | Run the trust pipeline + pre-merge checklist |
Prompt parity with the Claude Code plugin is enforced by `plugins/ruview/scripts/smoke.sh` (every `commands/<name>.md` must have a matching `codex/prompts/<name>.md`).
## Install
**Per-user prompts** — copy the prompt files into Codex's prompt directory:
```bash
mkdir -p ~/.codex/prompts
cp plugins/ruview/codex/prompts/*.md ~/.codex/prompts/
# now in the codex TUI: /ruview-start /ruview-flash /ruview-app /ruview-train /ruview-verify /ruview-advanced
```
**Project rules** — point Codex at the `AGENTS.md`. Codex auto-discovers an `AGENTS.md` at the repo root and in the working directory; either symlink it or copy it:
```bash
ln -s plugins/ruview/codex/AGENTS.md AGENTS.md # repo root (if you don't already have one)
# — or, if a root AGENTS.md exists, append the relevant sections from plugins/ruview/codex/AGENTS.md
```
**Config (optional)** — to keep prompts in-repo instead of `~/.codex/prompts`, add to `~/.codex/config.toml`:
```toml
# Codex reads prompts from ~/.codex/prompts by default; symlinking keeps them versioned with the repo:
# ln -s "$PWD/plugins/ruview/codex/prompts" ~/.codex/prompts/ruview (then prompts appear as /ruview/ruview-start, etc.)
```
## Notes
- The Codex mirror is the **operator-facing subset** — the seven `/ruview-*` commands. The Claude Code plugin additionally ships skills (`ruview-quickstart`, `ruview-hardware-setup`, `ruview-configure`, `ruview-applications`, `ruview-model-training`, `ruview-advanced-sensing`, `ruview-cli-api`, `ruview-mmwave`, `ruview-verify`) and agents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`) that have no Codex equivalent — their content is folded into `AGENTS.md` and the prompt files.
- On Windows, ESP-IDF firmware builds go through the Python-subprocess pattern documented in `CLAUDE.local.md` (Git Bash / MSYS2 is not supported by ESP-IDF v5.4). Default ESP32 serial port: **COM8**.
@@ -0,0 +1,15 @@
# /ruview-advanced — advanced RuView capabilities
Drive RuView's research-grade / multi-node features. Topic: `$ARGUMENTS` (one of `multistatic`, `cross-viewpoint`, `tomography`, `field-model`, `intention`, `adversarial`, `security`; if empty, ask).
- **multistatic** (ADR-029) — treat every WiFi link in range (incl. neighbours' APs) as a bistatic radar pair, then fuse. `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs` (attention-weighted fusion, geometric diversity), `phase_align.rs` (iterative LO phase-offset, circular mean), `multiband.rs`, `coherence.rs` / `coherence_gate.rs` (Z-score scoring; Accept / PredictOnly / Reject / Recalibrate).
- **cross-viewpoint** (ADR-016 viewpoint module) — combine 2+ nodes geometrically. `v2/crates/wifi-densepose-ruvector/src/viewpoint/`: `attention.rs` (CrossViewpointAttention, GeometricBias, softmax with `G_bias`), `geometry.rs` (GeometricDiversityIndex, CramérRao bounds, Fisher Information), `coherence.rs` (phase-phasor coherence, hysteresis gate), `fusion.rs` (MultistaticArray aggregate root). Explore geometry first: `node scripts/mesh-graph-transformer.js`, `node scripts/deep-scan.js`.
- **tomography** — `ruvsense/tomography.rs` reconstructs a voxel occupancy grid via an ISTA L1 solver (sparse — most voxels empty); pair with cross-viewpoint geometry for through-wall volumetric imaging. RuVector solver crates back the 114→56 subcarrier sparse interpolation.
- **field-model** (ADR-030) — `ruvsense/field_model.rs` builds an SVD eigenstructure of the room, persists it (RVF, ideally on a Cognitum Seed); new frames are projected against it and the residual is the perturbation. Survives restarts; answers "what's different from the empty-room baseline?"
- **intention** — `ruvsense/intention.rs`, pre-movement lead signals 200500 ms ahead.
- **adversarial** — `ruvsense/adversarial.rs`, rejects physically impossible signals + cross-checks multi-link consistency.
- **security** (ADR-032, multistatic mesh hardening) — using neighbour APs and pooling links across a mesh expands the attack surface. Mitigations: `adversarial.rs` + `coherence_gate.rs` quarantine (Reject / Recalibrate) + Ed25519 witness chain (ADR-028). Run a security review (`docs/security-audit-wasm-edge-vendor.md`); see `/ruview-verify`.
Also relevant: ADR-031 (sensing-first RF mode), ADR-081 (adaptive CSI mesh firmware kernel), ADR-083 (per-cluster π compute hop), ADR-095/096 (on-ESP32 temporal modeling, sparse GQA).
Validate: `cd v2 && cargo test -p wifi-densepose-signal --no-default-features && cargo test -p wifi-densepose-ruvector --no-default-features`, then `cd .. && python archive/v1/data/proof/verify.py`.
@@ -0,0 +1,13 @@
# /ruview-app — run a RuView sensing application
Run a RuView application. Which one: `$ARGUMENTS` (one of `presence`, `vitals`, `pose`, `sleep`, `environment`, `mat`, `pointcloud`, or a novel-RF app name; if empty, show the catalogue and ask).
- **presence / vitals / pose / environment** → `cd v2 && cargo run -p wifi-densepose-sensing-server` against a live ESP32 sink, or the Docker demo (`docker run -p 3000:3000 ruvnet/wifi-densepose:latest`) for simulated CSI. For environment also `-- --model model.rvf --build-index env`. Vitals: breathing 630 BPM (bandpass 0.10.5 Hz), heart rate 40120 BPM (bandpass 0.82.0 Hz), `wifi-densepose-vitals` crate (ADR-021). Pose: 17 COCO keypoints via WiFlow (ADR-059 live pipeline) — train for accuracy (`/ruview-train`).
- **sleep** → `examples/sleep/` + `node scripts/apnea-detector.js` (sleep-stage classification, apnea screening).
- **mat** (Mass Casualty Assessment — disaster survivor detection) → `wifi-densepose-mat` crate, `docs/wifi-mat-user-guide.md`.
- **pointcloud** → `python scripts/mmwave_fusion_bridge.py` (camera depth via MiDaS + WiFi CSI + mmWave radar → unified spatial model, ~22 ms, 19K+ pts/frame; ADR-094).
- **novel RF** → `scripts/passive-radar.js`, `material-classifier.js`, `device-fingerprint.js`, `mincut-person-counter.js`, `gait-analyzer.js` (ADR-077/078).
No hardware? Fall back to the Docker demo or `python examples/ruview_live.py`. Visualisers: `node scripts/csi-spectrogram.js`, `node scripts/csi-graph-visualizer.js`.
Help me pick: through-wall → presence/activity (≤5 m depth); stationary subject → vitals/sleep; need skeletons → pose (train it); search & rescue → MAT; best spatial accuracy → 2+ ESP32 nodes + cross-viewpoint fusion (`v2/crates/wifi-densepose-ruvector/src/viewpoint/`), optionally + Cognitum Seed. Examples: `examples/{environment,medical,sleep,stress,happiness-vector}/`.
@@ -0,0 +1,17 @@
# /ruview-flash — build + flash ESP32 firmware
Build and flash RuView ESP32 firmware. Variant + port: `$ARGUMENTS` (default `8mb`, port `COM8`).
1. **Variant.** `8mb` → ensure it builds from `firmware/esp32-csi-node/sdkconfig.defaults.template` (no mock — real WiFi CSI). `4mb``cp firmware/esp32-csi-node/sdkconfig.defaults.4mb firmware/esp32-csi-node/sdkconfig.defaults` first (display disabled, dual OTA via `partitions_4mb.csv`). `heltec``sdkconfig.defaults.heltec_n16r2`.
2. **Build (Windows).** ESP-IDF v5.4 does NOT work under Git Bash; `cmd.exe /C` hangs. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped — the exact command is in `CLAUDE.local.md` (`[python, idf_py, 'build']`, cwd = `firmware/esp32-csi-node`). Outputs in `firmware/esp32-csi-node/build/{bootloader/bootloader.bin, partition_table/partition-table.bin, esp32-csi-node.bin, ota_data_initial.bin}`.
3. **Flash.** Same subprocess with `[python, idf_py, '-p', 'COM8', 'flash']`, or:
```
python -m esptool --chip esp32s3 --port COM8 --baud 460800 write_flash \
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
4. **Confirm.** Serial monitor via pyserial on `COM8` @ 115200 (NOT `idf.py monitor` — it hangs in a subprocess). Then `cd v2 && cargo run -p wifi-densepose-sensing-server` — frames should arrive. If not: re-run `/ruview-provision`, match the AP channel, drop any `--filter-mac`.
Never test in mock mode — the Kconfig fall-threshold bug only showed up with real CSI.
@@ -0,0 +1,25 @@
# /ruview-provision — provision an ESP32 sensing node
Write NVS config to a RuView ESP32 node. Args: `$ARGUMENTS` (expect `--port`, `--ssid`, `--password`, `--target-ip`, optional `--channel`, `--filter-mac`). Default port `COM8`.
First get the authoritative flag list: `python firmware/esp32-csi-node/provision.py --help` (on Windows prefix `PYTHONUTF8=1 PYTHONIOENCODING=utf-8` — the help text has non-ASCII and crashes under cp1252). Then run:
```
python firmware/esp32-csi-node/provision.py --port COM8 \
--ssid "<SSID>" --password "<PW>" --target-ip <SINK_IP> --target-port 5005 --node-id <0-255> \
[--channel <N>] [--filter-mac <AA:BB:CC:DD:EE:FF>] [--hop-channels 1,6,11 --hop-dwell 200] \
[--tdm-slot <i> --tdm-total <n>] [--edge-tier 0|1|2] [--pres-thresh 50] [--fall-thresh 15000] \
[--vital-win 300] [--vital-int 1000] [--subk-count 32] \
[--seed-url http://10.1.10.236 --seed-token <bearer> --zone lobby] [--swarm-hb 30] [--swarm-ingest 5] [--dry-run]
```
Trade-offs:
- `--channel <N>` pins the node to one WiFi channel (set it to the AP's channel). Omit it and pass `--hop-channels 1,6,11` for the firmware's multi-band hopping schedule (more sensing bandwidth, uses neighbour APs as illuminators; `--hop-dwell` ms per channel).
- `--filter-mac <MAC>` restricts CSI capture to one transmitter (cleaner signal); omit for all transmitters (more data, more noise).
- `--edge-tier` 0/1/2 = off / stats / vitals (ADR-041). `--tdm-slot`/`--tdm-total` slot a multi-node mesh. `--fall-thresh 15000` ≈ 15.0 rad/s² (raise to cut false falls).
⚠️ **Issue #391:** flashing rewrites the *entire* `csi_cfg` NVS namespace — every key not on the CLI is erased. Pass the full set you want; warn before re-provisioning a working node. `--dry-run` builds the NVS binary without flashing; `--force-partial` allows config without WiFi creds (knowingly).
Fleet provisioning: `python scripts/generate_nvs_matrix.py` (subprocess-first — the `esp_idf_nvs_partition_gen` API changed across versions).
Verify: serial monitor (pyserial on `COM8`, 115200) should show `adaptive_ctrl` ticks + `csi_collector: CSI cb #… len=128 rssi=… ch=…` lines; the sink `cd v2 && cargo run -p wifi-densepose-sensing-server` should report incoming UDP frames if `--target-ip` points at this host. If no frames: wrong channel, MAC filter too tight, target-ip not this host, or WiFi creds wrong — re-run with corrected args.
@@ -0,0 +1,11 @@
# /ruview-start — onboard onto RuView
Help me get started with RuView (WiFi-DensePose). Path: `$ARGUMENTS` (one of `docker`, `build`, `hardware`; if empty, ask which hardware I have).
- **docker** (no hardware): `docker pull ruvnet/wifi-densepose:latest && docker run -p 3000:3000 ruvnet/wifi-densepose:latest`, then open http://localhost:3000 (simulated CSI, full UI).
- **build** (from source): `cd v2 && cargo test --workspace --no-default-features`, then `cd .. && python archive/v1/data/proof/verify.py` (expect `VERDICT: PASS`). Single-crate sanity: `cargo check -p wifi-densepose-train --no-default-features`.
- **hardware** (ESP32-S3/C6): use `/ruview-flash` then `/ruview-provision`, then `cd v2 && cargo run -p wifi-densepose-sensing-server` to consume the UDP CSI stream. Also: `node scripts/rf-scan.js --port 5006`, `node scripts/snn-csi-processor.js --port 5006`.
Warn me about: ESP32-C3 / original ESP32 are unsupported (single-core); one node = limited spatial resolution (use 2+ or add a Cognitum Seed); camera-free pose is modest — camera-supervised training reaches 92.9% PCK@20 (ADR-079); no cloud/cameras/internet needed.
Then point me at next steps: `/ruview-app`, `/ruview-train`, `/ruview-verify`, and the configuration workflow (sdkconfig variants, NVS provisioning, edge modules, mesh, Cognitum Seed). Reference `README.md`, `docs/user-guide.md`, `docs/build-guide.md`, `docs/TROUBLESHOOTING.md`, `examples/`.
@@ -0,0 +1,14 @@
# /ruview-train — train a RuView model
Train / evaluate / publish a RuView model. Track: `$ARGUMENTS` (one of `camera-free`, `camera-supervised`, `embeddings`, `domain-gen`, `snn`, `gpu`; if empty, ask).
- **camera-free** (WiFlow pose, no labels): `cd v2 && cargo run -p wifi-densepose-sensing-server -- --pretrain --dataset data/csi/ --pretrain-epochs 50`, then `-- --train --dataset data/mmfi/ --epochs 100 --save-rvf model.rvf`. ~84 s on M4 Pro, modest accuracy. Bench `node scripts/benchmark-wiflow.js`, eval `node scripts/eval-wiflow.js`.
- **camera-supervised** (ADR-079, 92.9% PCK@20, ~19 min): `python scripts/collect-ground-truth.py` (MediaPipe landmarks; needs `data/pose_landmarker_lite.task`), `python scripts/collect-training-data.py` (CSI capture), `node scripts/align-ground-truth.js` (timestamp align), then `cd v2 && cargo run -p wifi-densepose-sensing-server -- --train --dataset data/paired/ --epochs <N> --save-rvf model.rvf`, eval `node scripts/eval-wiflow.js` (reports PCK@20).
- **embeddings** (AETHER ADR-024 / spectrogram ADR-076): `wifi-densepose-train` + `wifi-densepose-ruvector`; `-- --model model.rvf --embed`, `-- --model model.rvf --build-index env`. 171K emb/s on M4 Pro.
- **domain-gen** (MERIDIAN ADR-027): domain-generalization options in the training pipeline + `ruview_metrics`.
- **snn** (local env adaptation, <30 s): `node scripts/snn-csi-processor.js --port 5006`; `docs/tutorials/cognitum-seed-pretraining.md`; ADR-084/085 (RaBitQ), ADR-086 (novelty gate).
- **gpu**: `gcloud auth login && gcloud config set project cognitum-20260110`, then `bash scripts/gcloud-train.sh --dry-run` (smoke), `bash scripts/gcloud-train.sh --gpu l4 --hours 2` (proto, ~$0.80/hr), `bash scripts/gcloud-train.sh --gpu a100 --config scripts/training-config-sweep.json` (~$3.60/hr), `bash scripts/gcloud-train.sh --sweep` (full sweep). VM auto-deletes unless `--keep-vm`. Local Mac: `bash scripts/mac-mini-train.sh`. Bench: `python scripts/benchmark-model.py`.
Data: `data/recordings/` raw CSI · `data/csi/` pretrain · `data/mmfi/` MM-Fi · `data/paired/` camera↔CSI · `data/ground-truth/` MediaPipe · `models/` artifacts. Record more: `python scripts/record-csi-udp.py`.
After training: `cd v2 && cargo test --workspace --no-default-features`, `cd .. && python archive/v1/data/proof/verify.py` (VERDICT: PASS). Publish: `python scripts/publish-huggingface.py` (or `.sh`; `docs/huggingface/`). Then run `/ruview-verify`.
@@ -0,0 +1,12 @@
# /ruview-verify — run the RuView trust pipeline
Verify a RuView build. Scope: `$ARGUMENTS` (one of `tests`, `proof`, `bundle`, `all`; default `all`).
1. **tests**`cd v2 && cargo test --workspace --no-default-features` → must be 1,400+ passed, 0 failed (~2 min). Single-crate: `cargo test -p wifi-densepose-signal --no-default-features`, etc.
2. **proof**`cd .. && python archive/v1/data/proof/verify.py` → must print `VERDICT: PASS`. If a hash mismatch from a legitimate numpy/scipy bump: `python archive/v1/data/proof/verify.py --generate-hash`, then re-run. Optional: `cd archive/v1 && python -m pytest tests/ -x -q`.
3. **bundle**`bash scripts/generate-witness-bundle.sh` produces `dist/witness-bundle-ADR028-<sha>.tar.gz` (WITNESS-LOG-028.md, ADR-028 audit, proof, rust test log, firmware hash manifest, crate versions, VERIFY.sh). Then `cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh` → must be 7/7 PASS.
4. **all** — do 1→3 in order.
If this follows a code change, walk the pre-merge checklist from `CLAUDE.md`: Rust tests pass; Python proof passes; README updated if scope changed; CLAUDE.md updated if scope changed; CHANGELOG `[Unreleased]` entry; `docs/user-guide.md` updated if new data sources/CLI flags/setup; ADR count bumped in README if a new ADR added; witness bundle regenerated if tests/proof hash changed; Docker image rebuilt only if Dockerfile/deps/runtime changed; crate publishing only if a published crate's public API changed (publish in dependency order — see CLAUDE.md); `.gitignore` updated for new artifacts; security review for new hardware/network-boundary modules.
For security-related changes also run `npx @claude-flow/cli@latest security scan`. QEMU firmware CI (ADR-061): local helpers `scripts/qemu-esp32s3-test.sh`, `qemu-mesh-test.sh`, `qemu-chaos-test.sh`, `install-qemu.sh`.
@@ -0,0 +1,19 @@
---
description: Use advanced RuView capabilities — multistatic sensing, cross-viewpoint fusion, RF tomography, persistent field model, intention signals, adversarial detection, mesh security.
argument-hint: "[multistatic|cross-viewpoint|tomography|field-model|intention|adversarial|security]"
---
# /ruview-advanced
Drive RuView's research-grade / multi-node features.
1. Invoke the **`ruview-advanced-sensing`** skill.
2. Route on `$ARGUMENTS`:
- **multistatic** (ADR-029) — `wifi-densepose-signal/src/ruvsense/multistatic.rs`, `phase_align.rs`, `coherence_gate.rs`; neighbours' APs as illuminators.
- **cross-viewpoint** (ADR-016 viewpoint) — `wifi-densepose-ruvector/src/viewpoint/`; needs 2+ nodes; `node scripts/mesh-graph-transformer.js`.
- **tomography** — `ruvsense/tomography.rs` (ISTA L1 voxel solver) + cross-viewpoint geometry; through-wall volumetric.
- **field-model** (ADR-030) — `ruvsense/field_model.rs`, SVD room eigenstructure persisted to RVF (Cognitum Seed); residual = perturbation.
- **intention** — `ruvsense/intention.rs`, 200500 ms pre-movement lead signals.
- **adversarial** — `ruvsense/adversarial.rs`, physically-impossible-signal + multi-link consistency checks.
- **security** (ADR-032) — mesh hardening: adversarial gate + coherence quarantine + Ed25519 witness chain; run a security review (`docs/security-audit-wasm-edge-vendor.md`), see `/ruview-verify`.
3. Validate: `cd v2 && cargo test -p wifi-densepose-signal --no-default-features && cargo test -p wifi-densepose-ruvector --no-default-features`, then `python archive/v1/data/proof/verify.py`.
+18
View File
@@ -0,0 +1,18 @@
---
description: Run a RuView sensing application — presence, vitals, pose, sleep, environment mapping, MAT, point cloud, or a novel RF app.
argument-hint: "[presence|vitals|pose|sleep|environment|mat|pointcloud|<name>]"
---
# /ruview-app
Launch a RuView application.
1. Invoke the **`ruview-applications`** skill.
2. Map `$ARGUMENTS` to an application; if empty, show the catalogue and ask. Quick mappings:
- `presence` / `vitals` / `pose` / `environment``cd v2 && cargo run -p wifi-densepose-sensing-server` (live ESP32 sink) or the Docker demo for simulated CSI; for environment also `--build-index env`.
- `sleep``examples/sleep/` + `node scripts/apnea-detector.js`.
- `mat` (Mass Casualty Assessment) → `wifi-densepose-mat` crate, `docs/wifi-mat-user-guide.md`.
- `pointcloud``python scripts/mmwave_fusion_bridge.py` (camera depth + CSI + mmWave).
- novel RF → `scripts/passive-radar.js`, `material-classifier.js`, `device-fingerprint.js`, `mincut-person-counter.js`.
3. If no hardware: fall back to `docker run -p 3000:3000 ruvnet/wifi-densepose:latest` or `python examples/ruview_live.py`.
4. Help pick the right modality (through-wall → presence/activity; stationary subject → vitals/sleep; need skeletons → pose, train it for accuracy; search & rescue → MAT; best accuracy → 2+ nodes + cross-viewpoint fusion via `/ruview-advanced`).
+15
View File
@@ -0,0 +1,15 @@
---
description: Build and flash RuView ESP32 firmware (8MB or 4MB), then confirm the CSI stream.
argument-hint: "[8mb|4mb] [COM port]"
---
# /ruview-flash
Build + flash RuView firmware to an ESP32-S3 sensing node.
1. Invoke the **`ruview-hardware-setup`** skill.
2. Determine variant from `$ARGUMENTS` (default `8mb`). For `4mb`: `cp firmware/esp32-csi-node/sdkconfig.defaults.4mb firmware/esp32-csi-node/sdkconfig.defaults` first. For `8mb`: ensure it's built from `sdkconfig.defaults.template` (no mock).
3. Build using the **Python-subprocess** command from `CLAUDE.local.md` (ESP-IDF v5.4 does NOT work under Git Bash — strip `MSYSTEM*` env vars). Never use `cmd.exe /C` from bash.
4. Flash: same subprocess, `[python, idf_py, '-p', '<COM port>', 'flash']` (default port **COM8**), or `python -m esptool ... write_flash ...` with the four binaries.
5. Confirm: serial monitor via pyserial (not `idf.py monitor`), then `cd v2 && cargo run -p wifi-densepose-sensing-server` to see frames arrive.
6. If no frames: re-run `/ruview-provision`, check channel matches the AP, drop any `--filter-mac`.
@@ -0,0 +1,24 @@
---
description: Provision WiFi credentials, sink IP, and optional channel / MAC-filter overrides onto a RuView ESP32 node.
argument-hint: "--port COM8 --ssid ... --password ... --target-ip ... [--channel N] [--filter-mac AA:BB:..]"
---
# /ruview-provision
Write NVS config to an ESP32 sensing node.
1. Invoke the **`ruview-configure`** skill (§"Runtime device config" — has the full `provision.py` flag table).
2. Run `python firmware/esp32-csi-node/provision.py --help` for the authoritative options (on Windows: `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python …` — the help text has non-ASCII). Collect any missing params (port — default **COM8**, SSID, password, target sink IP, `--target-port` default 5005, `--node-id`).
3. Run:
```bash
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<PW>" --target-ip <IP> --target-port 5005 --node-id <0-255> \
[--channel <N>] [--filter-mac <MAC>] [--hop-channels 1,6,11 --hop-dwell 200] \
[--tdm-slot <i> --tdm-total <n>] [--edge-tier {0|1|2}] [--pres-thresh 50] [--fall-thresh 15000] \
[--vital-win 300] [--vital-int 1000] [--subk-count 32] \
[--seed-url http://… --seed-token … --zone lobby] [--swarm-hb 30] [--swarm-ingest 5] [--dry-run]
```
4. Explain trade-offs: `--channel` pins the node (AP's channel) vs. `--hop-channels` for ADR-061 multi-freq hopping; `--filter-mac` restricts to one transmitter vs. omit for all (more data, more noise); `--edge-tier` 0/1/2 = off/stats/vitals; `--tdm-slot`/`--tdm-total` slot a multi-node mesh.
5. ⚠️ **Issue #391**: flashing rewrites the *entire* `csi_cfg` NVS namespace — every key not on the CLI is erased. Pass the full set you want; warn the user before re-provisioning a working node. `--force-partial` bypasses the WiFi-creds requirement (knowingly). `--dry-run` builds the NVS binary without flashing.
6. Fleet provisioning: `scripts/generate_nvs_matrix.py` (subprocess-first).
7. Verify: serial monitor (pyserial on the port, 115200) should show `adaptive_ctrl` ticks + `csi_collector: CSI cb #… len=128 …` lines; the sink (`cd v2 && cargo run -p wifi-densepose-sensing-server`) should report incoming UDP frames if `--target-ip` points at this host.
+16
View File
@@ -0,0 +1,16 @@
---
description: Get started with RuView — pick the fastest path (Docker demo, repo build, or live ESP32) and walk through it.
argument-hint: "[docker|build|hardware]"
---
# /ruview-start
Onboard the user onto RuView (WiFi-DensePose).
1. Invoke the **`ruview-quickstart`** skill.
2. If `$ARGUMENTS` names a tier (`docker`, `build`, `hardware`), go straight to it; otherwise ask which hardware they have:
- **No hardware** → Tier 0: `docker run -p 3000:3000 ruvnet/wifi-densepose:latest`, open `http://localhost:3000`.
- **Want to build from source** → Tier 1: `cd v2 && cargo test --workspace --no-default-features`, then `python archive/v1/data/proof/verify.py`.
- **Have an ESP32-S3 / C6** → Tier 2: hand off to `/ruview-flash` then `/ruview-provision`, then `cargo run -p wifi-densepose-sensing-server`.
3. Warn about the gotchas: ESP32-C3 / original ESP32 unsupported; single node = limited spatial resolution; camera-free pose is modest (use camera-supervised for 92.9% PCK@20).
4. Point to next steps: `/ruview-app`, `/ruview-train`, `/ruview-advanced`, `/ruview-verify`, and the `ruview-configure` skill.
+18
View File
@@ -0,0 +1,18 @@
---
description: Train a RuView model — camera-free WiFlow pose, camera-supervised pose (92.9% PCK@20), RuVector embeddings, domain generalization, local SNN, with optional GPU on GCloud.
argument-hint: "[camera-free|camera-supervised|embeddings|domain-gen|snn|gpu] [--epochs N]"
---
# /ruview-train
Train, fine-tune, evaluate, or publish a RuView model.
1. Invoke the **`ruview-model-training`** skill.
2. Pick the track from `$ARGUMENTS`; if empty, ask which:
- **camera-free** (Track A) — `cargo run -p wifi-densepose-sensing-server -- --pretrain --dataset data/csi/ --pretrain-epochs 50` then `-- --train --dataset data/mmfi/ --epochs 100 --save-rvf model.rvf`. ~84 s on M4 Pro, modest accuracy.
- **camera-supervised** (Track B, ADR-079) — `python scripts/collect-ground-truth.py`, `python scripts/collect-training-data.py`, `node scripts/align-ground-truth.js`, then train on `data/paired/`, eval with `node scripts/eval-wiflow.js`. ~19 min, 92.9% PCK@20. Needs `data/pose_landmarker_lite.task`.
- **embeddings** (Track C, AETHER ADR-024) — `wifi-densepose-train` + `wifi-densepose-ruvector`; `-- --model model.rvf --embed`, `-- --build-index env`.
- **domain-gen** (Track D, MERIDIAN ADR-027) / **snn** (Track E) — `node scripts/snn-csi-processor.js --port 5006`; cognitum-seed-pretraining tutorial.
- **gpu** — `gcloud config set project cognitum-20260110`; `bash scripts/gcloud-train.sh --gpu l4 --hours 2` (or `--gpu a100 --sweep`, `--dry-run` to smoke-test). VM auto-deletes unless `--keep-vm`.
3. After training: `cd v2 && cargo test --workspace --no-default-features`, `python archive/v1/data/proof/verify.py`. To publish: `python scripts/publish-huggingface.py`.
4. Hand off to `/ruview-verify` for the witness bundle.
+17
View File
@@ -0,0 +1,17 @@
---
description: Verify a RuView build — Rust tests, deterministic Python proof, firmware hashes, ADR-028 witness bundle + self-verification, and the pre-merge checklist.
argument-hint: "[tests|proof|bundle|all]"
---
# /ruview-verify
Run RuView's trust pipeline.
1. Invoke the **`ruview-verify`** skill.
2. Based on `$ARGUMENTS` (default `all`):
- **tests** — `cd v2 && cargo test --workspace --no-default-features` (1,400+ pass, 0 fail).
- **proof** — `python archive/v1/data/proof/verify.py` (must print `VERDICT: PASS`; if hash drift from a legit numpy/scipy bump, `--generate-hash` then re-run). Optionally `cd archive/v1 && python -m pytest tests/ -x -q`.
- **bundle** — `bash scripts/generate-witness-bundle.sh`, then `cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh` (must be 7/7 PASS).
- **all** — do all of the above in order.
3. If this follows a code change, walk the **pre-merge checklist** from `CLAUDE.md` (README/CLAUDE.md/CHANGELOG/user-guide updates, ADR count, witness bundle regen, Docker rebuild only if needed, crate publishing in dependency order, `.gitignore`, security review for hardware/network modules).
4. For security-related changes also run `npx @claude-flow/cli@latest security scan`.
@@ -0,0 +1,43 @@
# ADR-0001 — ruview plugin contract
- **Status:** Proposed
- **Date:** 2026-05-11
- **Scope:** `plugins/ruview` (and the repo-root `.claude-plugin/marketplace.json` that lists it)
## Context
RuView (WiFi-DensePose) is a large dual-codebase project (Rust `v2/`, Python `archive/v1/`, ESP32 firmware, 96 ADRs). Newcomers and operators repeatedly re-derive the same workflows: spin up the Docker demo, flash and provision an ESP32, run a sensing application, train a pose model, run the witness verification. We want those workflows packaged as a single discoverable Claude Code plugin (and mirrored for Codex), spanning practical → advanced.
## Decision
1. **One mega-plugin, marketplace-listed from the repo root.** A single plugin `ruview` under `plugins/ruview/`, listed by `.claude-plugin/marketplace.json` **at the repo root** (marketplace name `ruview`, plugin `source: "./plugins/ruview"`). The manifest sits at the repo root so `claude plugin marketplace add ruvnet/RuView` (and `/plugin marketplace add ruvnet/RuView` in Claude Code) resolve it — Claude Code looks for `.claude-plugin/marketplace.json` at the cloned repo's root, not in subdirectories. No sub-plugins; the breadth is organized by skill instead.
2. **Directory contract.**
```
.claude-plugin/marketplace.json # REPO ROOT — marketplace name `ruview`, plugin source ./plugins/ruview
plugins/ruview/.claude-plugin/plugin.json # name, description, version, author, homepage, license, keywords — NO skills/commands/agents arrays
plugins/ruview/skills/<name>/SKILL.md # frontmatter: name, description, allowed-tools
plugins/ruview/commands/<name>.md # frontmatter: description (+ argument-hint)
plugins/ruview/agents/<name>.md # frontmatter: name, description, model
plugins/ruview/docs/adrs/0001-ruview-plugin-contract.md
plugins/ruview/scripts/smoke.sh # structural contract
plugins/ruview/codex/AGENTS.md + codex/README.md + codex/prompts/*.md # Codex mirror
plugins/ruview/README.md # Compatibility + Namespace coordination + Verification + ADR sections
```
Skills/commands/agents are **auto-discovered** from the directory tree — they are deliberately *not* enumerated in `plugin.json`.
3. **Shell-first skills.** Skills drive RuView's own tooling — `cargo`, `python`, `idf.py` (via the Windows Python-subprocess pattern in `CLAUDE.local.md`), `docker`, `node` scripts. `allowed-tools` is limited to core tools (`Bash Read Write Edit Glob Grep`); **no `mcp__claude-flow__*` dependency** and **no wildcard tools**. The only external CLI referenced is `npx @claude-flow/cli@latest security scan`, and only as an optional step for security changes.
4. **Namespace.** The plugin claims the `ruview-*` namespace for skills (`ruview-quickstart`, `ruview-hardware-setup`, `ruview-configure`, `ruview-applications`, `ruview-model-training`, `ruview-advanced-sensing`, `ruview-cli-api`, `ruview-mmwave`, `ruview-verify`), commands (`/ruview-*`), and agents (`ruview-*`). It writes to no `claude-flow` memory namespace. Coexists with the `ruflo` marketplace with zero overlap (`ruview-*` vs. `ruflo-*`); if both are present, defer to `ruflo-agentdb` ADR-0001 §"Namespace convention".
5. **Codex mirror — full command parity.** Every `/ruview-*` command (`ruview-start`, `ruview-flash`, `ruview-provision`, `ruview-app`, `ruview-train`, `ruview-advanced`, `ruview-verify`) has a matching `codex/prompts/<name>.md`; `codex/AGENTS.md` carries the project rules and `codex/README.md` documents installation. The mirror covers the operator-facing **commands** in full; the additional **skills** (`ruview-quickstart`, `ruview-hardware-setup`, `ruview-configure`, `ruview-applications`, `ruview-model-training`, `ruview-advanced-sensing`, `ruview-cli-api`, `ruview-mmwave`, `ruview-verify`) and **agents** have no Codex equivalent — their knowledge is folded into `AGENTS.md` and the prompt files. The smoke script enforces command↔prompt parity.
6. **Compatibility surface.** Targets the `ruvnet/RuView` / `wifi-densepose` repo layout (`v2/crates/`, `firmware/esp32-csi-node/`, `archive/v1/`, `scripts/`, `docs/adr/`). Hardware docs default to ESP32 on `COM8` and tell the reader to confirm the port.
7. **Smoke contract** (`scripts/smoke.sh`, ≥13 checks): repo-root `.claude-plugin/marketplace.json` exists + lists `ruview` + points `source` at `./plugins/ruview`; plugin.json has `name`/`description`/`version`/`keywords` and does **not** contain `skills`/`commands`/`agents` arrays; every `skills/*/SKILL.md` has `name` + `description` + `allowed-tools`; no wildcard (`*`) in any `allowed-tools`; the expected skill set is present; every `commands/*.md` has a `description`; every `agents/*.md` has `name` + `description` + `model`; README contains a `## Compatibility` section and a `Namespace coordination` block; this ADR exists with `Status: Proposed`; `codex/AGENTS.md` and `codex/prompts/*.md` exist **and** every `commands/<name>.md` has a matching `codex/prompts/<name>.md` (command↔prompt parity); nothing is misplaced under `.claude-plugin/`.
## Consequences
- **Good:** `/plugin marketplace add ruvnet/RuView` + `/plugin install ruview@ruview` (or `claude --plugin-dir ./plugins/ruview` from a clone) gives newcomers and operators the whole RuView workflow surface; no MCP-server prerequisite; Codex users get the same operator commands; the smoke script makes drift visible.
- **Cost:** a mega-plugin means coarser install granularity (you get all 9 skills or none); the Codex mirror must be kept in sync by hand (the smoke script checks command↔prompt *presence* parity, not content parity); a skill stem (`ruview-verify`) collides with a command stem — tolerated by Claude Code (both resolve), but `claude plugin details` lists it twice.
- **Follow-ups:** if the skill set grows past comfortable browsing (it's at 9), revisit the "one mega-plugin" decision and split by lifecycle (`ruview-edge`, `ruview-train`, …); add a *content*-parity lint between commands and Codex prompts; consider renaming `/ruview-verify` to drop the skill/command stem collision; consider pinning a tested `claude-flow` CLI minor for the security-scan step if that step becomes load-bearing; verify the underlying RuView command flags (`sensing-server --help`, `gcloud-train.sh`, `provision.py`) against the live tree rather than from README/scripts.
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Structural smoke test for the `ruview` Claude Code plugin.
# Run from anywhere: bash plugins/ruview/scripts/smoke.sh
set -u
# Resolve plugin root (this file lives in <root>/scripts/smoke.sh).
# Plugin lives at <repo>/plugins/ruview ; marketplace manifest is at <repo>/.claude-plugin/marketplace.json
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO="$(cd "$ROOT/../.." && pwd)"
MARKET="$REPO/.claude-plugin/marketplace.json"
PASS=0
FAIL=0
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); }
has() { grep -q "$1" "$2" 2>/dev/null; }
echo "ruview plugin smoke test"
echo "root: $ROOT"
echo "repo: $REPO"
echo
# 1. repo-root marketplace.json exists, lists the ruview plugin, points source at ./plugins/ruview
if [ -f "$MARKET" ] && has '"ruview"' "$MARKET" && has '"\./plugins/ruview"' "$MARKET"; then ok "repo-root .claude-plugin/marketplace.json lists 'ruview' with source ./plugins/ruview"; else bad "marketplace.json missing / wrong location / wrong source ($MARKET)"; fi
# 2. plugin.json exists with required fields
PJ="$ROOT/.claude-plugin/plugin.json"
if [ -f "$PJ" ] && has '"name"' "$PJ" && has '"description"' "$PJ" && has '"version"' "$PJ"; then ok "plugin.json has name/description/version"; else bad "plugin.json missing or incomplete"; fi
# 3. plugin.json has keywords
if has '"keywords"' "$PJ"; then ok "plugin.json has keywords"; else bad "plugin.json missing keywords"; fi
# 4. plugin.json does NOT enumerate skills/commands/agents (auto-discovered)
if has '"skills"' "$PJ" || has '"commands"' "$PJ" || has '"agents"' "$PJ"; then bad "plugin.json must NOT contain skills/commands/agents arrays"; else ok "plugin.json does not enumerate skills/commands/agents"; fi
# 5. every skill has SKILL.md with name + description + allowed-tools, and no wildcard tools
SKILL_OK=1
for d in "$ROOT"/skills/*/; do
[ -d "$d" ] || continue
f="$d/SKILL.md"
if [ ! -f "$f" ]; then bad "missing $f"; SKILL_OK=0; continue; fi
has '^name:' "$f" || { bad "$f missing 'name:'"; SKILL_OK=0; }
has '^description:' "$f" || { bad "$f missing 'description:'"; SKILL_OK=0; }
has '^allowed-tools:' "$f" || { bad "$f missing 'allowed-tools:'"; SKILL_OK=0; }
if grep -E '^allowed-tools:.*(\*|\ball tools\b)' "$f" >/dev/null 2>&1; then bad "$f uses wildcard tools"; SKILL_OK=0; fi
done
[ "$SKILL_OK" = 1 ] && ok "all skills have valid frontmatter, no wildcard tools"
# 6. expected skills present
EXPECTED_SKILLS="ruview-quickstart ruview-hardware-setup ruview-configure ruview-applications ruview-model-training ruview-advanced-sensing ruview-cli-api ruview-mmwave ruview-verify"
SKILLS_PRESENT=1
for s in $EXPECTED_SKILLS; do
[ -f "$ROOT/skills/$s/SKILL.md" ] || { bad "expected skill missing: $s"; SKILLS_PRESENT=0; }
done
[ "$SKILLS_PRESENT" = 1 ] && ok "expected skill set present ($(echo $EXPECTED_SKILLS | wc -w) skills)"
# 7. every command has a description in frontmatter
CMD_OK=1
for f in "$ROOT"/commands/*.md; do
[ -f "$f" ] || { bad "no command files found"; CMD_OK=0; break; }
has '^description:' "$f" || { bad "$f missing 'description:'"; CMD_OK=0; }
done
[ "$CMD_OK" = 1 ] && ok "all commands have a description"
# 8. every agent has name + description + model
AG_OK=1
for f in "$ROOT"/agents/*.md; do
[ -f "$f" ] || { bad "no agent files found"; AG_OK=0; break; }
has '^name:' "$f" || { bad "$f missing 'name:'"; AG_OK=0; }
has '^description:' "$f" || { bad "$f missing 'description:'"; AG_OK=0; }
has '^model:' "$f" || { bad "$f missing 'model:'"; AG_OK=0; }
done
[ "$AG_OK" = 1 ] && ok "all agents have name/description/model"
# 9. README has Compatibility + Namespace coordination
RM="$ROOT/README.md"
if has '## Compatibility' "$RM" && has 'Namespace coordination' "$RM"; then ok "README has Compatibility + Namespace coordination"; else bad "README missing Compatibility or Namespace coordination section"; fi
# 10. ADR-0001 exists with Status: Proposed
ADR="$ROOT/docs/adrs/0001-ruview-plugin-contract.md"
if [ -f "$ADR" ] && grep -qi 'Status:.*Proposed' "$ADR"; then ok "ADR-0001 present with Status: Proposed"; else bad "ADR-0001 missing or not 'Proposed'"; fi
# 11. Codex mirror present
if [ -f "$ROOT/codex/AGENTS.md" ] && ls "$ROOT"/codex/prompts/*.md >/dev/null 2>&1; then ok "Codex mirror present (AGENTS.md + prompts/)"; else bad "Codex mirror missing"; fi
# 11b. command <-> Codex prompt parity
PARITY=1
for f in "$ROOT"/commands/*.md; do
[ -f "$f" ] || continue
base="$(basename "$f")"
[ -f "$ROOT/codex/prompts/$base" ] || { bad "no Codex prompt for command $base"; PARITY=0; }
done
[ "$PARITY" = 1 ] && ok "every command has a matching Codex prompt"
# 12. no skills/commands/agents accidentally placed inside .claude-plugin/
if ls "$ROOT"/.claude-plugin/skills "$ROOT"/.claude-plugin/commands "$ROOT"/.claude-plugin/agents >/dev/null 2>&1; then bad "skills/commands/agents must not live under .claude-plugin/"; else ok ".claude-plugin/ contains only plugin.json"; fi
echo
echo "----------------------------------------"
echo "PASS: $PASS FAIL: $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
@@ -0,0 +1,76 @@
---
name: ruview-advanced-sensing
description: Advanced RuView capabilities — RuvSense multistatic sensing (attention-weighted fusion, geometric diversity, persistent field model), cross-viewpoint fusion across multiple nodes, RF tomography (ISTA L1 solver, voxel grids), longitudinal biomechanics drift, pre-movement intention signals, adversarial signal detection, and multistatic mesh security hardening. Use for research-grade or multi-node deployments.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Advanced Sensing
The deep end: multistatic mesh, tomography, persistent field models, and the security model that protects them. Most of this lives in `wifi-densepose-signal/src/ruvsense/` (14 modules) and `wifi-densepose-ruvector/src/viewpoint/` (5 modules).
## RuvSense multistatic mode (ADR-029)
Treat every WiFi link in range — including neighbours' APs — as a bistatic radar pair, then fuse them.
| Module (`signal/src/ruvsense/`) | Purpose |
|--------------------------------|---------|
| `multiband.rs` | Multi-band CSI frame fusion, cross-channel coherence |
| `phase_align.rs` | Iterative LO phase-offset estimation, circular mean |
| `multistatic.rs` | Attention-weighted fusion, geometric diversity |
| `coherence.rs` / `coherence_gate.rs` | Z-score coherence scoring; Accept / PredictOnly / Reject / Recalibrate gate decisions |
| `pose_tracker.rs` | 17-keypoint Kalman tracker with AETHER re-ID embeddings |
| `field_model.rs` | SVD room eigenstructure, perturbation extraction |
| `tomography.rs` | RF tomography, ISTA L1 solver, voxel grid |
| `longitudinal.rs` | Welford stats, biomechanics drift detection |
| `intention.rs` | Pre-movement lead signals (200500 ms ahead) |
| `cross_room.rs` | Environment fingerprinting, transition graph |
| `gesture.rs` | DTW template-matching gesture classifier |
| `adversarial.rs` | Physically-impossible-signal detection, multi-link consistency |
## Cross-viewpoint fusion (ADR-016 viewpoint module)
Combine 2+ nodes geometrically — more nodes, more independent looks, tighter localization.
| Module (`ruvector/src/viewpoint/`) | Purpose |
|------------------------------------|---------|
| `attention.rs` | CrossViewpointAttention, GeometricBias, softmax with `G_bias` |
| `geometry.rs` | GeometricDiversityIndex, CramérRao bounds, Fisher Information |
| `coherence.rs` | Phase-phasor coherence, hysteresis gate |
| `fusion.rs` | MultistaticArray aggregate root, domain events |
Host-side helpers to explore the geometry before deploying: `node scripts/mesh-graph-transformer.js`, `node scripts/passive-radar.js`, `node scripts/deep-scan.js`.
## Persistent field model (ADR-030)
`field_model.rs` builds an SVD eigenstructure of the room and stores it (RVF, ideally on a Cognitum Seed). New CSI frames are projected against it; the residual *is* the perturbation. Lets you ask "what's different from the empty-room baseline?" and survive restarts.
## RF tomography
`tomography.rs` reconstructs a voxel occupancy grid from the multistatic link set via an ISTA L1 solver (sparse — most voxels are empty). Use with cross-viewpoint geometry for through-wall volumetric imaging. RuVector solver crates back the sparse interpolation (114→56 subcarriers).
## Sensing-first RF mode & adaptive mesh kernel
- ADR-031 (RuView sensing-first RF mode), ADR-081 (adaptive CSI mesh firmware kernel), ADR-083 (per-cluster π compute hop), ADR-095/096 (on-ESP32 temporal modeling with sparse GQA attention — runs the temporal head on-device).
## Security (ADR-032 — multistatic mesh hardening)
Using neighbours' APs as illuminators and pooling links across a mesh expands the attack surface. Mitigations:
- `adversarial.rs` rejects physically impossible signals and cross-checks multi-link consistency.
- `coherence_gate.rs` quarantines low-coherence / suspicious links (Reject / Recalibrate).
- Ed25519 witness chain (ADR-028) attests every measurement.
- Run a security review when touching anything on the hardware/network boundary (see `ruview-verify` and `docs/security-audit-wasm-edge-vendor.md`).
## Validate advanced changes
```bash
cd v2 && cargo test --workspace --no-default-features # incl. ruvsense + viewpoint tests
cargo test -p wifi-densepose-signal --no-default-features
cargo test -p wifi-densepose-ruvector --no-default-features
cd .. && python archive/v1/data/proof/verify.py
```
## Reference
- ADRs: 014 (SOTA signal processing), 029 (multistatic mode), 030 (persistent field model), 031 (sensing-first RF), 032 (mesh security hardening), 081/083/095/096
- `v2/crates/wifi-densepose-signal/src/ruvsense/` · `v2/crates/wifi-densepose-ruvector/src/viewpoint/`
- `docs/research/`, `docs/security-audit-wasm-edge-vendor.md`
@@ -0,0 +1,69 @@
---
name: ruview-applications
description: Run RuView sensing applications — presence/occupancy, breathing & heart rate, activity & fall detection, 17-keypoint pose estimation (WiFlow), sleep monitoring & apnea screening, environment mapping, Mass Casualty Assessment (MAT), and the 3D point-cloud fusion demo. Use when someone wants to actually *do* something with a working RuView setup.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Applications
What RuView can sense, and how to run each one. Assumes you have either the Docker demo (simulated CSI) or a live ESP32 sink (see `ruview-quickstart` / `ruview-hardware-setup`).
## Application catalogue
| Application | What it does | Entry point |
|-------------|--------------|-------------|
| **Presence / occupancy** | Detect people through walls, count them, track entries/exits (trained model + PIR fusion, ~0.012 ms latency) | sensing-server live mode; `examples/environment/` |
| **Vital signs** | Breathing 630 BPM (bandpass 0.10.5 Hz), heart rate 40120 BPM (bandpass 0.82.0 Hz), contactless while sleeping/sitting | `wifi-densepose-vitals` crate (ADR-021); `examples/medical/` |
| **Activity recognition** | Walking, sitting, gestures, falls — from temporal CSI patterns | RuvSense `gesture.rs` (DTW), `pose_tracker.rs`; `scripts/gait-analyzer.js` |
| **Pose estimation** | 17 COCO keypoints via WiFlow architecture; dual-modal webcam+WiFi fusion demo | `cargo run -p wifi-densepose-sensing-server` + pose-fusion demo (ADR-059); see `ruview-model-training` to train |
| **Sleep monitoring** | Overnight monitoring, sleep-stage classification, apnea screening | `examples/sleep/`; `scripts/apnea-detector.js` |
| **Environment mapping** | RF fingerprinting identifies rooms, detects moved furniture, spots new objects | sensing-server `--build-index env`; RuvSense `field_model.rs`, `cross_room.rs` |
| **Mass Casualty Assessment (MAT)** | Disaster survivor detection — find people in rubble/smoke | `wifi-densepose-mat` crate; `docs/wifi-mat-user-guide.md`; `examples/medical/` |
| **3D point cloud** *(optional fusion)* | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model (~22 ms, 19K+ pts/frame) | `scripts/mmwave_fusion_bridge.py`; ADR-094 (GitHub Pages deploy) |
| **Novel RF apps** | Passive radar, material classification, device fingerprinting, mincut person-counting | `scripts/passive-radar.js`, `material-classifier.js`, `device-fingerprint.js`, `mincut-person-counter.js` (ADR-077/078) |
## Quick recipes
```bash
# Docker demo — everything, simulated CSI
docker run -p 3000:3000 ruvnet/wifi-densepose:latest # http://localhost:3000
# Live sensing server (consumes ESP32 UDP CSI)
cd v2 && cargo run -p wifi-densepose-sensing-server
# Live RF room scan (Cognitum Seed on :5006)
node scripts/rf-scan.js --port 5006
node scripts/snn-csi-processor.js --port 5006
# Embed a trained model + build an environment index
cd v2
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --embed
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --build-index env
# Python live demo
python examples/ruview_live.py
# Spectrogram / graph visualisers
node scripts/csi-spectrogram.js
node scripts/csi-graph-visualizer.js
```
## Picking the right modality
- **Through a wall, no line of sight** → presence + activity; expect ≤5 m depth (Fresnel-zone geometry).
- **Person stationary (sleeping / sitting)** → vitals (breathing first, heart rate needs cleaner signal) + sleep staging.
- **Need skeletons** → pose (WiFlow). Camera-free works but is modest; camera-supervised gets 92.9% PCK@20 — train it (`ruview-model-training`).
- **Search & rescue** → MAT (`docs/wifi-mat-user-guide.md`).
- **"What changed in this room?"** → environment mapping / RF fingerprint index.
- **Best spatial accuracy** → 2+ ESP32 nodes + cross-viewpoint fusion (`ruview-advanced-sensing`), optionally + Cognitum Seed.
## Examples directory map
`examples/environment/` · `examples/medical/` · `examples/sleep/` · `examples/stress/` · `examples/happiness-vector/` · `examples/ruview_live.py` — each has a README.
## Reference
- `README.md` — feature matrix, latency/throughput numbers
- `docs/user-guide.md`, `docs/wifi-mat-user-guide.md`
- ADRs: 021 (vitals), 024 (AETHER contrastive embeddings), 027 (MERIDIAN domain generalization), 041 (edge modules), 059 (live ESP32 pipeline), 077/078 (novel RF apps), 082 (pose tracker output filter), 094 (point cloud)
- RuvSense modules: `v2/crates/wifi-densepose-signal/src/ruvsense/` (14 modules)
@@ -0,0 +1,82 @@
---
name: ruview-cli-api
description: Use the RuView `wifi-densepose` CLI binary (incl. MAT scan/status/zones/survivors/alerts/export subcommands), the REST API (`wifi-densepose-api`, Axum), and the browser/WASM build (`wifi-densepose-wasm`, `wifi-densepose-wasm-edge`). Use when integrating RuView into another program, scripting it from the shell, exposing it over HTTP, or shipping it to the browser / ESP32-WASM3.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView CLI, API & WASM
The programmatic surfaces of RuView — the `wifi-densepose` binary, the HTTP API, and the WebAssembly builds.
## 1. The `wifi-densepose` CLI binary (`wifi-densepose-cli`)
```bash
cd v2
cargo run -p wifi-densepose-cli -- --help # or: cargo build -p wifi-densepose-cli --release → target/release/wifi-densepose
cargo run -p wifi-densepose-cli -- version
```
Top-level subcommands: `version`, and `mat` (Mass Casualty Assessment Tool).
### `wifi-densepose mat …` — disaster survivor detection
| Subcommand | Purpose | Key flags |
|------------|---------|-----------|
| `mat scan [zone]` | Start scanning for survivors | `--disaster-type <…>`, `--sensitivity 0.01.0`, `--max-depth <m>`, `--continuous`, `--interval <ms>`, `--simulate` |
| `mat status` | Current scan status | `--detailed`, `--format <…>`, `--watch` |
| `mat zones …` | Manage scan zones | `zones list [--active-only]`, plus add/remove/update |
| `mat survivors` | List detected survivors with triage status | |
| `mat alerts` | View / manage alerts | |
| `mat export` | Export scan data | JSON or CSV |
Example:
```bash
cargo run -p wifi-densepose-cli -- mat scan rubble-A --disaster-type earthquake --sensitivity 0.7 --max-depth 5 --continuous --interval 2000
cargo run -p wifi-densepose-cli -- mat survivors --format json
cargo run -p wifi-densepose-cli -- mat export --format csv > survivors.csv
```
Use `--simulate` for testing without hardware. Background and user guide: `docs/wifi-mat-user-guide.md`, `wifi-densepose-mat` crate.
## 2. REST API (`wifi-densepose-api`, Axum)
Library crate (`v2/crates/wifi-densepose-api/src/lib.rs`) — the Axum router/handlers; configured via the `wifi-densepose-config` crate. It's wired into the server binaries (e.g. the sensing server / Docker image), not a standalone `cargo run` target by itself.
```bash
# Easiest way to exercise it: the Docker image exposes the API + dashboard on :3000
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
# Then hit the HTTP endpoints (see the API module / docs for routes) and open http://localhost:3000
# v1 Python service config reference: example.env, pyproject.toml (archive/v1/)
```
When embedding the API crate in your own binary, take the router from `wifi_densepose_api`, supply config via `wifi-densepose-config`, and serve with Axum/Tokio. Keep input validation at the boundary (project rule).
## 3. WASM / browser & ESP32-WASM3
- **`wifi-densepose-wasm`** — compiles the stack to `wasm32-unknown-unknown` with a JS-friendly API:
```bash
cd v2/crates/wifi-densepose-wasm
wasm-pack build --target web --features mat # recommended (produces pkg/)
cargo build --target wasm32-unknown-unknown --features mat # plain cargo build
```
See `v2/crates/wifi-densepose-wasm/README.md` for the exported surface.
- **`wifi-densepose-wasm-edge`** — 60 edge modules (609 tests) that compile to `wasm32-unknown-unknown` and run on ESP32-S3 via WASM3; shared utils in `src/vendor_common.rs`. These are the ADR-041 edge-intelligence modules in WASM form.
- Browser demos: pose-fusion (ADR-059), point-cloud (ADR-094) — deployed via GitHub Pages from the WASM build.
## 4. Where it fits
| You want to… | Use |
|--------------|-----|
| Script a survivor scan / export results | `wifi-densepose mat …` |
| Expose sensing over HTTP | `wifi-densepose-api` (via a server binary / Docker) |
| Run sensing in a browser | `wifi-densepose-wasm` → `wasm-pack build --target web` |
| Run an edge module on an ESP32 in WASM | `wifi-densepose-wasm-edge` + WASM3 |
| A long-running CSI sink + training | `wifi-densepose-sensing-server` (see `ruview-applications` / `ruview-model-training`) |
## Reference
- Crates: `wifi-densepose-cli`, `wifi-densepose-api`, `wifi-densepose-config`, `wifi-densepose-wasm`, `wifi-densepose-wasm-edge`, `wifi-densepose-mat`
- ADRs: 041 (edge modules), 059 (live ESP32 pipeline), 094 (point-cloud GitHub Pages)
- `docs/wifi-mat-user-guide.md`, `docs/edge-modules/`, `docs/security-audit-wasm-edge-vendor.md`
- Validate after changes: `cd v2 && cargo test -p wifi-densepose-cli -p wifi-densepose-api -p wifi-densepose-wasm --no-default-features`
@@ -0,0 +1,99 @@
---
name: ruview-configure
description: Configure RuView — ESP32 sdkconfig variants, NVS provisioning, WiFi channel / MAC filter overrides (ADR-060), edge intelligence modules (ADR-041), sensing-server flags, multi-node mesh, and Cognitum Seed integration. Use when adjusting how a deployed RuView system behaves without changing code.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Configuration
Everything you can tune in a RuView deployment, from a one-line provision flag to a full mesh + Cognitum Seed setup.
## 1. Firmware build-time config (sdkconfig)
| Variant | File | When |
|---------|------|------|
| 8MB (default) | `firmware/esp32-csi-node/sdkconfig.defaults.template` | ESP32-S3 8MB, full feature set, real WiFi CSI |
| 4MB | `firmware/esp32-csi-node/sdkconfig.defaults.4mb` | ESP32-S3 SuperMini 4MB — display disabled, dual OTA slots (`partitions_4mb.csv`, ~1.856 MB each) |
| Heltec N16R2 | `firmware/esp32-csi-node/sdkconfig.defaults.heltec_n16r2` | Heltec boards |
Switch: `cp firmware/esp32-csi-node/sdkconfig.defaults.<variant> firmware/esp32-csi-node/sdkconfig.defaults`, then rebuild (see `ruview-hardware-setup`). **Never test in mock mode** — the Kconfig fall-threshold bug only showed up with real CSI.
## 2. Runtime device config (NVS via provision.py)
`provision.py` writes the `csi_cfg` NVS namespace over the serial port. **Run `python firmware/esp32-csi-node/provision.py --help` for the authoritative flag list** (on Windows force `PYTHONUTF8=1 PYTHONIOENCODING=utf-8` — the help text contains non-ASCII and crashes under cp1252).
```bash
python firmware/esp32-csi-node/provision.py --port COM8 \
--ssid "WiFi" --password "secret" \
--target-ip 192.168.1.20 --target-port 5005 \ # aggregator UDP sink (port default 5005)
--node-id 1 \ # 0-255
--channel 6 --filter-mac AA:BB:CC:DD:EE:FF # ADR-060: pin channel + filter transmitter
```
| Flag group | Flags | Notes |
|------------|-------|-------|
| WiFi / sink | `--ssid` `--password` `--target-ip` `--target-port` (5005) `--node-id` | `--node-id` 0-255 |
| TDM mesh | `--tdm-slot` `--tdm-total` | 0-based slot index + total node count — this is how multi-node mesh is slotted |
| Edge processing | `--edge-tier {0,1,2}` | 0=off, 1=stats, 2=vitals (ADR-041) |
| Detection thresholds | `--pres-thresh` (50) `--fall-thresh` (15000 → 15.0 rad/s²) | raise `--fall-thresh` to cut false falls in high-traffic areas (issue #263) |
| Vitals | `--vital-win` (300 frames) `--vital-int` (1000 ms) `--subk-count` (32, top-K subcarriers) | |
| Channel / hopping | `--channel` (1-14 / 36-177, overrides AP auto-detect) `--filter-mac` `--hop-channels` (`1,6,11`) `--hop-dwell` (200 ms) | omit `--channel` + set `--hop-channels` for ADR-061 multi-freq hopping; omit `--filter-mac` to capture all transmitters |
| Cognitum Seed | `--seed-url` (`http://10.1.10.236`) `--seed-token` (Bearer, from pairing) `--zone` (`lobby`) | |
| Swarm | `--swarm-hb` (30 s) `--swarm-ingest` (5 s) | heartbeat + vector ingest intervals |
| Mode | `--dry-run` (build NVS bin, don't flash) `--baud` (460800) `--force-partial` | |
> ⚠️ **NVS namespace is replaced wholesale (issue #391).** Flashing rewrites the *entire* `csi_cfg` namespace — **any key you don't pass on the CLI is erased**. Always pass the full set you want, or use `--force-partial` knowingly. Read the device's current values off the serial boot log first (`adaptive_ctrl` / `csi_collector` lines) if you're unsure.
- NVS partition images for fleet provisioning: `scripts/generate_nvs_matrix.py` (subprocess-first — the `esp_idf_nvs_partition_gen` API changed across versions).
## 3. Sensing server flags
```bash
cd v2
cargo run -p wifi-densepose-sensing-server -- --help
# Common modes:
cargo run -p wifi-densepose-sensing-server # live sink, default port
cargo run -p wifi-densepose-sensing-server -- --pretrain --dataset data/csi/ --pretrain-epochs 50
cargo run -p wifi-densepose-sensing-server -- --train --dataset data/mmfi/ --epochs 100 --save-rvf model.rvf
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --embed
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --build-index env
```
`wifiscan` server (multi-BSSID, ADR-022): `cargo run -p wifi-densepose-sensing-server` consumes `wifi-densepose-wifiscan` output; use neighbour APs as free radar illuminators.
## 4. Edge intelligence modules (ADR-041)
Small Rust/WASM programs that run on the ESP32 itself — no internet, instant response. See `docs/edge-modules/` and `docs/adr/ADR-041-*`. Each module declares its CSI feature inputs (8-dim feature vectors) and an RVF store target (Cognitum Seed). Configure which modules ship in a build via the firmware component config; configure their thresholds via NVS keys.
Helper scripts that mirror edge-module logic on the host (useful for tuning before flashing):
`scripts/apnea-detector.js`, `gait-analyzer.js`, `material-classifier.js`, `passive-radar.js`, `mincut-person-counter.js`, `device-fingerprint.js`, `mesh-graph-transformer.js`, `material-detector.js`.
## 5. Multi-node mesh
- 2+ nodes give real spatial resolution. Each node provisioned to the same `--target-ip` sink.
- TDM protocol + channel hopping coordinated by `wifi-densepose-hardware` (`v2/crates/wifi-densepose-hardware/src/esp32/`).
- Cross-viewpoint fusion combines nodes — see `ruview-advanced-sensing`.
## 6. Cognitum Seed integration ($140 total BOM)
ESP32 streams CSI → bridge forwards to a Cognitum Seed for persistent RVF memory, kNN over environments, and an Ed25519 witness chain.
```bash
node scripts/rf-scan.js --port 5006 # live RF room scan → Seed
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning on-Seed
```
See `docs/tutorials/cognitum-seed-pretraining.md` and ADR-028 (capability audit + witness verification).
## 7. App-level config
- API: `wifi-densepose-api` (Axum) — config via `wifi-densepose-config` crate; see `example.env` / `pyproject.toml` for the v1 Python service.
- Docker: `docker run -p 3000:3000 ruvnet/wifi-densepose:latest` (env-var overrides documented in `README.md` / `docker/`).
- Dashboard: served on `:3000`; nvsim dashboard (ADR-092) is separate.
## Reference
- `docs/adr/` (96 ADRs) — esp. ADR-022 (wifiscan), ADR-028 (capability audit), ADR-041 (edge modules), ADR-060 (channel/MAC override), ADR-061 (QEMU + mesh), ADR-081 (adaptive CSI mesh kernel)
- `CLAUDE.md` / `CLAUDE.local.md` — crate map, build env, QEMU CI fixes
- `example.env`, `Makefile`, `firmware/esp32-csi-node/`
@@ -0,0 +1,127 @@
---
name: ruview-hardware-setup
description: ESP32-S3 / ESP32-C6 firmware build, flash, WiFi provisioning, and serial monitoring for RuView CSI sensing nodes. Use when setting up physical hardware, reflashing a node, or debugging a device that isn't streaming CSI.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Hardware Setup
Bring a RuView sensing node online: build firmware → flash → provision WiFi → confirm CSI stream.
## Supported devices
| Device | Flash | Chip | Role |
|--------|-------|------|------|
| ESP32-S3 (8MB) | 8 MB | Xtensa dual-core | WiFi CSI sensing node (default) |
| ESP32-S3 SuperMini | 4 MB | Xtensa dual-core | Compact CSI node — use `sdkconfig.defaults.4mb` |
| ESP32-C6 + Seeed MR60BHA2 | — | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence |
**Not supported:** original ESP32, ESP32-C3 (single-core).
## 1. Build firmware (Windows — Python subprocess, NOT bash directly)
ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it:
```bash
/c/Espressif/tools/python/v5.4/venv/Scripts/python.exe -c "
import subprocess, os
env = os.environ.copy()
for k in ['MSYSTEM','MSYSTEM_CHOST','MSYSTEM_PREFIX','MINGW_PREFIX','CHERE_INVOKING']:
env.pop(k, None)
env['IDF_PATH'] = r'C:\Users\ruv\esp\v5.4\esp-idf'
env['IDF_PYTHON_ENV_PATH'] = r'C:\Espressif\tools\python\v5.4\venv'
env['IDF_TOOLS_PATH'] = r'C:\Espressif'
env['PATH'] = (
r'C:\Espressif\tools\xtensa-esp-elf\esp-14.2.0_20241119\xtensa-esp-elf\bin;'
r'C:\Espressif\tools\cmake\3.30.2\cmake-3.30.2-windows-x86_64\bin;'
r'C:\Espressif\tools\ninja\1.12.1;'
r'C:\Espressif\tools\idf-exe\1.0.3;'
r'C:\Espressif\tools\ccache\4.10.2\ccache-4.10.2-windows-x86_64;'
r'C:\Espressif\tools\python\v5.4\venv\Scripts;'
+ env['PATH']
)
python = r'C:\Espressif\tools\python\v5.4\venv\Scripts\python.exe'
idf_py = os.path.join(env['IDF_PATH'], 'tools', 'idf.py')
r = subprocess.run([python, idf_py, 'build'], # flash: [python, idf_py, '-p', 'COM8', 'flash']
cwd=r'C:\Users\ruv\Projects\wifi-densepose\firmware\esp32-csi-node',
env=env, capture_output=True, text=True, timeout=300)
print(r.stdout[-3000:]); print(r.stderr[-2000:]); print('RC:', r.returncode)
"
```
- **8MB build:** uses `sdkconfig.defaults.template` (no mock — real WiFi CSI).
- **4MB build:** `cp firmware/esp32-csi-node/sdkconfig.defaults.4mb firmware/esp32-csi-node/sdkconfig.defaults` first, then build.
- Build outputs: `firmware/esp32-csi-node/build/{bootloader/bootloader.bin, partition_table/partition-table.bin, esp32-csi-node.bin, ota_data_initial.bin}`.
## 2. Flash to the device
Same subprocess pattern, swap `[python, idf_py, 'build']``[python, idf_py, '-p', 'COM8', 'flash']`. Or with esptool directly:
```bash
python -m esptool --chip esp32s3 --port COM8 --baud 460800 \
write_flash 0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
(The default device port in this workspace is **COM8**. Some docs reference COM9 — confirm with the user.)
## 3. Provision WiFi + sink address
Runs directly — no ESP-IDF env needed:
```bash
python firmware/esp32-csi-node/provision.py --port COM8 \
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20 --target-port 5005 --node-id 1
# Optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port COM8 --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
`--help` lists the full flag set (TDM mesh slotting, edge tier, detection thresholds, vitals window, hop channels, Cognitum Seed, swarm intervals) — see the `ruview-configure` skill for the table. **Gotcha (issue #391):** flashing replaces the *entire* `csi_cfg` NVS namespace — any key not on the CLI is erased; pass the full set you want. On Windows, `provision.py --help` needs `PYTHONUTF8=1` to print (non-ASCII in the help text).
## 4. Confirm CSI stream
```bash
# Serial monitor (use pyserial — idf.py monitor hangs in a subprocess)
/c/Espressif/tools/python/v5.4/venv/Scripts/python.exe -c "
import serial, time
ser = serial.Serial('COM8', 115200, timeout=1); start = time.time()
while time.time() - start < 15:
line = ser.readline()
if line: print(line.decode('utf-8', errors='replace').strip())
ser.close()
"
```
Then start the sink and watch frames arrive:
```bash
cd v2 && cargo run -p wifi-densepose-sensing-server # listens for ESP32 UDP CSI
```
## Common issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| `MSys/Mingw is no longer supported` | ESP-IDF detected Git Bash | Use the Python-subprocess command above with `MSYSTEM*` stripped |
| `cmd.exe /C` hangs | Interactive prompt from Git Bash | Don't use `cmd.exe /C` — use the Python subprocess |
| `cmake not found` | Wrong path | It's `cmake\3.30.2\cmake-3.30.2-windows-x86_64\bin`, not `cmake\3.30.2\bin` |
| `python_env not found` | Missing env var | Set `IDF_PYTHON_ENV_PATH=C:\Espressif\tools\python\v5.4\venv` |
| No CSI frames at the sink | WiFi not provisioned, wrong channel, or MAC filter too tight | Re-run `provision.py`; try `--channel` matching your AP; drop `--filter-mac` |
| False fall alerts | Old `fall_thresh` default | Issue #263 raised it to 15.0 rad/s² + debounce — reflash latest firmware |
## Firmware release process (for maintainers)
1. Build 8MB from `sdkconfig.defaults.template` (no mock)
2. Build 4MB from `sdkconfig.defaults.4mb` (no mock)
3. Save 6 binaries: `esp32-csi-node.bin`, `bootloader.bin`, `partition-table.bin`, `ota_data_initial.bin`, `esp32-csi-node-4mb.bin`, `partition-table-4mb.bin`
4. `git tag v0.X.Y-esp32 && git push origin v0.X.Y-esp32`
5. `gh release create v0.X.Y-esp32 <binaries> --title "..." --notes-file ...`
6. Verify on real hardware (COM8) before publishing — **always test with real WiFi CSI, not mock mode** (mock missed the Kconfig threshold bug)
## Reference
- `CLAUDE.local.md` — exact ESP-IDF build env, paths, QEMU CI notes
- `firmware/esp32-csi-node/` — C firmware (channel hopping, NVS config, TDM protocol)
- `docs/adr/ADR-028-esp32-capability-audit.md`, `docs/build-guide.md`, `docs/TROUBLESHOOTING.md`
@@ -0,0 +1,61 @@
---
name: ruview-mmwave
description: Set up and run RuView mmWave / FMCW radar sensing — ESP32-C6 + Seeed MR60BHA2 (60 GHz, heart rate / breathing rate / presence) and HLK-LD2410 (24 GHz, presence + distance), plus mmWave↔WiFi-CSI sensor fusion (48-byte fused vitals, MR60BHA2/LD2410 auto-detect, v0.5.0+). Use when the deployment includes a millimetre-wave radar alongside or instead of WiFi CSI.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView mmWave / FMCW Radar
The radio side-channel: 60 GHz and 24 GHz FMCW radar, standalone and fused with WiFi CSI.
## Hardware
| Device | Port | Band | Provides | ~Cost |
|--------|------|------|----------|-------|
| ESP32-C6 + Seeed MR60BHA2 | COM4 (typical) | 60 GHz FMCW | Heart rate, breathing rate, presence | ~$15 |
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance (gated zones) | ~$3 |
The C6 is RISC-V and can run the radar pipeline; it is **not** a WiFi-CSI node (use an ESP32-S3 for CSI). LD2410 is a UART module wired to a host or to the C6.
## 1. Firmware with mmWave fusion (v0.5.0+)
The ESP32 firmware auto-detects an attached MR60BHA2 or LD2410 and emits **48-byte fused vitals** records (CSI-derived + radar-derived, reconciled). Binary is ~12 KB larger than the CSI-only build. Build/flash as in `ruview-hardware-setup` (Windows: Python-subprocess; ESP-IDF v5.4 ≠ Git Bash). Recommended stable firmware tag: `v0.5.0-esp32` or later — see `docs/user-guide.md` release table.
```bash
# Provision the radar/fusion node (same provision.py; the firmware probes for the radar on boot)
python firmware/esp32-csi-node/provision.py --port COM8 --ssid "WiFi" --password "secret" --target-ip 192.168.1.20
# Confirm: serial monitor should report which radar was detected and start emitting fused vitals
```
## 2. mmWave ↔ WiFi-CSI fusion bridge (host side)
```bash
python scripts/mmwave_fusion_bridge.py # bridges radar HR/BR + CSI → unified spatial model
node scripts/passive-radar.js # passive-radar style processing for exploration
```
The 3D point-cloud demo fuses **camera depth (MiDaS) + WiFi CSI + mmWave radar** → unified spatial model (~22 ms pipeline, 19K+ pts/frame; ADR-094). Drive it with `scripts/mmwave_fusion_bridge.py` plus the point-cloud front-end.
## 3. Standalone radar use
- **MR60BHA2 (60 GHz)** — best for contactless vitals on a (near-)stationary subject: blood pressure proxy, heart rate, breathing rate; $15 hardware, no wearable. See `examples/medical/README.md`.
- **LD2410 (24 GHz)** — best for cheap presence + coarse distance / gated zones; complements CSI presence (PIR-style fusion) for higher confidence.
## 4. When to use mmWave vs. WiFi CSI
| Situation | Prefer |
|-----------|--------|
| Contactless vitals, subject stationary, line of sight | **MR60BHA2** (cleaner HR/BR than CSI alone) |
| Cheap, robust presence / occupancy in a defined zone | **LD2410** (or LD2410 + CSI) |
| Through-wall presence / activity, no line of sight | **WiFi CSI** (mmWave doesn't penetrate walls) |
| Pose / skeletons | **WiFi CSI** (WiFlow) — mmWave doesn't do this here |
| Highest-confidence vitals | **Fusion** — 48-byte fused vitals reconcile CSI + radar |
| Volumetric 3D | **Fusion** — camera depth + CSI + mmWave point cloud |
## Reference
- Hardware tables: `README.md`, `docs/user-guide.md` (release table — v0.5.0 mmWave fusion notes, binary sizes)
- `scripts/mmwave_fusion_bridge.py`, `scripts/passive-radar.js`
- `examples/medical/README.md` (60 GHz mmWave vitals)
- ADR-094 (point-cloud GitHub Pages deployment)
- Validate firmware changes with the QEMU helpers and `ruview-verify`
@@ -0,0 +1,122 @@
---
name: ruview-model-training
description: Train RuView models — camera-free WiFlow pose (10 sensor signals, no labels), camera-supervised pose (MediaPipe + ESP32 CSI → 92.9% PCK@20, ADR-079), RuVector contrastive embeddings (AETHER, ADR-024), domain generalization (MERIDIAN, ADR-027), local SNN environment adaptation, plus GPU training on GCloud and Hugging Face publishing. Use when building, fine-tuning, evaluating, or shipping a model.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Model Training
RuView trains several kinds of model. Pick the track that matches the goal; all of them run on a laptop, with an optional GPU path.
## Track A — Camera-free pose (WiFlow), no cameras, no labels
Trains 17-keypoint pose from 10 sensor signals. Fast, fully unsupervised, modest accuracy.
```bash
cd v2
# Pretrain on raw CSI (contrastive)
cargo run -p wifi-densepose-sensing-server -- --pretrain --dataset data/csi/ --pretrain-epochs 50
# Train pose head, save an RVF artifact
cargo run -p wifi-densepose-sensing-server -- --train --dataset data/mmfi/ --epochs 100 --save-rvf model.rvf
```
~84 s on an M4 Pro. Benchmarks: `node scripts/benchmark-wiflow.js`, eval: `node scripts/eval-wiflow.js`.
## Track B — Camera-supervised pose (ADR-079) → 92.9% PCK@20
Uses a webcam + MediaPipe as ground truth, paired with ESP32 CSI. ~19 min on a laptop.
```bash
# 1. Collect paired data (camera + CSI)
python scripts/collect-ground-truth.py # MediaPipe pose landmarks
python scripts/collect-training-data.py # CSI capture, time-synced
node scripts/align-ground-truth.js # align camera ↔ CSI timestamps
# 2. Train (the camera-supervised path through the sensing-server / train crate)
cd v2
cargo run -p wifi-densepose-sensing-server -- --train --dataset data/paired/ --epochs <N> --save-rvf model.rvf
# 3. Evaluate
cd .. && node scripts/eval-wiflow.js # reports PCK@20
```
Requires `data/pose_landmarker_lite.task` (MediaPipe model). See `docs/adr/ADR-079-camera-ground-truth-training.md`.
## Track C — RuVector contrastive embeddings (AETHER, ADR-024)
CSI subcarrier amplitude/phase → embeddings for re-ID and retrieval (171K emb/s on M4 Pro). Driven by `wifi-densepose-train` + `wifi-densepose-ruvector` (RuVector v2.0.4). Spectrogram embeddings: ADR-076.
```bash
cd v2
cargo check -p wifi-densepose-train --no-default-features # sanity
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --embed
cargo run -p wifi-densepose-sensing-server -- --model model.rvf --build-index env
```
## Track D — Domain generalization (MERIDIAN, ADR-027)
Make a model transfer across environments without retraining. Configured through the training pipeline's domain-generalization options; see ADR-027 and `wifi-densepose-train` + `ruview_metrics`.
## Track E — Local SNN environment adaptation
Spiking neural network that adapts to a new room in <30 s, on-device or on a Cognitum Seed:
```bash
node scripts/snn-csi-processor.js --port 5006
```
See `docs/tutorials/cognitum-seed-pretraining.md`, ADR-084/085 (RaBitQ similarity sensor), ADR-086 (edge novelty gate).
## GPU training on GCloud
Project `cognitum-20260110` has L4 / A100 / H100 quota.
```bash
gcloud auth login
gcloud config set project cognitum-20260110
bash scripts/gcloud-train.sh --dry-run # smoke test, synthetic data
bash scripts/gcloud-train.sh --gpu l4 --hours 2 # prototyping
bash scripts/gcloud-train.sh --gpu a100 --config scripts/training-config-sweep.json
bash scripts/gcloud-train.sh --sweep # full hyperparameter sweep
# VM is auto-deleted after training unless --keep-vm. Cost: L4 ~$0.80/hr, A100 40GB ~$3.60/hr.
```
Local Mac training: `bash scripts/mac-mini-train.sh`. Model benchmark: `python scripts/benchmark-model.py`.
## Publishing a trained model
```bash
python scripts/publish-huggingface.py # or: bash scripts/publish-huggingface.sh
```
Pushes the RVF artifact + card to Hugging Face. See `docs/huggingface/`.
## Data layout
| Path | Contents |
|------|----------|
| `data/recordings/` | Raw CSI captures (`*.csi.jsonl`), overnight runs |
| `data/csi/` | CSI datasets for pretraining |
| `data/mmfi/` | MM-Fi dataset (ADR-015) |
| `data/paired/` | Camera ↔ CSI paired samples (ADR-079) |
| `data/ground-truth/` | MediaPipe pose landmarks |
| `data/pose_landmarker_lite.task` | MediaPipe model file |
| `models/` | Trained artifacts |
Record more data: `python scripts/record-csi-udp.py` (UDP CSI capture from a live node).
## Validation after a training change
```bash
cd v2 && cargo test --workspace --no-default-features # 1,400+ pass, 0 fail
cd .. && python archive/v1/data/proof/verify.py # VERDICT: PASS
```
Then hand off to `ruview-verify` for the witness bundle.
## Reference
- ADRs: 015 (MM-Fi + Wi-Pose datasets), 016 (RuVector training integration — complete), 017 (RuVector signal + MAT), 024 (AETHER), 027 (MERIDIAN), 076 (spectrogram embeddings), 079 (camera ground truth), 084/085 (RaBitQ), 095/096 (on-ESP32 temporal modeling, sparse GQA)
- Crates: `wifi-densepose-train`, `wifi-densepose-nn`, `wifi-densepose-ruvector`, `wifi-densepose-sensing-server`
- `scripts/gcloud-train.sh`, `mac-mini-train.sh`, `benchmark-wiflow.js`, `eval-wiflow.js`, `benchmark-model.py`
@@ -0,0 +1,77 @@
---
name: ruview-quickstart
description: Onboarding and first-run for RuView (WiFi-DensePose) — Docker demo with simulated data, repo build, and the fastest path to a live sensing dashboard. Use when someone is new to RuView or wants the shortest path to "it works on my machine".
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Quickstart
Get a newcomer from zero to a running RuView sensing dashboard. Three tiers, pick the one that matches the hardware on hand.
## Tier 0 — Docker, no hardware (2 minutes)
```bash
docker pull ruvnet/wifi-densepose:latest
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
# open http://localhost:3000 — simulated CSI, full UI
```
Use this to demo the dashboard, explore the API, or develop UI without a sensor.
## Tier 1 — Build the repo from source
```bash
# Rust workspace (1,400+ tests, ~2 min)
cd v2
cargo test --workspace --no-default-features
# Single-crate sanity check (no GPU)
cargo check -p wifi-densepose-train --no-default-features
# Python proof (deterministic SHA-256 pipeline check)
cd ..
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
```
If `verify.py` fails on a hash mismatch after a numpy/scipy bump:
```bash
python archive/v1/data/proof/verify.py --generate-hash
python archive/v1/data/proof/verify.py
```
## Tier 2 — Live sensing with an ESP32-S3 ($9)
This is the real thing. Hand off to the `ruview-hardware-setup` skill for the flash/provision/monitor loop, then:
```bash
# Lightweight sensing server (consumes the ESP32 UDP CSI stream)
cd v2
cargo run -p wifi-densepose-sensing-server
# Live RF room scan / SNN learning helpers:
node ../scripts/rf-scan.js --port 5006
node ../scripts/snn-csi-processor.js --port 5006
```
## What to know before you start
- **ESP32-C3 and the original ESP32 are NOT supported** — single-core, can't run the CSI DSP pipeline. Use ESP32-S3 (8MB or 4MB) or ESP32-C6.
- A **single ESP32** has limited spatial resolution — 2+ nodes (or add a Cognitum Seed) for good results.
- Camera-free pose accuracy is limited (~84s to train, modest PCK). For 92.9% PCK@20 use camera-supervised training (see `ruview-model-training` skill, ADR-079).
- No cloud, no internet, no cameras required — everything runs on edge hardware.
## Next steps to suggest
| Goal | Skill / command |
|------|-----------------|
| Flash & provision an ESP32 node | `ruview-hardware-setup` · `/ruview-flash` · `/ruview-provision` |
| Tune channels / MAC filter / edge modules | `ruview-configure` |
| Run a sensing application (presence, vitals, pose, sleep, MAT) | `ruview-applications` · `/ruview-app` |
| Train a pose / sensing model | `ruview-model-training` · `/ruview-train` |
| Multistatic mesh, tomography, cross-viewpoint fusion | `ruview-advanced-sensing` · `/ruview-advanced` |
| Verify the build + generate a witness bundle | `ruview-verify` · `/ruview-verify` |
## Reference
- `README.md` — feature matrix, hardware table, install options
- `docs/user-guide.md`, `docs/wifi-mat-user-guide.md`, `docs/build-guide.md`, `docs/TROUBLESHOOTING.md`
- `docs/tutorials/`, `examples/` — runnable examples (environment, medical, sleep, stress, `ruview_live.py`)
@@ -0,0 +1,97 @@
---
name: ruview-verify
description: Verify a RuView build — full Rust workspace tests, the deterministic Python pipeline proof (SHA-256 Trust Kill Switch), firmware hash manifest, and the ADR-028 witness bundle with one-command self-verification. Use after any significant change, before merging a PR, or to produce an attestation bundle for a recipient.
allowed-tools: Bash Read Write Edit Glob Grep
---
# RuView Verification & Witness Bundle
The trust pipeline for RuView. Run this after meaningful changes and before merging.
## 1. Rust workspace tests
```bash
cd v2
cargo test --workspace --no-default-features # must be 1,400+ passed, 0 failed (~2 min)
```
Single-crate checks (no GPU): `cargo check -p wifi-densepose-train --no-default-features`, `cargo test -p wifi-densepose-signal --no-default-features`, etc.
## 2. Deterministic Python proof (Trust Kill Switch)
Feeds a reference CSI signal through the **production** pipeline and hashes the output. Any behavioural drift changes the hash.
```bash
cd ..
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
```
If it fails on a hash mismatch after a legitimate numpy/scipy bump:
```bash
python archive/v1/data/proof/verify.py --generate-hash
python archive/v1/data/proof/verify.py
```
Artifacts: `archive/v1/data/proof/verify.py`, `expected_features.sha256`, `sample_csi_data.json` (1,000 synthetic frames, seed=42).
## 3. Python test suite (v1)
```bash
cd archive/v1 && python -m pytest tests/ -x -q
```
## 4. Generate the witness bundle (ADR-028)
```bash
bash scripts/generate-witness-bundle.sh
```
Produces `dist/witness-bundle-ADR028-<sha>.tar.gz` containing:
- `WITNESS-LOG-028.md` — 33-row attestation matrix, evidence per capability
- `ADR-028-esp32-capability-audit.md` — full audit findings
- `proof/verify.py` + `expected_features.sha256` — the deterministic proof
- `test-results/rust-workspace-tests.log` — full cargo test output
- `firmware-manifest/source-hashes.txt` — SHA-256 of all 7 ESP32 firmware files
- `crate-manifest/versions.txt` — all 15 crates + versions
- `VERIFY.sh` — one-command self-verification for recipients
## 5. Self-verify the bundle
```bash
cd dist/witness-bundle-ADR028-*/
bash VERIFY.sh # must be 7/7 PASS
```
## Pre-merge checklist (from CLAUDE.md)
1. Rust tests pass (1,400+, 0 fail)
2. Python proof passes (VERDICT: PASS)
3. `README.md` updated if scope changed (platform/crate/hardware tables, feature summaries)
4. `CLAUDE.md` updated if scope changed (crate table, ADR list, module tables, version)
5. `CHANGELOG.md` — entry under `[Unreleased]`
6. `docs/user-guide.md` updated if new data sources / CLI flags / setup steps
7. ADR index — bump ADR count in README docs table if a new ADR was added
8. Witness bundle regenerated if tests or proof hash changed
9. Docker Hub image rebuilt only if Dockerfile / deps / runtime behaviour changed
10. Crate publishing only if a published crate's public API changed (publish in dependency order — see CLAUDE.md)
11. `.gitignore` updated for new build artifacts/binaries
12. Security review for new modules touching hardware/network boundaries
## Security scan
```bash
npx @claude-flow/cli@latest security scan # after security-related changes
```
Also see `docs/security-audit-wasm-edge-vendor.md`, `docs/qe-reports/`, ADR-080 (QE remediation plan), ADR-093 (dashboard gap analysis).
## QEMU firmware CI (ADR-061)
11-job workflow ("Firmware QEMU Tests"). Local QEMU helpers: `scripts/qemu-esp32s3-test.sh`, `qemu-mesh-test.sh`, `qemu-chaos-test.sh`, `qemu-snapshot-test.sh`, `install-qemu.sh`. Notes: `espressif/idf:v5.4` container needs `source $IDF_PATH/export.sh` before `pip`; QEMU needs `esptool merge_bin --fill-flash-size 8MB`; WARNs (no real WiFi) are treated as OK in CI.
## Reference
- `docs/WITNESS-LOG-028.md`, `docs/adr/ADR-028-esp32-capability-audit.md`
- `scripts/generate-witness-bundle.sh`, `archive/v1/data/proof/verify.py`
- `CLAUDE.md` → "Validation & Witness Verification" + "Pre-Merge Checklist"
- `CLAUDE.local.md` → QEMU CI pipeline fixes
+190
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:]))
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Transcode an ESP32 .csi.jsonl recording into a .rvcsi capture (JSONL).
This is the moral equivalent of `rvcsi record --source esp32-jsonl` (which the
PR does not ship yet): parse each ESP32 frame, derive amplitude/phase from the
raw int8 I/Q pairs, run the same validation/quality logic rvcsi_core does, and
write a .rvcsi file whose first line is a CaptureHeader and every later line a
CsiFrame. Rejected frames are dropped (quarantine), like the real pipeline.
Usage: esp32_jsonl_to_rvcsi.py <in.csi.jsonl> <out.rvcsi> [--limit N]
"""
import json
import math
import sys
# --- rvcsi_core::ValidationPolicy::default() -------------------------------
MIN_SUBCARRIERS = 1
MAX_SUBCARRIERS = 4096
RSSI_LO, RSSI_HI = -110, 0
MIN_QUALITY = 0.25
RSSI_HARD_MARGIN = 30
def quality_and_status(amplitude, rssi_dbm):
"""Faithful port of rvcsi_core::validation::validate_frame soft scoring."""
reasons = []
q = 1.0
sc = len(amplitude)
# out-of-range (non-fatal) RSSI
if rssi_dbm is not None and (rssi_dbm < RSSI_LO or rssi_dbm > RSSI_HI):
q *= 0.6
reasons.append(f"rssi {rssi_dbm} dBm outside [{RSSI_LO},{RSSI_HI}]")
# dead subcarriers
dead = sum(1 for a in amplitude if a < 1e-6)
if dead > 0:
frac = dead / max(sc, 1)
q *= max(1.0 - frac, 0.05)
reasons.append(f"{dead}/{sc} dead subcarriers")
# amplitude spike vs median
if sc >= 3:
s = sorted(amplitude)
median = max(s[sc // 2], 1e-9)
mx = s[-1]
if mx > median * 50.0:
q *= 0.7
reasons.append(f"amplitude spike: max {mx:.3f} vs median {median:.3f}")
if rssi_dbm is None:
q *= 0.95
reasons.append("missing rssi")
q = min(max(q, 0.0), 1.0)
if q < MIN_QUALITY:
status = "Degraded" # degrade_instead_of_reject = true
else:
status = "Accepted"
return q, status, reasons
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(2)
in_path, out_path = sys.argv[1], sys.argv[2]
limit = None
if "--limit" in sys.argv:
limit = int(sys.argv[sys.argv.index("--limit") + 1])
source_id = "esp32-com7-rec"
header = {
"rvcsi_capture_version": 1,
"session_id": 0,
"source_id": source_id,
"adapter_profile": {
"adapter_kind": "Esp32",
"chip": "ESP32-S3",
"firmware_version": None,
"driver_version": None,
"supported_channels": [],
"supported_bandwidths_mhz": [],
"expected_subcarrier_counts": [],
"supports_live_capture": True,
"supports_injection": False,
"supports_monitor_mode": False,
},
"validation_policy": {
"min_subcarriers": MIN_SUBCARRIERS,
"max_subcarriers": MAX_SUBCARRIERS,
"rssi_dbm_bounds": [RSSI_LO, RSSI_HI],
"strict_monotonic_time": False,
"degrade_instead_of_reject": True,
"min_quality": MIN_QUALITY,
},
"calibration_version": None,
"runtime_config_json": "{}",
"created_unix_ns": 0,
}
stats = {
"read": 0, "written": 0,
"rej_len": 0, "rej_sc": 0, "rej_nonfinite": 0, "rej_rssi": 0,
"accepted": 0, "degraded": 0,
}
sc_hist = {}
out = open(out_path, "w", newline="\n")
out.write(json.dumps(header, separators=(",", ":")) + "\n")
fid = 0
with open(in_path) as f:
for line in f:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("type") != "raw_csi":
continue
stats["read"] += 1
if limit is not None and stats["read"] > limit:
stats["read"] -= 1
break
iq_hex = d.get("iq_hex", "")
raw = bytes.fromhex(iq_hex)
n_pairs = len(raw) // 2
# ESP-IDF CSI buffer layout: [imag0, real0, imag1, real1, ...] as int8
i_vals, q_vals, amp, ph = [], [], [], []
for k in range(n_pairs):
imag = raw[2 * k]
real = raw[2 * k + 1]
if imag >= 128:
imag -= 256
if real >= 128:
real -= 256
fi, fq = float(real), float(imag)
i_vals.append(fi)
q_vals.append(fq)
amp.append(math.sqrt(fi * fi + fq * fq))
ph.append(math.atan2(fq, fi))
sc = n_pairs
sc_hist[sc] = sc_hist.get(sc, 0) + 1
# hard checks (mirror validate_frame)
if sc < MIN_SUBCARRIERS or sc > MAX_SUBCARRIERS:
stats["rej_sc"] += 1
continue
# int8 -> always finite, lengths consistent by construction
# RSSI: the v1 collector's rssi byte is unreliable (sentinels 64/-128
# etc.); only carry it through when it lands in a plausible band,
# otherwise leave it None (a small quality penalty, not a reject).
r = d.get("rssi")
rssi_dbm = r if (isinstance(r, int) and -140 <= r <= 30) else None
if rssi_dbm is not None and (rssi_dbm < RSSI_LO - RSSI_HARD_MARGIN or rssi_dbm > RSSI_HI + RSSI_HARD_MARGIN):
stats["rej_rssi"] += 1
continue
if rssi_dbm is not None and not (-110 <= rssi_dbm <= 0):
rssi_dbm = None # implausible but not insane -> drop the field
q, status, reasons = quality_and_status(amp, rssi_dbm)
ch = d.get("channel", 0) or 0
frame = {
"frame_id": fid,
"session_id": 0,
"source_id": source_id,
"adapter_kind": "Esp32",
"timestamp_ns": int(d.get("ts_ns", 0)),
"channel": int(ch),
"bandwidth_mhz": 20,
"rssi_dbm": rssi_dbm,
"noise_floor_dbm": None,
"antenna_index": 0,
"tx_chain": None,
"rx_chain": None,
"subcarrier_count": sc,
"i_values": i_vals,
"q_values": q_vals,
"amplitude": amp,
"phase": ph,
"validation": status,
"quality_score": q,
}
if reasons:
frame["quality_reasons"] = reasons
frame["calibration_version"] = None
out.write(json.dumps(frame, separators=(",", ":")) + "\n")
fid += 1
stats["written"] += 1
stats[status.lower()] = stats.get(status.lower(), 0) + 1
out.close()
print("transcode stats:", json.dumps(stats))
print("subcarrier-count histogram:", json.dumps(dict(sorted(sc_hist.items(), key=lambda x: -x[1]))))
if __name__ == "__main__":
main()
+115
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"
}
]
}
Generated
+293 -7
View File
@@ -231,6 +231,18 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "async-compression"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -318,7 +330,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-tungstenite",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -871,6 +883,23 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -915,6 +944,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -1256,7 +1294,7 @@ version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case",
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
@@ -2371,6 +2409,16 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hdrhistogram"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
dependencies = [
"byteorder",
"num-traits",
]
[[package]]
name = "heapless"
version = "0.6.1"
@@ -3152,7 +3200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading",
"libloading 0.7.4",
"once_cell",
]
@@ -3172,6 +3220,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -3585,6 +3643,63 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "napi"
version = "2.16.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags 2.11.0",
"ctor",
"napi-derive",
"napi-sys",
"once_cell",
]
[[package]]
name = "napi-build"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
[[package]]
name = "napi-derive"
version = "2.16.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
dependencies = [
"cfg-if",
"convert_case 0.6.0",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "napi-derive-backend"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
dependencies = [
"convert_case 0.6.0",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn 2.0.117",
]
[[package]]
name = "napi-sys"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
dependencies = [
"libloading 0.8.9",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -3892,13 +4007,35 @@ name = "nvsim"
version = "0.3.0"
dependencies = [
"approx 0.5.1",
"criterion",
"js-sys",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde-wasm-bindgen",
"serde_json",
"sha2",
"thiserror 1.0.69",
"tracing",
"wasm-bindgen",
]
[[package]]
name = "nvsim-server"
version = "0.3.0"
dependencies = [
"axum",
"clap",
"futures-util",
"nvsim",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
]
[[package]]
@@ -4487,6 +4624,26 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "pin-project"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -5278,7 +5435,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tower",
"tower 0.5.3",
"tower-http 0.6.8",
"tower-service",
"url",
@@ -5311,7 +5468,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http 0.6.8",
"tower-service",
"url",
@@ -5798,6 +5955,111 @@ version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac"
[[package]]
name = "rvcsi-adapter-file"
version = "0.3.0"
dependencies = [
"rvcsi-core",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-adapter-nexmon"
version = "0.3.0"
dependencies = [
"cc",
"rvcsi-core",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-cli"
version = "0.3.0"
dependencies = [
"anyhow",
"clap",
"rvcsi-adapter-file",
"rvcsi-adapter-nexmon",
"rvcsi-core",
"rvcsi-runtime",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "rvcsi-core"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-dsp"
version = "0.3.0"
dependencies = [
"rvcsi-core",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-events"
version = "0.3.0"
dependencies = [
"rvcsi-core",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-node"
version = "0.3.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"rvcsi-adapter-nexmon",
"rvcsi-core",
"rvcsi-runtime",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "rvcsi-runtime"
version = "0.3.0"
dependencies = [
"rvcsi-adapter-file",
"rvcsi-adapter-nexmon",
"rvcsi-core",
"rvcsi-dsp",
"rvcsi-events",
"rvcsi-ruvector",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "rvcsi-ruvector"
version = "0.3.0"
dependencies = [
"rvcsi-core",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -7379,6 +7641,27 @@ dependencies = [
"zip 0.6.6",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"hdrhistogram",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.5",
"slab",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -7401,8 +7684,10 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"async-compression",
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
@@ -7433,7 +7718,7 @@ dependencies = [
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
@@ -8385,6 +8670,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tower-http 0.5.2",
]
[[package]]
@@ -8454,7 +8740,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-train"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"approx 0.5.1",
+17
View File
@@ -21,6 +21,16 @@ members = [
"crates/wifi-densepose-geo",
"crates/nvsim",
"crates/nvsim-server",
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout)
"crates/rvcsi-core",
"crates/rvcsi-dsp",
"crates/rvcsi-events",
"crates/rvcsi-adapter-file",
"crates/rvcsi-adapter-nexmon",
"crates/rvcsi-ruvector",
"crates/rvcsi-runtime",
"crates/rvcsi-node",
"crates/rvcsi-cli",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
@@ -108,6 +118,13 @@ indicatif = "0.17"
# CLI
clap = { version = "4.4", features = ["derive", "env"] }
# rvCSI: napi-rs (Rust -> Node bindings) + napi-c (C-shim build glue)
napi = { version = "2.16", default-features = false, features = ["napi8"] }
napi-derive = "2.16"
napi-build = "2.1"
cc = "1.0"
libc = "0.2"
# Testing
criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1.4"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "rvcsi-adapter-file"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI file/replay adapter — records and replays .rvcsi capture sessions deterministically (ADR-095 FR1/FR10, D9)"
repository.workspace = true
keywords = ["wifi", "csi", "replay", "rvcsi"]
categories = ["science"]
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tempfile = "3.10"
+144
View File
@@ -0,0 +1,144 @@
//! The `.rvcsi` capture container format (ADR-095 FR1/FR10, D9).
//!
//! A `.rvcsi` file is plain [JSONL]: the **first line** is a
//! [`CaptureHeader`] object describing the session; every **subsequent line**
//! is one [`rvcsi_core::CsiFrame`] serialized as JSON. This keeps the format
//! simple, deterministic, append-friendly and trivially debuggable with `head`
//! / `jq`.
//!
//! [JSONL]: https://jsonlines.org/
use rvcsi_core::{AdapterProfile, SessionId, SourceId, ValidationPolicy};
use serde::{Deserialize, Serialize};
/// Current `.rvcsi` capture format version. Written into every header and
/// checked on read.
pub const CAPTURE_VERSION: u32 = 1;
/// Header object — the first line of every `.rvcsi` capture file.
///
/// It records enough context to replay the session faithfully: the originating
/// session/source ids, the source's [`AdapterProfile`], the
/// [`ValidationPolicy`] that was in force, the calibration version (if any),
/// and an opaque `runtime_config_json` blob the caller may use for whatever it
/// likes (defaults to `"{}"`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CaptureHeader {
/// Capture format version (always [`CAPTURE_VERSION`] when written).
pub rvcsi_capture_version: u32,
/// Session this capture belongs to.
pub session_id: SessionId,
/// Source the frames were captured from.
pub source_id: SourceId,
/// Capability descriptor of the source at capture time.
pub adapter_profile: AdapterProfile,
/// Validation policy that was in force during capture.
pub validation_policy: ValidationPolicy,
/// Calibration version frames were processed against, if any.
pub calibration_version: Option<String>,
/// Opaque caller-supplied runtime config (JSON; default `"{}"`).
pub runtime_config_json: String,
/// Wall-clock creation time, nanoseconds since the Unix epoch (`0` if unknown).
pub created_unix_ns: u64,
}
impl CaptureHeader {
/// Build a header for `session_id` / `source_id` / `adapter_profile` with
/// sensible defaults: version [`CAPTURE_VERSION`], [`ValidationPolicy::default`],
/// no calibration version, `runtime_config_json == "{}"`, and
/// `created_unix_ns` taken from the system clock (or `0` if it is unavailable
/// or before the epoch).
pub fn new(session_id: SessionId, source_id: SourceId, adapter_profile: AdapterProfile) -> Self {
CaptureHeader {
rvcsi_capture_version: CAPTURE_VERSION,
session_id,
source_id,
adapter_profile,
validation_policy: ValidationPolicy::default(),
calibration_version: None,
runtime_config_json: "{}".to_string(),
created_unix_ns: now_unix_ns(),
}
}
/// Builder: override the validation policy.
pub fn with_validation_policy(mut self, policy: ValidationPolicy) -> Self {
self.validation_policy = policy;
self
}
/// Builder: set the calibration version.
pub fn with_calibration_version(mut self, version: impl Into<String>) -> Self {
self.calibration_version = Some(version.into());
self
}
/// Builder: set the opaque runtime config blob.
pub fn with_runtime_config_json(mut self, json: impl Into<String>) -> Self {
self.runtime_config_json = json.into();
self
}
/// Builder: pin `created_unix_ns` (useful for deterministic tests).
pub fn with_created_unix_ns(mut self, ns: u64) -> Self {
self.created_unix_ns = ns;
self
}
}
/// Best-effort "nanoseconds since the Unix epoch" using the system clock;
/// returns `0` when the clock is unavailable or set before the epoch.
fn now_unix_ns() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::AdapterKind;
#[test]
fn header_defaults() {
let h = CaptureHeader::new(
SessionId(7),
SourceId::from("file:lab.rvcsi"),
AdapterProfile::offline(AdapterKind::File),
);
assert_eq!(h.rvcsi_capture_version, CAPTURE_VERSION);
assert_eq!(h.runtime_config_json, "{}");
assert!(h.calibration_version.is_none());
assert_eq!(h.validation_policy, ValidationPolicy::default());
}
#[test]
fn header_builders() {
let h = CaptureHeader::new(
SessionId(1),
SourceId::from("s"),
AdapterProfile::offline(AdapterKind::File),
)
.with_calibration_version("room@v2")
.with_runtime_config_json(r#"{"foo":1}"#)
.with_created_unix_ns(42);
assert_eq!(h.calibration_version.as_deref(), Some("room@v2"));
assert_eq!(h.runtime_config_json, r#"{"foo":1}"#);
assert_eq!(h.created_unix_ns, 42);
}
#[test]
fn header_json_roundtrips() {
let h = CaptureHeader::new(
SessionId(3),
SourceId::from("esp32"),
AdapterProfile::esp32_default(),
)
.with_created_unix_ns(123);
let json = serde_json::to_string(&h).unwrap();
let back: CaptureHeader = serde_json::from_str(&json).unwrap();
assert_eq!(h, back);
}
}
+342
View File
@@ -0,0 +1,342 @@
//! # rvCSI file/replay adapter
//!
//! The `.rvcsi` capture container, its [`FileRecorder`], and the
//! [`FileReplayAdapter`] [`CsiSource`](rvcsi_core::CsiSource) (ADR-095 FR1/FR10,
//! D9).
//!
//! A `.rvcsi` file is plain [JSONL]: the first line is a [`CaptureHeader`]
//! describing the session; every subsequent line is one
//! [`rvcsi_core::CsiFrame`] serialized as compact JSON. The format is simple,
//! deterministic, append-friendly and trivially inspectable with `head` / `jq`.
//!
//! Typical use:
//!
//! ```no_run
//! use rvcsi_adapter_file::{CaptureHeader, FileRecorder, FileReplayAdapter};
//! use rvcsi_core::{AdapterKind, AdapterProfile, CsiSource, SessionId, SourceId};
//!
//! # fn demo() -> rvcsi_core::Result<()> {
//! let header = CaptureHeader::new(
//! SessionId(1),
//! SourceId::from("file:lab.rvcsi"),
//! AdapterProfile::offline(AdapterKind::File),
//! );
//! let mut rec = FileRecorder::create("lab.rvcsi", &header)?;
//! // rec.write_frame(&frame)?; ...
//! rec.finish()?;
//!
//! let mut replay = FileReplayAdapter::open("lab.rvcsi")?;
//! while let Some(frame) = replay.next_frame()? {
//! // hand `frame` downstream — its ValidationStatus is preserved as recorded
//! let _ = frame;
//! }
//! # Ok(())
//! # }
//! ```
//!
//! [JSONL]: https://jsonlines.org/
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod format;
mod recorder;
mod replay;
pub use format::{CaptureHeader, CAPTURE_VERSION};
pub use recorder::FileRecorder;
pub use replay::FileReplayAdapter;
use std::path::Path;
use rvcsi_core::{CsiFrame, Result};
/// Read an entire `.rvcsi` capture into memory: its [`CaptureHeader`] and every
/// [`CsiFrame`] it contains, in recording order.
///
/// This is a convenience wrapper over [`FileReplayAdapter`]; for large captures
/// or streaming use, prefer iterating [`FileReplayAdapter`] directly. Errors are
/// the same as [`FileReplayAdapter::open`] / [`FileReplayAdapter::next_frame`]:
/// an [`rvcsi_core::RvcsiError::Io`] for a missing/unreadable file, an
/// [`rvcsi_core::RvcsiError::Parse`] (offset `0`) for a bad header, or an
/// [`rvcsi_core::RvcsiError::Parse`] carrying the 1-based line number for a
/// malformed frame line.
pub fn read_all(path: impl AsRef<Path>) -> Result<(CaptureHeader, Vec<CsiFrame>)> {
use rvcsi_core::CsiSource;
let mut adapter = FileReplayAdapter::open(path)?;
let header = adapter.header().clone();
let mut frames = Vec::new();
while let Some(frame) = adapter.next_frame()? {
frames.push(frame);
}
Ok((header, frames))
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{
AdapterKind, AdapterProfile, CsiSource, FrameId, RvcsiError, SessionId, SourceId,
ValidationStatus,
};
use std::fs::File;
use std::io::{Read, Write};
fn header() -> CaptureHeader {
CaptureHeader::new(
SessionId(1),
SourceId::from("it-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0)
.with_calibration_version("room@v1")
.with_runtime_config_json(r#"{"window_ms":500}"#)
}
/// A small varied set of frames: two accepted (quality 0.9), two degraded
/// with reasons, one recovered — varying timestamps / channels / subcarrier
/// counts.
fn sample_frames() -> Vec<CsiFrame> {
let mut frames = Vec::new();
let mut f0 = CsiFrame::from_iq(
FrameId(0),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
1_000,
1,
20,
vec![1.0, 2.0, 3.0, 4.0],
vec![0.5, 0.5, 0.5, 0.5],
)
.with_rssi(-55);
f0.validation = ValidationStatus::Accepted;
f0.quality_score = 0.9;
frames.push(f0);
let mut f1 = CsiFrame::from_iq(
FrameId(1),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
2_000,
6,
40,
vec![0.1; 8],
vec![0.2; 8],
);
f1.validation = ValidationStatus::Degraded;
f1.quality_score = 0.4;
f1.quality_reasons = vec!["missing rssi".to_string(), "low snr".to_string()];
frames.push(f1);
let mut f2 = CsiFrame::from_iq(
FrameId(2),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
3_000,
11,
20,
vec![5.0, 6.0],
vec![1.0, -1.0],
)
.with_rssi(-70)
.with_noise_floor(-95);
f2.validation = ValidationStatus::Accepted;
f2.quality_score = 0.9;
frames.push(f2);
let mut f3 = CsiFrame::from_iq(
FrameId(3),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
2_500, // deliberately out of order — replay preserves it verbatim
6,
20,
vec![0.0; 3],
vec![0.0; 3],
);
f3.validation = ValidationStatus::Recovered;
f3.quality_score = 0.3;
frames.push(f3);
let mut f4 = CsiFrame::from_iq(
FrameId(4),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
4_000,
36,
80,
vec![2.0; 6],
vec![0.0; 6],
);
f4.validation = ValidationStatus::Degraded;
f4.quality_score = 0.5;
f4.quality_reasons = vec!["amplitude spike".to_string()];
frames.push(f4);
frames
}
#[test]
fn record_then_replay_roundtrips_exactly() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
assert_eq!(rec.frames_written(), frames.len() as u64);
rec.finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(adapter.header(), &header);
let mut got = Vec::new();
while let Some(f) = adapter.next_frame().unwrap() {
got.push(f);
}
assert_eq!(got, frames);
assert_eq!(adapter.health().frames_delivered, frames.len() as u64);
assert!(!adapter.health().connected);
}
#[test]
fn re_serializing_replayed_frames_is_byte_identical() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
let mut original = String::new();
File::open(tmp.path()).unwrap().read_to_string(&mut original).unwrap();
// Round-trip the whole capture and re-emit it; bytes must match.
let (h, fs) = read_all(tmp.path()).unwrap();
let tmp2 = tempfile::NamedTempFile::new().unwrap();
let mut rec2 = FileRecorder::create(tmp2.path(), &h).unwrap();
for f in &fs {
rec2.write_frame(f).unwrap();
}
rec2.finish().unwrap();
let mut reemitted = String::new();
File::open(tmp2.path()).unwrap().read_to_string(&mut reemitted).unwrap();
assert_eq!(original, reemitted);
}
#[test]
fn read_all_matches_replay() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
let (h, fs) = read_all(tmp.path()).unwrap();
assert_eq!(h, header);
assert_eq!(fs, frames);
}
#[test]
fn header_only_capture_has_no_frames() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(adapter.next_frame().unwrap().is_none());
let (h, fs) = read_all(tmp.path()).unwrap();
assert_eq!(h, header);
assert!(fs.is_empty());
}
#[test]
fn bad_header_line_is_parse_error_at_offset_zero() {
let tmp = tempfile::NamedTempFile::new().unwrap();
{
let mut f = File::create(tmp.path()).unwrap();
f.write_all(b"not json\n").unwrap();
}
match FileReplayAdapter::open(tmp.path()) {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
other => panic!("expected Parse at offset 0, got {other:?}"),
}
match read_all(tmp.path()) {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
other => panic!("expected Parse at offset 0, got {other:?}"),
}
}
#[test]
fn garbage_frame_after_good_frames_reports_line_number() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
// lines 2 + 3: good frames
let frames = sample_frames();
serde_json::to_writer(&mut f, &frames[0]).unwrap();
f.write_all(b"\n").unwrap();
serde_json::to_writer(&mut f, &frames[1]).unwrap();
f.write_all(b"\n").unwrap();
// line 4: garbage
f.write_all(b"{ not a frame }\n").unwrap();
}
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(adapter.next_frame().unwrap().is_some()); // line 2
assert!(adapter.next_frame().unwrap().is_some()); // line 3
match adapter.next_frame() {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 4),
other => panic!("expected Parse at line 4, got {other:?}"),
}
}
#[test]
fn nonexistent_path_is_io_error() {
match FileReplayAdapter::open("/no/such/file/at/all.rvcsi") {
Err(RvcsiError::Io(_)) => {}
other => panic!("expected Io error, got {other:?}"),
}
match read_all("/no/such/file/at/all.rvcsi") {
Err(RvcsiError::Io(_)) => {}
other => panic!("expected Io error, got {other:?}"),
}
}
#[test]
fn counters_are_consistent() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for (i, f) in frames.iter().enumerate() {
rec.write_frame(f).unwrap();
assert_eq!(rec.frames_written(), (i + 1) as u64);
}
rec.finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
let mut n = 0u64;
while adapter.next_frame().unwrap().is_some() {
n += 1;
assert_eq!(adapter.health().frames_delivered, n);
}
assert_eq!(n, frames.len() as u64);
}
}
@@ -0,0 +1,113 @@
//! [`FileRecorder`] — writes a `.rvcsi` capture: a header line followed by one
//! JSON line per [`CsiFrame`].
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use rvcsi_core::{CsiFrame, Result};
use crate::format::CaptureHeader;
/// Append-only writer for a `.rvcsi` capture file.
///
/// Create one with [`FileRecorder::create`] (which writes the header line),
/// push frames with [`FileRecorder::write_frame`], and call
/// [`FileRecorder::finish`] (or just drop it after [`FileRecorder::flush`]) to
/// be sure everything reached disk.
pub struct FileRecorder {
writer: BufWriter<File>,
frames_written: u64,
}
impl FileRecorder {
/// Create `path` (truncating any existing file) and write `header` as the
/// first line.
pub fn create(path: impl AsRef<Path>, header: &CaptureHeader) -> Result<Self> {
let file = File::create(path.as_ref())?;
let mut writer = BufWriter::new(file);
write_json_line(&mut writer, header)?;
Ok(FileRecorder {
writer,
frames_written: 0,
})
}
/// Append one frame as a JSON line.
pub fn write_frame(&mut self, frame: &CsiFrame) -> Result<()> {
write_json_line(&mut self.writer, frame)?;
self.frames_written += 1;
Ok(())
}
/// Flush buffered bytes to the underlying file.
pub fn flush(&mut self) -> Result<()> {
self.writer.flush()?;
Ok(())
}
/// Number of frames written so far (the header line is not counted).
pub fn frames_written(&self) -> u64 {
self.frames_written
}
/// Flush and close the file, consuming the recorder.
pub fn finish(mut self) -> Result<()> {
self.flush()
}
}
/// Serialize `value` as a single JSON line (no embedded newlines — `serde_json`
/// compact form never produces them) followed by `\n`.
fn write_json_line<W: Write, T: serde::Serialize>(writer: &mut W, value: &T) -> Result<()> {
serde_json::to_writer(&mut *writer, value)?;
writer.write_all(b"\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{AdapterKind, AdapterProfile, FrameId, SessionId, SourceId};
use std::io::Read;
fn frame(id: u64, ts: u64) -> CsiFrame {
CsiFrame::from_iq(
FrameId(id),
SessionId(1),
SourceId::from("rec-test"),
AdapterKind::File,
ts,
6,
20,
vec![1.0, 2.0, 3.0],
vec![0.5, 0.5, 0.5],
)
}
#[test]
fn writes_header_then_frames_and_counts() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rec-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
assert_eq!(rec.frames_written(), 0);
rec.write_frame(&frame(0, 100)).unwrap();
rec.write_frame(&frame(1, 200)).unwrap();
assert_eq!(rec.frames_written(), 2);
rec.finish().unwrap();
let mut contents = String::new();
File::open(tmp.path()).unwrap().read_to_string(&mut contents).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 3);
let parsed_header: CaptureHeader = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed_header, header);
let f0: CsiFrame = serde_json::from_str(lines[1]).unwrap();
assert_eq!(f0, frame(0, 100));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! [`FileReplayAdapter`] — a [`CsiSource`] that replays a `.rvcsi` capture
//! file, frame by frame, exactly as it was recorded.
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use rvcsi_core::{
AdapterProfile, CsiFrame, CsiSource, Result, RvcsiError, SessionId, SourceHealth, SourceId,
};
use crate::format::{CaptureHeader, CAPTURE_VERSION};
/// Deterministic replay source backed by a `.rvcsi` capture file.
///
/// The header is parsed eagerly on [`FileReplayAdapter::open`]; frames are
/// parsed lazily, one line at a time, on each [`CsiSource::next_frame`] call.
/// Timestamps, ordering and per-frame [`rvcsi_core::ValidationStatus`] are
/// preserved verbatim — replay does not re-validate or re-order anything, it
/// only deserializes what was stored.
///
/// `replay_speed` is carried for the daemon/CLI to pace playback with; the
/// adapter itself never sleeps.
#[derive(Debug)]
pub struct FileReplayAdapter {
header: CaptureHeader,
profile: AdapterProfile,
source_id: SourceId,
reader: BufReader<File>,
/// 1-based line number of the line a subsequent `next_frame` will read.
next_line: usize,
frames_delivered: u64,
at_eof: bool,
replay_speed: f32,
last_status: Option<String>,
}
impl FileReplayAdapter {
/// Open `path` for replay at real-time speed (`replay_speed == 1.0`).
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with_speed(path, 1.0)
}
/// Open `path` for replay, carrying `replay_speed` for downstream pacing.
pub fn open_with_speed(path: impl AsRef<Path>, replay_speed: f32) -> Result<Self> {
let file = File::open(path.as_ref())?;
let mut reader = BufReader::new(file);
let mut first = String::new();
let n = reader.read_line(&mut first)?;
if n == 0 {
return Err(RvcsiError::parse(0, "empty capture file: missing header line"));
}
let header: CaptureHeader = serde_json::from_str(first.trim_end_matches(['\n', '\r']))
.map_err(|e| RvcsiError::parse(0, format!("invalid .rvcsi header line: {e}")))?;
if header.rvcsi_capture_version != CAPTURE_VERSION {
return Err(RvcsiError::parse(
0,
format!(
"unsupported .rvcsi capture version {} (this build supports {})",
header.rvcsi_capture_version, CAPTURE_VERSION
),
));
}
let profile = header.adapter_profile.clone();
let source_id = header.source_id.clone();
Ok(FileReplayAdapter {
header,
profile,
source_id,
reader,
next_line: 2,
frames_delivered: 0,
at_eof: false,
replay_speed,
last_status: None,
})
}
/// The capture header parsed from the file.
pub fn header(&self) -> &CaptureHeader {
&self.header
}
/// Playback speed multiplier carried for the daemon/CLI (the adapter itself
/// does not sleep).
pub fn replay_speed(&self) -> f32 {
self.replay_speed
}
/// Whether the underlying file has been fully consumed.
pub fn is_at_eof(&self) -> bool {
self.at_eof
}
}
impl CsiSource for FileReplayAdapter {
fn profile(&self) -> &AdapterProfile {
&self.profile
}
fn session_id(&self) -> SessionId {
self.header.session_id
}
fn source_id(&self) -> &SourceId {
&self.source_id
}
fn next_frame(&mut self) -> core::result::Result<Option<CsiFrame>, RvcsiError> {
if self.at_eof {
return Ok(None);
}
loop {
let mut line = String::new();
let read = self.reader.read_line(&mut line)?;
if read == 0 {
self.at_eof = true;
return Ok(None);
}
let line_no = self.next_line;
self.next_line += 1;
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
// Tolerate blank lines (e.g. a trailing newline at EOF).
continue;
}
let frame: CsiFrame = serde_json::from_str(trimmed).map_err(|e| {
self.last_status = Some(format!("parse error at line {line_no}"));
RvcsiError::parse(line_no, format!("invalid frame line {line_no}: {e}"))
})?;
self.frames_delivered += 1;
return Ok(Some(frame));
}
}
fn health(&self) -> SourceHealth {
SourceHealth {
connected: !self.at_eof,
frames_delivered: self.frames_delivered,
frames_rejected: 0,
status: self.last_status.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recorder::FileRecorder;
use rvcsi_core::{AdapterKind, FrameId, ValidationStatus};
use std::io::Write;
fn frame(id: u64, ts: u64) -> CsiFrame {
CsiFrame::from_iq(
FrameId(id),
SessionId(1),
SourceId::from("rep-test"),
AdapterKind::File,
ts,
6,
20,
vec![1.0, 2.0],
vec![0.0, 1.0],
)
}
fn write_capture(path: &Path, frames: &[CsiFrame]) -> CaptureHeader {
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rep-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
let mut rec = FileRecorder::create(path, &header).unwrap();
for f in frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
header
}
#[test]
fn open_speed_default_is_one() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), &[]);
let a = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(a.replay_speed(), 1.0);
let b = FileReplayAdapter::open_with_speed(tmp.path(), 4.0).unwrap();
assert_eq!(b.replay_speed(), 4.0);
}
#[test]
fn replays_frames_in_order() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let frames = vec![frame(0, 10), frame(1, 20), frame(2, 30)];
let header = write_capture(tmp.path(), &frames);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(a.header(), &header);
assert_eq!(a.session_id(), SessionId(1));
assert_eq!(a.source_id(), &SourceId::from("rep-test"));
let mut got = Vec::new();
while let Some(f) = a.next_frame().unwrap() {
got.push(f);
}
assert_eq!(got, frames);
assert!(a.is_at_eof());
assert!(!a.health().connected);
assert_eq!(a.health().frames_delivered, 3);
// Repeated calls after EOF stay at None.
assert!(a.next_frame().unwrap().is_none());
}
#[test]
fn header_only_file_yields_no_frames() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), &[]);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(a.next_frame().unwrap().is_none());
assert_eq!(a.health().frames_delivered, 0);
}
#[test]
fn validation_status_preserved() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut f = frame(0, 1);
f.validation = ValidationStatus::Degraded;
f.quality_score = 0.42;
f.quality_reasons = vec!["missing rssi".to_string()];
write_capture(tmp.path(), &[f.clone()]);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
let back = a.next_frame().unwrap().unwrap();
assert_eq!(back, f);
assert_eq!(back.validation, ValidationStatus::Degraded);
assert_eq!(back.quality_reasons, vec!["missing rssi".to_string()]);
}
#[test]
fn bad_header_is_parse_error_at_offset_zero() {
let tmp = tempfile::NamedTempFile::new().unwrap();
{
let mut f = File::create(tmp.path()).unwrap();
f.write_all(b"not json\n").unwrap();
}
let err = FileReplayAdapter::open(tmp.path()).unwrap_err();
match err {
RvcsiError::Parse { offset, .. } => assert_eq!(offset, 0),
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn garbage_frame_line_is_parse_error_with_line_number() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rep-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
// line 2: a good frame
serde_json::to_writer(&mut f, &frame(0, 1)).unwrap();
f.write_all(b"\n").unwrap();
// line 3: garbage
f.write_all(b"{not a frame}\n").unwrap();
}
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(a.next_frame().unwrap().is_some()); // line 2 ok
let err = a.next_frame().unwrap_err(); // line 3
match err {
RvcsiError::Parse { offset, .. } => assert_eq!(offset, 3),
other => panic!("expected Parse at line 3, got {other:?}"),
}
}
#[test]
fn nonexistent_path_is_io_error() {
let err = FileReplayAdapter::open("/no/such/rvcsi/file.rvcsi").unwrap_err();
assert!(matches!(err, RvcsiError::Io(_)), "expected Io, got {err:?}");
}
#[test]
fn wrong_version_rejected() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut header = CaptureHeader::new(
SessionId(1),
SourceId::from("x"),
AdapterProfile::offline(AdapterKind::File),
);
header.rvcsi_capture_version = 999;
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
}
let err = FileReplayAdapter::open(tmp.path()).unwrap_err();
assert!(matches!(err, RvcsiError::Parse { offset: 0, .. }));
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "rvcsi-adapter-nexmon"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI Nexmon adapter — wraps the isolated napi-c shim that parses Nexmon CSI UDP/PCAP records into normalized CsiFrames (ADR-095 D2/D15, ADR-096)"
repository.workspace = true
keywords = ["wifi", "csi", "nexmon", "rvcsi"]
categories = ["science"]
build = "build.rs"
links = "rvcsi_nexmon_shim"
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
thiserror = { workspace = true }
[build-dependencies]
cc = { workspace = true }
+18
View File
@@ -0,0 +1,18 @@
//! Compiles the isolated napi-c shim (`native/rvcsi_nexmon_shim.c`) into a
//! static library linked into `rvcsi-adapter-nexmon`. This is the only place
//! the rvCSI runtime invokes a C compiler (ADR-095 D2, ADR-096).
fn main() {
println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.c");
println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.h");
cc::Build::new()
.file("native/rvcsi_nexmon_shim.c")
.include("native")
.warnings(true)
.extra_warnings(true)
// The shim is allocation-free and freestanding-ish; keep it tight.
.flag_if_supported("-std=c11")
.flag_if_supported("-fno-strict-aliasing")
.compile("rvcsi_nexmon_shim");
}
@@ -0,0 +1,313 @@
/*
* rvCSI — Nexmon CSI compatibility shim implementation (napi-c layer).
* See rvcsi_nexmon_shim.h for the record/packet layouts and the contract.
*
* Deliberately tiny, allocation-free, and dependency-free (libc only). Every
* read is bounds-checked against the caller-supplied length; nothing here can
* scribble outside caller buffers, and nothing here panics or aborts.
*/
#include "rvcsi_nexmon_shim.h"
#include <string.h>
#define RVCSI_NX_ABI 0x00010001u /* major.minor = 1.1 (added the nexmon_csi UDP entry points) */
/* ---- little-endian load/store helpers (portable, no aliasing UB) ---- */
static uint16_t ld_u16(const uint8_t *p) {
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static uint32_t ld_u32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
((uint32_t)p[3] << 24);
}
static uint64_t ld_u64(const uint8_t *p) {
return (uint64_t)ld_u32(p) | ((uint64_t)ld_u32(p + 4) << 32);
}
static int16_t ld_i16(const uint8_t *p) { return (int16_t)ld_u16(p); }
static void st_u16(uint8_t *p, uint16_t v) {
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
}
static void st_u32(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
p[2] = (uint8_t)((v >> 16) & 0xFF);
p[3] = (uint8_t)((v >> 24) & 0xFF);
}
static void st_u64(uint8_t *p, uint64_t v) {
st_u32(p, (uint32_t)(v & 0xFFFFFFFFu));
st_u32(p + 4, (uint32_t)((v >> 32) & 0xFFFFFFFFu));
}
static void st_i16(uint8_t *p, int16_t v) { st_u16(p, (uint16_t)v); }
/* Q8.8 fixed-point <-> float, with saturation on encode (rvCSI record format). */
static float q88_to_f(int16_t v) { return (float)v / 256.0f; }
static int16_t f_to_q88(float f) {
float scaled = f * 256.0f;
if (scaled >= 32767.0f) return (int16_t)32767;
if (scaled <= -32768.0f) return (int16_t)-32768;
if (scaled >= 0.0f) return (int16_t)(scaled + 0.5f);
return (int16_t)(scaled - 0.5f);
}
/* Plain int16 <-> float for the raw nexmon_csi int16 I/Q export. */
static int16_t f_to_i16_sat(float f) {
if (f >= 32767.0f) return (int16_t)32767;
if (f <= -32768.0f) return (int16_t)-32768;
if (f >= 0.0f) return (int16_t)(f + 0.5f);
return (int16_t)(f - 0.5f);
}
uint32_t rvcsi_nx_abi_version(void) { return RVCSI_NX_ABI; }
const char *rvcsi_nx_strerror(int code) {
switch (code) {
case RVCSI_NX_OK: return "ok";
case RVCSI_NX_ERR_TOO_SHORT: return "buffer too short for header";
case RVCSI_NX_ERR_BAD_MAGIC: return "bad magic (not an rvCSI Nexmon record)";
case RVCSI_NX_ERR_BAD_VERSION: return "unsupported record version";
case RVCSI_NX_ERR_CAPACITY: return "output buffer too small for subcarrier count";
case RVCSI_NX_ERR_TRUNCATED: return "buffer shorter than the declared record";
case RVCSI_NX_ERR_ZERO_SUBCARRIERS: return "record declares zero subcarriers";
case RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS: return "record declares too many subcarriers";
case RVCSI_NX_ERR_NULL_ARG: return "null argument";
case RVCSI_NX_ERR_BAD_NEXMON_MAGIC: return "nexmon_csi UDP magic mismatch (expected 0x1111)";
case RVCSI_NX_ERR_BAD_CSI_LEN: return "nexmon_csi CSI body length is not a positive multiple of 4";
case RVCSI_NX_ERR_UNKNOWN_FORMAT: return "unknown CSI body format";
default: return "unknown error";
}
}
/* ===== rvCSI record (format 1) ======================================== */
static int validate_header(const uint8_t *buf, size_t len, uint16_t *out_n,
size_t *out_total) {
if (len < (size_t)RVCSI_NX_HEADER_BYTES) return -RVCSI_NX_ERR_TOO_SHORT;
if (ld_u32(buf) != RVCSI_NX_MAGIC) return -RVCSI_NX_ERR_BAD_MAGIC;
if (buf[4] != (uint8_t)RVCSI_NX_VERSION) return -RVCSI_NX_ERR_BAD_VERSION;
uint16_t n = ld_u16(buf + 6);
if (n == 0) return -RVCSI_NX_ERR_ZERO_SUBCARRIERS;
if (n > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS;
size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u;
if (len < total) return -RVCSI_NX_ERR_TRUNCATED;
*out_n = n;
*out_total = total;
return 0;
}
size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len) {
if (buf == NULL) return 0;
uint16_t n;
size_t total;
if (validate_header(buf, len, &n, &total) < 0) return 0;
return total;
}
int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta,
float *i_out, float *q_out, size_t cap) {
if (buf == NULL || meta == NULL || i_out == NULL || q_out == NULL)
return RVCSI_NX_ERR_NULL_ARG;
uint16_t n;
size_t total;
int rc = validate_header(buf, len, &n, &total);
if (rc < 0) return -rc;
if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY;
uint8_t flags = buf[5];
meta->subcarrier_count = n;
meta->channel = ld_u16(buf + 10);
meta->bandwidth_mhz = ld_u16(buf + 12);
meta->rssi_dbm =
(flags & RVCSI_NX_FLAG_RSSI) ? (int16_t)(int8_t)buf[8] : RVCSI_NX_ABSENT_I16;
meta->noise_floor_dbm =
(flags & RVCSI_NX_FLAG_NOISE) ? (int16_t)(int8_t)buf[9] : RVCSI_NX_ABSENT_I16;
meta->timestamp_ns = ld_u64(buf + 16);
const uint8_t *p = buf + RVCSI_NX_HEADER_BYTES;
for (uint16_t k = 0; k < n; ++k) {
i_out[k] = q88_to_f(ld_i16(p));
q_out[k] = q88_to_f(ld_i16(p + 2));
p += 4;
}
return RVCSI_NX_OK;
}
size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta,
const float *i_in, const float *q_in) {
if (buf == NULL || meta == NULL || i_in == NULL || q_in == NULL) return 0;
uint16_t n = meta->subcarrier_count;
if (n == 0 || n > RVCSI_NX_MAX_SUBCARRIERS) return 0;
size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u;
if (cap < total) return 0;
memset(buf, 0, RVCSI_NX_HEADER_BYTES);
st_u32(buf, RVCSI_NX_MAGIC);
buf[4] = (uint8_t)RVCSI_NX_VERSION;
uint8_t flags = 0;
if (meta->rssi_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_RSSI;
if (meta->noise_floor_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_NOISE;
buf[5] = flags;
st_u16(buf + 6, n);
buf[8] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_RSSI) ? meta->rssi_dbm : 0);
buf[9] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_NOISE) ? meta->noise_floor_dbm : 0);
st_u16(buf + 10, meta->channel);
st_u16(buf + 12, meta->bandwidth_mhz);
st_u16(buf + 14, 0);
st_u64(buf + 16, meta->timestamp_ns);
uint8_t *p = buf + RVCSI_NX_HEADER_BYTES;
for (uint16_t k = 0; k < n; ++k) {
st_i16(p, f_to_q88(i_in[k]));
st_i16(p + 2, f_to_q88(q_in[k]));
p += 4;
}
return total;
}
/* ===== real nexmon_csi UDP payload (format 2) ========================= */
/* Map a subcarrier (FFT) count to a bandwidth in MHz, per the standard nexmon
* exports: 64->20, 128->40, 256->80, 512->160 (and the half-bands 32->10,
* 16->5). Returns 0 if `nsub` doesn't look like one of those. */
static uint16_t bw_from_nsub(uint16_t nsub) {
switch (nsub) {
case 16: return 5;
case 32: return 10;
case 64: return 20;
case 128: return 40;
case 256: return 80;
case 512: return 160;
default: return 0;
}
}
/* Broadcom d11ac chanspec bandwidth field (bits [13:11]) -> MHz. */
static uint16_t bw_from_chanspec(uint16_t chanspec) {
switch ((chanspec >> 11) & 0x7u) {
case 2: return 20;
case 3: return 40;
case 4: return 80;
case 5: return 160;
case 6: return 80; /* 80+80: report the per-segment width */
default: return 0;
}
}
void rvcsi_nx_decode_chanspec(uint16_t chanspec, uint16_t *out_channel,
uint16_t *out_bw_mhz, uint8_t *out_is_5ghz) {
uint16_t channel = (uint16_t)(chanspec & 0x00FFu);
uint16_t bw = bw_from_chanspec(chanspec);
/* Band bits [15:14]: d11ac 5 GHz == 0b11. Cross-check with the channel number
* for robustness against older chanspec encodings. */
uint8_t band_is_5ghz = (((chanspec >> 14) & 0x3u) == 0x3u) ? 1u : 0u;
if (!band_is_5ghz && channel > 14u) band_is_5ghz = 1u;
if (band_is_5ghz && channel >= 1u && channel <= 13u && bw == 20u) {
/* almost certainly a 2.4 GHz control channel mislabeled by an old encoding */
band_is_5ghz = 0u;
}
if (out_channel) *out_channel = channel;
if (out_bw_mhz) *out_bw_mhz = bw;
if (out_is_5ghz) *out_is_5ghz = band_is_5ghz;
}
/* Validate + parse the 18-byte header; on success returns N (subcarrier count)
* and fills *out. On failure returns a negative RvcsiNxError. */
static int parse_nexmon_header(const uint8_t *payload, size_t len,
RvcsiNxUdpHeader *out, uint16_t *out_n) {
if (payload == NULL || out == NULL) return -RVCSI_NX_ERR_NULL_ARG;
if (len < (size_t)RVCSI_NX_NEXMON_HDR_BYTES) return -RVCSI_NX_ERR_TOO_SHORT;
if (ld_u16(payload) != RVCSI_NX_NEXMON_MAGIC) return -RVCSI_NX_ERR_BAD_NEXMON_MAGIC;
size_t csi_bytes = len - (size_t)RVCSI_NX_NEXMON_HDR_BYTES;
if (csi_bytes == 0u || (csi_bytes % 4u) != 0u) return -RVCSI_NX_ERR_BAD_CSI_LEN;
size_t nsub = csi_bytes / 4u;
if (nsub > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS;
uint16_t core_stream = ld_u16(payload + 12);
uint16_t chanspec = ld_u16(payload + 14);
memset(out, 0, sizeof(*out));
out->rssi_dbm = (int16_t)(int8_t)payload[2];
out->fctl = payload[3];
memcpy(out->src_mac, payload + 4, 6);
out->seq_cnt = ld_u16(payload + 10);
out->core = (uint16_t)(core_stream & 0x7u);
out->spatial_stream = (uint16_t)((core_stream >> 3) & 0x7u);
out->chanspec = chanspec;
out->chip_ver = ld_u16(payload + 16);
rvcsi_nx_decode_chanspec(chanspec, &out->channel, &out->bandwidth_mhz, &out->is_5ghz);
out->subcarrier_count = (uint16_t)nsub;
/* Prefer the FFT-derived bandwidth when the chanspec bits are missing/odd. */
{
uint16_t bw_n = bw_from_nsub((uint16_t)nsub);
if (bw_n != 0u) out->bandwidth_mhz = bw_n;
}
*out_n = (uint16_t)nsub;
return 0;
}
int rvcsi_nx_csi_udp_header(const uint8_t *payload, size_t len,
RvcsiNxUdpHeader *out) {
uint16_t n;
int rc = parse_nexmon_header(payload, len, out, &n);
return (rc < 0) ? -rc : RVCSI_NX_OK;
}
int rvcsi_nx_csi_udp_decode(const uint8_t *payload, size_t len, int csi_format,
RvcsiNxUdpHeader *hdr_out, RvcsiNxMeta *meta,
float *i_out, float *q_out, size_t cap) {
if (meta == NULL || i_out == NULL || q_out == NULL) return RVCSI_NX_ERR_NULL_ARG;
if (csi_format != RVCSI_NX_CSI_FMT_INT16_IQ) return RVCSI_NX_ERR_UNKNOWN_FORMAT;
RvcsiNxUdpHeader hdr;
uint16_t n;
int rc = parse_nexmon_header(payload, len, &hdr, &n);
if (rc < 0) return -rc;
if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY;
meta->subcarrier_count = n;
meta->channel = hdr.channel;
meta->bandwidth_mhz = hdr.bandwidth_mhz;
meta->rssi_dbm = hdr.rssi_dbm; /* always present in the nexmon header */
meta->noise_floor_dbm = RVCSI_NX_ABSENT_I16; /* not carried by nexmon_csi */
meta->timestamp_ns = 0u; /* the caller stamps this from the pcap packet time */
const uint8_t *p = payload + RVCSI_NX_NEXMON_HDR_BYTES;
for (uint16_t k = 0; k < n; ++k) {
i_out[k] = (float)ld_i16(p); /* real, raw int16 count */
q_out[k] = (float)ld_i16(p + 2); /* imag, raw int16 count */
p += 4;
}
if (hdr_out) *hdr_out = hdr;
return RVCSI_NX_OK;
}
size_t rvcsi_nx_csi_udp_write(uint8_t *buf, size_t cap, const RvcsiNxUdpHeader *hdr,
uint16_t subcarrier_count, const float *i_in,
const float *q_in) {
if (buf == NULL || hdr == NULL || i_in == NULL || q_in == NULL) return 0;
if (subcarrier_count == 0u || subcarrier_count > RVCSI_NX_MAX_SUBCARRIERS) return 0;
size_t total = (size_t)RVCSI_NX_NEXMON_HDR_BYTES + (size_t)subcarrier_count * 4u;
if (cap < total) return 0;
memset(buf, 0, RVCSI_NX_NEXMON_HDR_BYTES);
st_u16(buf, RVCSI_NX_NEXMON_MAGIC);
buf[2] = (uint8_t)(int8_t)hdr->rssi_dbm;
buf[3] = hdr->fctl;
memcpy(buf + 4, hdr->src_mac, 6);
st_u16(buf + 10, hdr->seq_cnt);
st_u16(buf + 12, (uint16_t)((hdr->core & 0x7u) | ((hdr->spatial_stream & 0x7u) << 3)));
st_u16(buf + 14, hdr->chanspec);
st_u16(buf + 16, hdr->chip_ver);
uint8_t *p = buf + RVCSI_NX_NEXMON_HDR_BYTES;
for (uint16_t k = 0; k < subcarrier_count; ++k) {
st_i16(p, f_to_i16_sat(i_in[k]));
st_i16(p + 2, f_to_i16_sat(q_in[k]));
p += 4;
}
return total;
}
@@ -0,0 +1,186 @@
/*
* rvCSI — Nexmon CSI compatibility shim (napi-c layer, ADR-095 D2, ADR-096).
*
* This is the ONLY C in the rvCSI runtime. It is the seam against fragile
* vendor/firmware byte formats; everything above this file is safe Rust.
*
* It exposes two record formats:
*
* (1) the "rvCSI Nexmon record" — a compact, byte-defined, self-describing
* record (magic 'RVNX', RSSI, channel, timestamp, then interleaved int16
* I/Q in Q8.8 fixed point). Used by the recorder, replay, and tests.
*
* (2) the *real* nexmon_csi UDP payload — what the patched Broadcom firmware
* (BCM43455c0 / 4358 / 4366c0, …) actually sends: an 18-byte header
* (magic 0x1111, RSSI, frame-control, source MAC, sequence, core/spatial
* stream, Broadcom chanspec, chip version) followed by `nsub` complex CSI
* samples. We implement the modern format (int16 LE I/Q interleaved — what
* CSIKit / csireader.py read for the 43455c0 et al.); the legacy packed-
* float export used by some 4339/4358 firmwares is a documented follow-up.
*
* Record (1) layout (all integers little-endian):
* off size field
* 0 4 magic = 0x52564E58 ('R','V','N','X')
* 4 1 version = RVCSI_NX_VERSION (1)
* 5 1 flags bit0: rssi present, bit1: noise floor present
* 6 2 subcarrier_count N (1 .. RVCSI_NX_MAX_SUBCARRIERS)
* 8 1 rssi_dbm int8 (valid iff flags bit0)
* 9 1 noise_dbm int8 (valid iff flags bit1)
* 10 2 channel uint16
* 12 2 bandwidth_mhz uint16
* 14 2 reserved (0)
* 16 8 timestamp_ns uint64
* 24 4*N N pairs of int16 (i, q), interleaved, fixed-point Q8.8
* total = 24 + 4*N bytes; stored int16 v maps to float v / 256.0
*
* Format (2) — nexmon_csi UDP payload header (all little-endian):
* off size field
* 0 2 magic = 0x1111
* 2 1 rssi int8 (dBm)
* 3 1 fctl uint8 (802.11 frame-control byte)
* 4 6 src_mac uint8[6]
* 10 2 seq_cnt uint16 (802.11 sequence-control)
* 12 2 core_stream uint16 (bits[2:0]=rx core, bits[5:3]=spatial stream)
* 14 2 chanspec uint16 (Broadcom d11ac chanspec)
* 16 2 chip_ver uint16 (e.g. 0x4345 = BCM43455c0)
* 18 ... CSI: nsub complex samples; for RVCSI_NX_CSI_FMT_INT16_IQ that is
* 4*nsub bytes = nsub pairs of int16 LE (real, imag), raw counts.
* nsub is derived from the payload length: nsub = (len - 18) / 4.
*/
#ifndef RVCSI_NEXMON_SHIM_H
#define RVCSI_NEXMON_SHIM_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define RVCSI_NX_MAGIC 0x52564E58u /* 'R','V','N','X' little-endian */
#define RVCSI_NX_VERSION 1
#define RVCSI_NX_HEADER_BYTES 24
#define RVCSI_NX_MAX_SUBCARRIERS 2048
#define RVCSI_NX_FLAG_RSSI 0x01u
#define RVCSI_NX_FLAG_NOISE 0x02u
/* nexmon_csi UDP payload constants. */
#define RVCSI_NX_NEXMON_MAGIC 0x1111u
#define RVCSI_NX_NEXMON_HDR_BYTES 18
/* CSI body formats for rvcsi_nx_csi_udp_decode. */
#define RVCSI_NX_CSI_FMT_INT16_IQ 0 /* nsub pairs of int16 LE (real, imag) — the modern 43455c0/4358/4366c0 export */
/* (1 = legacy nexmon packed-float — not yet implemented; see header comment) */
/* Sentinel for "metadata field absent". */
#define RVCSI_NX_ABSENT_I16 ((int16_t)0x7FFF)
/* Error codes returned (positive; the negated value is used internally). */
typedef enum {
RVCSI_NX_OK = 0,
RVCSI_NX_ERR_TOO_SHORT = 1, /* buffer shorter than the header */
RVCSI_NX_ERR_BAD_MAGIC = 2, /* rvCSI-record magic mismatch */
RVCSI_NX_ERR_BAD_VERSION = 3, /* unsupported rvCSI-record version */
RVCSI_NX_ERR_CAPACITY = 4, /* caller i/q buffer too small for N */
RVCSI_NX_ERR_TRUNCATED = 5, /* buffer shorter than the declared record */
RVCSI_NX_ERR_ZERO_SUBCARRIERS = 6,
RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS = 7,
RVCSI_NX_ERR_NULL_ARG = 8,
RVCSI_NX_ERR_BAD_NEXMON_MAGIC = 9, /* nexmon_csi UDP magic != 0x1111 */
RVCSI_NX_ERR_BAD_CSI_LEN = 10, /* (len - 18) not a positive multiple of 4 */
RVCSI_NX_ERR_UNKNOWN_FORMAT = 11 /* csi_format not recognised */
} RvcsiNxError;
/* Decoded per-record metadata (the I/Q samples are written separately into
* caller-provided float arrays). */
typedef struct RvcsiNxMeta {
uint16_t subcarrier_count;
uint16_t channel;
uint16_t bandwidth_mhz;
int16_t rssi_dbm; /* RVCSI_NX_ABSENT_I16 if not present */
int16_t noise_floor_dbm; /* RVCSI_NX_ABSENT_I16 if not present */
uint64_t timestamp_ns;
} RvcsiNxMeta;
/* The parsed 18-byte nexmon_csi UDP header (raw vendor fields preserved). */
typedef struct RvcsiNxUdpHeader {
int16_t rssi_dbm; /* sign-extended from the int8 in the packet */
uint8_t fctl;
uint8_t src_mac[6];
uint16_t seq_cnt;
uint16_t core; /* rx core index, core_stream bits [2:0] */
uint16_t spatial_stream;/* spatial stream index, core_stream bits [5:3] */
uint16_t chanspec; /* raw Broadcom chanspec word */
uint16_t chip_ver;
uint16_t channel; /* decoded from chanspec */
uint16_t bandwidth_mhz; /* decoded from chanspec (0 = unknown) */
uint8_t is_5ghz; /* 1 if the chanspec band bits say 5 GHz, else 0 */
uint16_t subcarrier_count; /* derived from the payload length: (len-18)/4 */
} RvcsiNxUdpHeader;
/* ----- rvCSI record (format 1) ---------------------------------------- */
/* Length, in bytes, of the rvCSI record at `buf` given `len` available, or 0 on
* any problem (too short / bad magic / bad version / N out of range / truncated). */
size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len);
/* Parse one rvCSI record at `buf`; fills `*meta` and writes `subcarrier_count`
* floats into each of `i_out`/`q_out` (capacity `cap` each). Returns RVCSI_NX_OK
* or a positive RvcsiNxError. No allocation, no globals. */
int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta,
float *i_out, float *q_out, size_t cap);
/* Serialize one rvCSI record into `buf` (capacity `cap`). Returns the byte count
* (24 + 4*N) or 0 on error. */
size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta,
const float *i_in, const float *q_in);
/* ----- real nexmon_csi UDP payload (format 2) ------------------------- */
/* Decode a Broadcom d11ac chanspec word into channel / bandwidth (MHz) / band.
* `out_channel` gets `chanspec & 0xff`; `out_bw_mhz` gets 20/40/80/160 (or 0 if
* the bandwidth bits are unrecognised); `out_is_5ghz` gets 1 for the 5 GHz band
* bits, 0 otherwise. Any out pointer may be NULL. Always succeeds. */
void rvcsi_nx_decode_chanspec(uint16_t chanspec, uint16_t *out_channel,
uint16_t *out_bw_mhz, uint8_t *out_is_5ghz);
/* Parse just the 18-byte nexmon_csi UDP header at `payload` (length `len`),
* filling `*out` (including the chanspec-decoded channel/bandwidth and the
* length-derived subcarrier count). Returns RVCSI_NX_OK or a positive error
* (TOO_SHORT, BAD_NEXMON_MAGIC, BAD_CSI_LEN, NULL_ARG). */
int rvcsi_nx_csi_udp_header(const uint8_t *payload, size_t len,
RvcsiNxUdpHeader *out);
/* Full decode of a nexmon_csi UDP payload: parses the 18-byte header, then the
* CSI body according to `csi_format` (currently only RVCSI_NX_CSI_FMT_INT16_IQ).
* Fills `*meta` (channel/bandwidth from the chanspec, rssi from the header,
* subcarrier_count from the length; `timestamp_ns` is left 0 — the caller stamps
* it from the pcap packet time). Writes `subcarrier_count` floats into each of
* `i_out`/`q_out` (capacity `cap`). If `hdr_out` is non-NULL it also receives the
* full parsed header. Returns RVCSI_NX_OK or a positive RvcsiNxError. */
int rvcsi_nx_csi_udp_decode(const uint8_t *payload, size_t len, int csi_format,
RvcsiNxUdpHeader *hdr_out, RvcsiNxMeta *meta,
float *i_out, float *q_out, size_t cap);
/* Write a synthetic nexmon_csi UDP payload (the 18-byte header + int16 I/Q body)
* into `buf` (capacity `cap`). Used by tests and the `nexmon` synthetic-source.
* `i_in`/`q_in` hold `subcarrier_count` raw int16-valued samples each (clamped to
* the int16 range on write). Returns the byte count (18 + 4*N) or 0 on error. */
size_t rvcsi_nx_csi_udp_write(uint8_t *buf, size_t cap, const RvcsiNxUdpHeader *hdr,
uint16_t subcarrier_count, const float *i_in,
const float *q_in);
/* ----- misc ----------------------------------------------------------- */
/* Static, human-readable string for an RvcsiNxError code. Never NULL. */
const char *rvcsi_nx_strerror(int code);
/* ABI version of this shim (`major << 16 | minor`); the Rust side asserts the
* major matches. Bumped to 1.1 when the nexmon_csi UDP entry points were added. */
uint32_t rvcsi_nx_abi_version(void);
#ifdef __cplusplus
}
#endif
#endif /* RVCSI_NEXMON_SHIM_H */
+340
View File
@@ -0,0 +1,340 @@
//! The Nexmon-supported Broadcom chip registry and Raspberry Pi model map
//! (ADR-095 D15, ADR-096) — including the **Raspberry Pi 5**.
//!
//! nexmon_csi runs on a handful of patched Broadcom/Cypress chips. This module
//! names them ([`NexmonChip`]), maps Raspberry Pi models to their chip
//! ([`RaspberryPiModel`]), resolves the on-the-wire `chip_ver` word back to a
//! chip (best-effort — the raw value is always preserved), and builds a
//! [`rvcsi_core::AdapterProfile`] (supported channels / bandwidths / expected
//! subcarrier counts) for each — so `validate_frame` can bound CSI frames
//! against the device that produced them.
//!
//! The Raspberry Pi 5 carries the same **CYW43455 (BCM43455c0)** 802.11ac
//! wireless as the Pi 3B+ / Pi 4 / Pi 400 — the chip with the most mature
//! nexmon_csi support — so Pi 5 CSI captures use the [`NexmonChip::Bcm43455c0`]
//! profile (20/40/80 MHz, 64/128/256 subcarriers, 2.4 + 5 GHz). The chip is also
//! auto-detected at runtime from each frame's `chip_ver` (see
//! [`crate::NexmonPcapAdapter`]).
use rvcsi_core::{AdapterKind, AdapterProfile};
/// A Broadcom/Cypress WiFi chip nexmon_csi is known to run on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum NexmonChip {
/// BCM43455c0 / CYW43455 — 802.11ac, 2.4 + 5 GHz, 20/40/80 MHz. The
/// flagship nexmon_csi target: **Raspberry Pi 3B+, Pi 4, Pi 400 and Pi 5**,
/// plus the Pi Zero W. Modern int16 I/Q CSI export.
Bcm43455c0,
/// BCM43436b0 — 802.11n, 2.4 GHz only, 20/40 MHz. Raspberry Pi Zero 2 W.
Bcm43436b0,
/// BCM4366c0 — 802.11ac, 2.4 + 5 GHz, up to 80 MHz. ASUS RT-AC86U. Modern int16 export.
Bcm4366c0,
/// BCM4375b1 — 802.11ax-class, 2.4 + 5 GHz. Some Samsung Galaxy S10/S20.
Bcm4375b1,
/// BCM4358 — 802.11ac. Nexus 6P (and similar). Some firmwares use the legacy
/// packed-float CSI export (see [`NexmonChip::uses_int16_iq`]).
Bcm4358,
/// BCM4339 — 802.11ac. Nexus 5. Legacy packed-float CSI export.
Bcm4339,
/// A chip we don't recognise — the raw `chip_ver` word from the packet.
Unknown {
/// The `chip_ver` word as it appeared on the wire.
chip_ver: u16,
},
}
impl NexmonChip {
/// Stable lower-case slug (`"bcm43455c0"`, `"bcm4366c0"`, ...; `"unknown:0xNNNN"` for [`NexmonChip::Unknown`]).
pub fn slug(self) -> String {
match self {
NexmonChip::Bcm43455c0 => "bcm43455c0".to_string(),
NexmonChip::Bcm43436b0 => "bcm43436b0".to_string(),
NexmonChip::Bcm4366c0 => "bcm4366c0".to_string(),
NexmonChip::Bcm4375b1 => "bcm4375b1".to_string(),
NexmonChip::Bcm4358 => "bcm4358".to_string(),
NexmonChip::Bcm4339 => "bcm4339".to_string(),
NexmonChip::Unknown { chip_ver } => format!("unknown:0x{chip_ver:04x}"),
}
}
/// A friendlier display name including a typical host device.
pub fn description(self) -> &'static str {
match self {
NexmonChip::Bcm43455c0 => "BCM43455c0 / CYW43455 (Raspberry Pi 3B+/4/400/5, Pi Zero W) — 802.11ac, 2.4+5 GHz",
NexmonChip::Bcm43436b0 => "BCM43436b0 (Raspberry Pi Zero 2 W) — 802.11n, 2.4 GHz",
NexmonChip::Bcm4366c0 => "BCM4366c0 (ASUS RT-AC86U) — 802.11ac, 2.4+5 GHz",
NexmonChip::Bcm4375b1 => "BCM4375b1 (Samsung Galaxy S10/S20) — 802.11ax-class, 2.4+5 GHz",
NexmonChip::Bcm4358 => "BCM4358 (Nexus 6P) — 802.11ac",
NexmonChip::Bcm4339 => "BCM4339 (Nexus 5) — 802.11ac",
NexmonChip::Unknown { .. } => "unknown Broadcom/Cypress chip",
}
}
/// Whether this chip's nexmon_csi firmware exports CSI in the modern int16
/// LE I/Q format ([`crate::NEXMON_CSI_FMT_INT16_IQ`]). The BCM4339 and some
/// BCM4358 firmwares use the legacy *packed-float* export instead (not yet
/// implemented by the shim — see `ffi::NEXMON_CSI_FMT_INT16_IQ`).
pub fn uses_int16_iq(self) -> bool {
!matches!(self, NexmonChip::Bcm4339 | NexmonChip::Bcm4358)
}
/// Whether the chip supports the 5 GHz band (and therefore 802.11ac wide channels).
pub fn dual_band(self) -> bool {
matches!(
self,
NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4375b1 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339
)
}
/// Resolve a `chip_ver` word from a nexmon_csi UDP header to a chip
/// (best-effort — matches the Broadcom chip-ID convention `0x4345` = BCM4345
/// family, `0x4339`, `0x4358`, `0x4366`, `0x4375`; anything else is
/// [`NexmonChip::Unknown`]). The c0/b0 revision suffix isn't carried by this
/// word; the int16-vs-packed-float export distinction is handled separately.
pub fn from_chip_ver(chip_ver: u16) -> NexmonChip {
match chip_ver {
0x4345 => NexmonChip::Bcm43455c0,
0x4339 => NexmonChip::Bcm4339,
0x4358 => NexmonChip::Bcm4358,
0x4366 => NexmonChip::Bcm4366c0,
0x4375 => NexmonChip::Bcm4375b1,
// 43436's chip id varies by source; treat it as unknown unless we see it.
other => NexmonChip::Unknown { chip_ver: other },
}
}
/// Parse a chip name/slug (`"bcm43455c0"`, `"43455c0"`, `"cyw43455"`, ...).
pub fn from_slug(s: &str) -> Option<NexmonChip> {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"bcm43455c0" | "43455c0" | "43455" | "bcm43455" | "cyw43455" => Some(NexmonChip::Bcm43455c0),
"bcm43436b0" | "43436b0" | "43436" | "bcm43436" => Some(NexmonChip::Bcm43436b0),
"bcm4366c0" | "4366c0" | "4366" | "bcm4366" => Some(NexmonChip::Bcm4366c0),
"bcm4375b1" | "4375b1" | "4375" | "bcm4375" => Some(NexmonChip::Bcm4375b1),
"bcm4358" | "4358" => Some(NexmonChip::Bcm4358),
"bcm4339" | "4339" => Some(NexmonChip::Bcm4339),
_ => None,
}
}
}
/// 5 GHz UNII channels (a representative set; nexmon picks a control channel via `makecsiparams`).
const FIVE_GHZ_CHANNELS: &[u16] = &[
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149,
153, 157, 161, 165,
];
fn channels_for(chip: NexmonChip) -> Vec<u16> {
let mut v: Vec<u16> = (1..=13).collect();
if chip.dual_band() {
v.extend_from_slice(FIVE_GHZ_CHANNELS);
}
v
}
fn bandwidths_for(chip: NexmonChip) -> Vec<u16> {
match chip {
NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 => vec![20, 40, 80],
NexmonChip::Bcm4375b1 => vec![20, 40, 80, 160],
NexmonChip::Bcm43436b0 => vec![20, 40],
NexmonChip::Unknown { .. } => vec![20, 40, 80],
}
}
/// Subcarrier (FFT) count per supported bandwidth: 20→64, 40→128, 80→256, 160→512.
fn subcarrier_counts_for(chip: NexmonChip) -> Vec<u16> {
bandwidths_for(chip)
.iter()
.map(|bw| (bw / 20) * 64)
.collect()
}
/// Build the [`rvcsi_core::AdapterProfile`] for a Nexmon chip — the channels /
/// bandwidths / expected subcarrier counts `validate_frame` will bound CSI
/// frames against, plus the live-capability flags (Nexmon supports monitor mode
/// and injection on these chips).
pub fn nexmon_adapter_profile(chip: NexmonChip) -> AdapterProfile {
AdapterProfile {
adapter_kind: AdapterKind::Nexmon,
chip: Some(chip.slug()),
firmware_version: None,
driver_version: None,
supported_channels: channels_for(chip),
supported_bandwidths_mhz: bandwidths_for(chip),
expected_subcarrier_counts: subcarrier_counts_for(chip),
supports_live_capture: true,
supports_injection: true,
supports_monitor_mode: true,
}
}
/// Raspberry Pi models with on-board WiFi that nexmon_csi can extract CSI from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RaspberryPiModel {
/// Raspberry Pi 3 Model B+ — CYW43455 / BCM43455c0.
Pi3BPlus,
/// Raspberry Pi 4 Model B — CYW43455 / BCM43455c0.
Pi4,
/// Raspberry Pi 400 — CYW43455 / BCM43455c0.
Pi400,
/// **Raspberry Pi 5** — CYW43455 / BCM43455c0 (same wireless as the Pi 4).
Pi5,
/// Raspberry Pi Zero W — CYW43438? No — the Zero W uses the BCM43438 (2.4 GHz
/// only), which nexmon_csi does **not** support; included here only so callers
/// can detect and reject it. Use a Zero 2 W instead.
PiZeroW,
/// Raspberry Pi Zero 2 W — BCM43436b0 (2.4 GHz only).
PiZero2W,
}
impl RaspberryPiModel {
/// The Broadcom/Cypress WiFi chip on this board.
pub fn nexmon_chip(self) -> NexmonChip {
match self {
RaspberryPiModel::Pi3BPlus
| RaspberryPiModel::Pi4
| RaspberryPiModel::Pi400
| RaspberryPiModel::Pi5 => NexmonChip::Bcm43455c0,
RaspberryPiModel::PiZero2W => NexmonChip::Bcm43436b0,
RaspberryPiModel::PiZeroW => NexmonChip::Unknown { chip_ver: 0x4343 }, // BCM43438 — not CSI-capable
}
}
/// Whether nexmon_csi can extract CSI from this board's WiFi.
pub fn csi_supported(self) -> bool {
!matches!(self, RaspberryPiModel::PiZeroW)
}
/// Stable slug (`"pi5"`, `"pi4"`, `"pi3b+"`, `"pi400"`, `"pizero2w"`, `"pizerow"`).
pub fn slug(self) -> &'static str {
match self {
RaspberryPiModel::Pi3BPlus => "pi3b+",
RaspberryPiModel::Pi4 => "pi4",
RaspberryPiModel::Pi400 => "pi400",
RaspberryPiModel::Pi5 => "pi5",
RaspberryPiModel::PiZeroW => "pizerow",
RaspberryPiModel::PiZero2W => "pizero2w",
}
}
/// Parse a model slug (accepts `pi5`, `pi 5`, `rpi5`, `raspberrypi5`, `pi3b+`/`pi3bplus`, ...).
pub fn from_slug(s: &str) -> Option<RaspberryPiModel> {
let s: String = s.trim().to_ascii_lowercase().chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect();
let s = s.strip_prefix("raspberrypi").or_else(|| s.strip_prefix("rpi")).unwrap_or(&s);
match s {
"pi5" | "5" => Some(RaspberryPiModel::Pi5),
"pi4" | "4" | "pi4b" => Some(RaspberryPiModel::Pi4),
"pi400" | "400" => Some(RaspberryPiModel::Pi400),
"pi3b+" | "pi3bplus" | "3b+" | "3bplus" => Some(RaspberryPiModel::Pi3BPlus),
"pizero2w" | "zero2w" | "pizero2" => Some(RaspberryPiModel::PiZero2W),
"pizerow" | "zerow" => Some(RaspberryPiModel::PiZeroW),
_ => None,
}
}
}
/// Build the [`rvcsi_core::AdapterProfile`] for a Raspberry Pi model (its
/// [`RaspberryPiModel::nexmon_chip`]'s profile, with the `chip` string tagged
/// with the model for legibility).
pub fn raspberry_pi_profile(model: RaspberryPiModel) -> AdapterProfile {
let mut p = nexmon_adapter_profile(model.nexmon_chip());
p.chip = Some(format!("{} ({})", model.nexmon_chip().slug(), model.slug()));
p
}
/// The full registry of Nexmon-supported chips, for `rvcsi nexmon-chips` and SDK callers.
pub fn known_chips() -> &'static [NexmonChip] {
&[
NexmonChip::Bcm43455c0,
NexmonChip::Bcm43436b0,
NexmonChip::Bcm4366c0,
NexmonChip::Bcm4375b1,
NexmonChip::Bcm4358,
NexmonChip::Bcm4339,
]
}
/// The full registry of Raspberry Pi models this crate knows about.
pub fn known_pi_models() -> &'static [RaspberryPiModel] {
&[
RaspberryPiModel::Pi5,
RaspberryPiModel::Pi4,
RaspberryPiModel::Pi400,
RaspberryPiModel::Pi3BPlus,
RaspberryPiModel::PiZero2W,
RaspberryPiModel::PiZeroW,
]
}
impl crate::ffi::NexmonCsiHeader {
/// Resolve this packet's chip from its `chip_ver` word (best-effort; the raw
/// `chip_ver` field is always preserved). For a Raspberry Pi 5 (or 4/400/3B+)
/// capture this returns [`NexmonChip::Bcm43455c0`].
pub fn chip(&self) -> NexmonChip {
NexmonChip::from_chip_ver(self.chip_ver)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pi5_uses_the_same_chip_as_pi4() {
assert_eq!(RaspberryPiModel::Pi5.nexmon_chip(), NexmonChip::Bcm43455c0);
assert_eq!(RaspberryPiModel::Pi4.nexmon_chip(), NexmonChip::Bcm43455c0);
assert!(RaspberryPiModel::Pi5.csi_supported());
let p = raspberry_pi_profile(RaspberryPiModel::Pi5);
assert_eq!(p.adapter_kind, AdapterKind::Nexmon);
assert!(p.chip.as_deref().unwrap().contains("pi5"));
assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]);
assert_eq!(p.expected_subcarrier_counts, vec![64, 128, 256]);
assert!(p.accepts_channel(36)); // 5 GHz
assert!(p.accepts_channel(6)); // 2.4 GHz
assert!(p.accepts_subcarrier_count(256)); // VHT80
assert!(!p.accepts_subcarrier_count(57));
assert!(p.supports_monitor_mode && p.supports_injection);
}
#[test]
fn chip_ver_resolution_best_effort() {
assert_eq!(NexmonChip::from_chip_ver(0x4345), NexmonChip::Bcm43455c0);
assert_eq!(NexmonChip::from_chip_ver(0x4339), NexmonChip::Bcm4339);
assert_eq!(NexmonChip::from_chip_ver(0x4366), NexmonChip::Bcm4366c0);
assert!(matches!(NexmonChip::from_chip_ver(0xABCD), NexmonChip::Unknown { chip_ver: 0xABCD }));
}
#[test]
fn chip_traits() {
assert!(NexmonChip::Bcm43455c0.uses_int16_iq());
assert!(!NexmonChip::Bcm4339.uses_int16_iq());
assert!(NexmonChip::Bcm43455c0.dual_band());
assert!(!NexmonChip::Bcm43436b0.dual_band());
assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).supported_bandwidths_mhz, vec![20, 40]);
assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).expected_subcarrier_counts, vec![64, 128]);
// unknown chip -> a permissive-ish 802.11ac default
let u = nexmon_adapter_profile(NexmonChip::Unknown { chip_ver: 0 });
assert_eq!(u.supported_bandwidths_mhz, vec![20, 40, 80]);
}
#[test]
fn slug_parsing() {
assert_eq!(NexmonChip::from_slug("CYW43455"), Some(NexmonChip::Bcm43455c0));
assert_eq!(NexmonChip::from_slug("bcm4366c0"), Some(NexmonChip::Bcm4366c0));
assert_eq!(NexmonChip::from_slug("nope"), None);
assert_eq!(RaspberryPiModel::from_slug("Pi 5"), Some(RaspberryPiModel::Pi5));
assert_eq!(RaspberryPiModel::from_slug("raspberry-pi-5"), Some(RaspberryPiModel::Pi5));
assert_eq!(RaspberryPiModel::from_slug("pi3bplus"), Some(RaspberryPiModel::Pi3BPlus));
assert_eq!(RaspberryPiModel::from_slug("pi42"), None);
assert_eq!(NexmonChip::Bcm43455c0.slug(), "bcm43455c0");
assert_eq!(RaspberryPiModel::Pi5.slug(), "pi5");
}
#[test]
fn registries_nonempty_and_pi5_present() {
assert!(known_chips().contains(&NexmonChip::Bcm43455c0));
assert!(known_pi_models().contains(&RaspberryPiModel::Pi5));
}
}
+644
View File
@@ -0,0 +1,644 @@
//! Raw FFI to the napi-c shim plus safe wrappers (ADR-096).
//!
//! The C side (`native/rvcsi_nexmon_shim.c`) is allocation-free and bounds-checks
//! every read against the caller-supplied lengths. The `unsafe` here is limited
//! to: calling those C functions with correct pointers/lengths, and reading back
//! the metadata struct the C side fully initialized on `RVCSI_NX_OK`.
use std::os::raw::c_char;
/// Bytes in a record header (the fixed prefix before the I/Q samples).
pub const RECORD_HEADER_BYTES: usize = 24;
/// Largest subcarrier count the shim will parse (mirrors `RVCSI_NX_MAX_SUBCARRIERS`).
pub const MAX_SUBCARRIERS: usize = 2048;
/// Sentinel the C side uses for "metadata field absent".
const ABSENT_I16: i16 = 0x7FFF;
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct RvcsiNxMeta {
subcarrier_count: u16,
channel: u16,
bandwidth_mhz: u16,
rssi_dbm: i16,
noise_floor_dbm: i16,
timestamp_ns: u64,
}
extern "C" {
fn rvcsi_nx_record_len(buf: *const u8, len: usize) -> usize;
fn rvcsi_nx_parse_record(
buf: *const u8,
len: usize,
meta: *mut RvcsiNxMeta,
i_out: *mut f32,
q_out: *mut f32,
cap: usize,
) -> i32;
fn rvcsi_nx_write_record(
buf: *mut u8,
cap: usize,
meta: *const RvcsiNxMeta,
i_in: *const f32,
q_in: *const f32,
) -> usize;
fn rvcsi_nx_decode_chanspec(
chanspec: u16,
out_channel: *mut u16,
out_bw_mhz: *mut u16,
out_is_5ghz: *mut u8,
);
fn rvcsi_nx_csi_udp_header(payload: *const u8, len: usize, out: *mut RvcsiNxUdpHeader) -> i32;
fn rvcsi_nx_csi_udp_decode(
payload: *const u8,
len: usize,
csi_format: i32,
hdr_out: *mut RvcsiNxUdpHeader,
meta: *mut RvcsiNxMeta,
i_out: *mut f32,
q_out: *mut f32,
cap: usize,
) -> i32;
fn rvcsi_nx_csi_udp_write(
buf: *mut u8,
cap: usize,
hdr: *const RvcsiNxUdpHeader,
subcarrier_count: u16,
i_in: *const f32,
q_in: *const f32,
) -> usize;
fn rvcsi_nx_strerror(code: i32) -> *const c_char;
fn rvcsi_nx_abi_version() -> u32;
}
/// Mirrors the C `RvcsiNxUdpHeader` (the parsed 18-byte nexmon_csi UDP header).
#[repr(C)]
#[derive(Debug, Clone, Copy, Default)]
struct RvcsiNxUdpHeader {
rssi_dbm: i16,
fctl: u8,
src_mac: [u8; 6],
seq_cnt: u16,
core: u16,
spatial_stream: u16,
chanspec: u16,
chip_ver: u16,
channel: u16,
bandwidth_mhz: u16,
is_5ghz: u8,
subcarrier_count: u16,
}
/// `csi_format` selector for [`decode_nexmon_udp`]: `nsub` pairs of int16 LE
/// `(real, imag)` — the modern BCM43455c0 chip ID / 4358 / 4366c0 export (mirrors
/// `RVCSI_NX_CSI_FMT_INT16_IQ`). The legacy packed-float export is not yet wired.
pub const NEXMON_CSI_FMT_INT16_IQ: i32 = 0;
/// ABI version of the linked C shim (`major << 16 | minor`).
pub fn shim_abi_version() -> u32 {
// SAFETY: no arguments, returns a plain u32 by value.
unsafe { rvcsi_nx_abi_version() }
}
/// Errors decoding a record (a structured view of the C error codes).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NexmonFfiError {
/// The C shim returned a non-zero error code.
#[error("nexmon shim error {code}: {message}")]
Shim {
/// Numeric `RvcsiNxError` code.
code: i32,
/// Static description from `rvcsi_nx_strerror`.
message: String,
},
/// The buffer didn't even contain a parseable header / record length.
#[error("not a record (bad magic, unsupported version, or too short)")]
NotARecord,
}
fn strerror(code: i32) -> String {
// SAFETY: rvcsi_nx_strerror always returns a non-NULL pointer to a static,
// NUL-terminated C string (see the C source); we only borrow it here.
unsafe {
let p = rvcsi_nx_strerror(code);
if p.is_null() {
return format!("error {code}");
}
std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
}
}
/// A record decoded from the wire: fixed metadata + the I/Q sample vectors.
#[derive(Debug, Clone, PartialEq)]
pub struct NexmonRecord {
/// Number of subcarriers (== length of `i_values`/`q_values`).
pub subcarrier_count: u16,
/// WiFi channel number.
pub channel: u16,
/// Bandwidth in MHz.
pub bandwidth_mhz: u16,
/// RSSI in dBm, if present in the record.
pub rssi_dbm: Option<i16>,
/// Noise floor in dBm, if present.
pub noise_floor_dbm: Option<i16>,
/// Source timestamp, ns.
pub timestamp_ns: u64,
/// In-phase samples.
pub i_values: Vec<f32>,
/// Quadrature samples.
pub q_values: Vec<f32>,
}
/// Length, in bytes, of the record starting at `buf[0]`, or `None` if `buf`
/// doesn't begin with a complete, valid record.
pub fn record_len(buf: &[u8]) -> Option<usize> {
// SAFETY: passing a valid pointer + the slice's true length; the C side
// reads at most `len` bytes and returns 0 on any problem.
let n = unsafe { rvcsi_nx_record_len(buf.as_ptr(), buf.len()) };
if n == 0 {
None
} else {
Some(n)
}
}
/// Decode the first record in `buf`. Returns the record and the number of bytes
/// it consumed (so callers can advance a cursor over a concatenated stream).
pub fn decode_record(buf: &[u8]) -> Result<(NexmonRecord, usize), NexmonFfiError> {
let total = record_len(buf).ok_or(NexmonFfiError::NotARecord)?;
debug_assert!(total >= RECORD_HEADER_BYTES && total <= buf.len());
let n = (total - RECORD_HEADER_BYTES) / 4;
let mut meta = RvcsiNxMeta {
subcarrier_count: 0,
channel: 0,
bandwidth_mhz: 0,
rssi_dbm: 0,
noise_floor_dbm: 0,
timestamp_ns: 0,
};
let mut i_out = vec![0.0f32; n];
let mut q_out = vec![0.0f32; n];
// SAFETY: `buf` is valid for `buf.len()` bytes; `i_out`/`q_out` are valid
// for `n` f32s each and we pass `n` as the capacity; `meta` points to a
// fully owned, properly aligned RvcsiNxMeta. The C side writes only within
// those bounds and fully initializes `meta` on RVCSI_NX_OK.
let rc = unsafe {
rvcsi_nx_parse_record(
buf.as_ptr(),
buf.len(),
&mut meta as *mut RvcsiNxMeta,
i_out.as_mut_ptr(),
q_out.as_mut_ptr(),
n,
)
};
if rc != 0 {
return Err(NexmonFfiError::Shim {
code: rc,
message: strerror(rc),
});
}
debug_assert_eq!(meta.subcarrier_count as usize, n);
let rec = NexmonRecord {
subcarrier_count: meta.subcarrier_count,
channel: meta.channel,
bandwidth_mhz: meta.bandwidth_mhz,
rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm),
noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm),
timestamp_ns: meta.timestamp_ns,
i_values: i_out,
q_values: q_out,
};
Ok((rec, total))
}
/// Encode a record to bytes via the C writer (used by tests and the recorder).
pub fn encode_record(rec: &NexmonRecord) -> Result<Vec<u8>, NexmonFfiError> {
let n = rec.subcarrier_count as usize;
if n == 0 || n > MAX_SUBCARRIERS || rec.i_values.len() != n || rec.q_values.len() != n {
return Err(NexmonFfiError::Shim {
code: 6,
message: "bad subcarrier count or i/q length".to_string(),
});
}
let meta = RvcsiNxMeta {
subcarrier_count: rec.subcarrier_count,
channel: rec.channel,
bandwidth_mhz: rec.bandwidth_mhz,
rssi_dbm: rec.rssi_dbm.unwrap_or(ABSENT_I16),
noise_floor_dbm: rec.noise_floor_dbm.unwrap_or(ABSENT_I16),
timestamp_ns: rec.timestamp_ns,
};
let cap = RECORD_HEADER_BYTES + n * 4;
let mut buf = vec![0u8; cap];
// SAFETY: `buf` is valid for `cap` bytes; `i_in`/`q_in` are valid for `n`
// f32s each (checked above); `meta` is a fully initialized owned struct.
let written = unsafe {
rvcsi_nx_write_record(
buf.as_mut_ptr(),
cap,
&meta as *const RvcsiNxMeta,
rec.i_values.as_ptr(),
rec.q_values.as_ptr(),
)
};
if written == 0 {
return Err(NexmonFfiError::Shim {
code: 4,
message: "write_record failed (capacity or argument error)".to_string(),
});
}
debug_assert_eq!(written, cap);
buf.truncate(written);
Ok(buf)
}
// ===== real nexmon_csi UDP payload (format 2) ==========================
/// A Broadcom d11ac `chanspec` decoded into (channel, bandwidth-MHz, 5 GHz?).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DecodedChanspec {
/// Raw chanspec word.
pub chanspec: u16,
/// `chanspec & 0xff`.
pub channel: u16,
/// 20 / 40 / 80 / 160, or `0` if the bandwidth bits are unrecognised.
pub bandwidth_mhz: u16,
/// `true` if the band bits (cross-checked against the channel number) say 5 GHz.
pub is_5ghz: bool,
}
/// Decode a Broadcom d11ac chanspec word (via the C shim).
pub fn decode_chanspec(chanspec: u16) -> DecodedChanspec {
let (mut ch, mut bw, mut b5) = (0u16, 0u16, 0u8);
// SAFETY: three valid out-pointers to owned locals; the C side only writes them.
unsafe { rvcsi_nx_decode_chanspec(chanspec, &mut ch, &mut bw, &mut b5) };
DecodedChanspec {
chanspec,
channel: ch,
bandwidth_mhz: bw,
is_5ghz: b5 != 0,
}
}
/// The parsed 18-byte nexmon_csi UDP header (raw vendor fields preserved, plus
/// the chanspec-decoded channel/bandwidth/band and the length-derived subcarrier
/// count).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NexmonCsiHeader {
/// RSSI in dBm (sign-extended from the int8 in the packet).
pub rssi_dbm: i16,
/// 802.11 frame-control byte.
pub fctl: u8,
/// Source MAC address.
pub src_mac: [u8; 6],
/// 802.11 sequence-control word.
pub seq_cnt: u16,
/// Receive core index (`core_stream` bits [2:0]).
pub core: u16,
/// Spatial-stream index (`core_stream` bits [5:3]).
pub spatial_stream: u16,
/// Raw Broadcom chanspec word.
pub chanspec: u16,
/// Chip version (e.g. `0x4345` = BCM43455c0 chip ID).
pub chip_ver: u16,
/// Channel number decoded from the chanspec.
pub channel: u16,
/// Bandwidth (MHz) — from the FFT size when known, else the chanspec bits.
pub bandwidth_mhz: u16,
/// `true` if the band bits say 5 GHz.
pub is_5ghz: bool,
/// Subcarrier (FFT) count, `(payload_len - 18) / 4`.
pub subcarrier_count: u16,
}
impl From<RvcsiNxUdpHeader> for NexmonCsiHeader {
fn from(h: RvcsiNxUdpHeader) -> Self {
NexmonCsiHeader {
rssi_dbm: h.rssi_dbm,
fctl: h.fctl,
src_mac: h.src_mac,
seq_cnt: h.seq_cnt,
core: h.core,
spatial_stream: h.spatial_stream,
chanspec: h.chanspec,
chip_ver: h.chip_ver,
channel: h.channel,
bandwidth_mhz: h.bandwidth_mhz,
is_5ghz: h.is_5ghz != 0,
subcarrier_count: h.subcarrier_count,
}
}
}
impl NexmonCsiHeader {
fn to_c(&self) -> RvcsiNxUdpHeader {
RvcsiNxUdpHeader {
rssi_dbm: self.rssi_dbm,
fctl: self.fctl,
src_mac: self.src_mac,
seq_cnt: self.seq_cnt,
core: self.core,
spatial_stream: self.spatial_stream,
chanspec: self.chanspec,
chip_ver: self.chip_ver,
channel: self.channel,
bandwidth_mhz: self.bandwidth_mhz,
is_5ghz: self.is_5ghz as u8,
subcarrier_count: self.subcarrier_count,
}
}
}
fn check(rc: i32) -> Result<(), NexmonFfiError> {
if rc == 0 {
Ok(())
} else {
Err(NexmonFfiError::Shim {
code: rc,
message: strerror(rc),
})
}
}
/// Parse just the 18-byte nexmon_csi UDP header of `payload`.
pub fn parse_nexmon_udp_header(payload: &[u8]) -> Result<NexmonCsiHeader, NexmonFfiError> {
let mut hdr = RvcsiNxUdpHeader::default();
// SAFETY: `payload` valid for `payload.len()`; `hdr` is an owned struct the
// C side only writes on RVCSI_NX_OK (and zero-initialises first).
let rc = unsafe { rvcsi_nx_csi_udp_header(payload.as_ptr(), payload.len(), &mut hdr) };
check(rc)?;
Ok(hdr.into())
}
/// Fully decode a nexmon_csi UDP payload (the 18-byte header + the CSI body).
/// Returns the parsed header and a [`NexmonRecord`] whose `timestamp_ns` is `0`
/// (the caller stamps it from the pcap packet time). `csi_format` is currently
/// only [`NEXMON_CSI_FMT_INT16_IQ`].
pub fn decode_nexmon_udp(
payload: &[u8],
csi_format: i32,
) -> Result<(NexmonCsiHeader, NexmonRecord), NexmonFfiError> {
// First parse the header so we know `nsub` (and reject bad packets early).
let header = parse_nexmon_udp_header(payload)?;
let n = header.subcarrier_count as usize;
if n == 0 || n > MAX_SUBCARRIERS {
return Err(NexmonFfiError::Shim {
code: 7,
message: "subcarrier count out of range".to_string(),
});
}
let mut hdr = RvcsiNxUdpHeader::default();
let mut meta = RvcsiNxMeta {
subcarrier_count: 0,
channel: 0,
bandwidth_mhz: 0,
rssi_dbm: 0,
noise_floor_dbm: 0,
timestamp_ns: 0,
};
let mut i_out = vec![0.0f32; n];
let mut q_out = vec![0.0f32; n];
// SAFETY: `payload` valid for its length; `i_out`/`q_out` valid for `n`
// f32s each (we pass `n` as the capacity); `hdr`/`meta` are owned structs
// the C side fully initialises on RVCSI_NX_OK and writes nothing else.
let rc = unsafe {
rvcsi_nx_csi_udp_decode(
payload.as_ptr(),
payload.len(),
csi_format,
&mut hdr,
&mut meta,
i_out.as_mut_ptr(),
q_out.as_mut_ptr(),
n,
)
};
check(rc)?;
debug_assert_eq!(meta.subcarrier_count as usize, n);
let rec = NexmonRecord {
subcarrier_count: meta.subcarrier_count,
channel: meta.channel,
bandwidth_mhz: meta.bandwidth_mhz,
rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm),
noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm),
timestamp_ns: meta.timestamp_ns,
i_values: i_out,
q_values: q_out,
};
Ok((NexmonCsiHeader::from(hdr), rec))
}
/// Serialize a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q body)
/// — used by tests and the synthetic Nexmon source. `i_values`/`q_values` are the
/// raw int16-valued samples (clamped to the int16 range on write); their length
/// must equal `header.subcarrier_count`.
pub fn encode_nexmon_udp(
header: &NexmonCsiHeader,
i_values: &[f32],
q_values: &[f32],
) -> Result<Vec<u8>, NexmonFfiError> {
let n = header.subcarrier_count as usize;
if n == 0 || n > MAX_SUBCARRIERS || i_values.len() != n || q_values.len() != n {
return Err(NexmonFfiError::Shim {
code: 6,
message: "bad subcarrier count or i/q length".to_string(),
});
}
let c_hdr = header.to_c();
let cap = NEXMON_HEADER_BYTES + n * 4;
let mut buf = vec![0u8; cap];
// SAFETY: `buf` valid for `cap` bytes; `i_in`/`q_in` valid for `n` f32s each
// (checked above); `c_hdr` is a fully initialised owned struct.
let written = unsafe {
rvcsi_nx_csi_udp_write(
buf.as_mut_ptr(),
cap,
&c_hdr as *const RvcsiNxUdpHeader,
header.subcarrier_count,
i_values.as_ptr(),
q_values.as_ptr(),
)
};
if written == 0 {
return Err(NexmonFfiError::Shim {
code: 4,
message: "csi_udp_write failed (capacity or argument error)".to_string(),
});
}
debug_assert_eq!(written, cap);
buf.truncate(written);
Ok(buf)
}
/// Bytes in the nexmon_csi UDP header (mirrors `RVCSI_NX_NEXMON_HDR_BYTES`).
pub const NEXMON_HEADER_BYTES: usize = 18;
/// nexmon_csi UDP payload magic (`0x1111`, the first two LE bytes of the header).
pub const NEXMON_MAGIC: u16 = 0x1111;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_buffer_is_not_a_record() {
assert!(record_len(&[]).is_none());
assert_eq!(decode_record(&[]).unwrap_err(), NexmonFfiError::NotARecord);
}
#[test]
fn encode_then_decode_is_identity() {
let rec = NexmonRecord {
subcarrier_count: 4,
channel: 11,
bandwidth_mhz: 20,
rssi_dbm: Some(-70),
noise_floor_dbm: None,
timestamp_ns: 999,
i_values: vec![1.0, -2.0, 0.0, 3.5],
q_values: vec![0.5, 0.25, -1.0, 0.0],
};
let bytes = encode_record(&rec).unwrap();
assert_eq!(bytes.len(), RECORD_HEADER_BYTES + 16);
let (back, consumed) = decode_record(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(back, rec);
}
#[test]
fn rejects_zero_subcarriers_on_encode() {
let rec = NexmonRecord {
subcarrier_count: 0,
channel: 1,
bandwidth_mhz: 20,
rssi_dbm: None,
noise_floor_dbm: None,
timestamp_ns: 0,
i_values: vec![],
q_values: vec![],
};
assert!(encode_record(&rec).is_err());
}
// ----- nexmon_csi UDP payload (format 2) -----
#[test]
fn chanspec_decode_known_values() {
// 2.4 GHz, channel 6, 20 MHz: band 2G (0x0000) | BW_20 (0x1000) | 0x06
let c = decode_chanspec(0x1000 | 6);
assert_eq!(c.channel, 6);
assert_eq!(c.bandwidth_mhz, 20);
assert!(!c.is_5ghz);
// 5 GHz, channel 36, 80 MHz: band 5G (0xc000) | BW_80 (0x2000) | 0x24
let c = decode_chanspec(0xc000 | 0x2000 | 36);
assert_eq!(c.channel, 36);
assert_eq!(c.bandwidth_mhz, 80);
assert!(c.is_5ghz);
// 5 GHz, channel 149, 40 MHz: band 5G | BW_40 (0x1800) | 0x95
let c = decode_chanspec(0xc000 | 0x1800 | 149);
assert_eq!(c.channel, 149);
assert_eq!(c.bandwidth_mhz, 40);
assert!(c.is_5ghz);
// channel > 14 with no/odd band bits still resolves to 5 GHz
let c = decode_chanspec(40);
assert_eq!(c.channel, 40);
assert!(c.is_5ghz);
}
fn synth_header(rssi: i16, chanspec: u16, nsub: u16) -> NexmonCsiHeader {
NexmonCsiHeader {
rssi_dbm: rssi,
fctl: 0x08,
src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01],
seq_cnt: 0x1234,
core: 1,
spatial_stream: 0,
chanspec,
chip_ver: 0x4345, // BCM43455c0 chip ID
channel: 0, // filled by decode
bandwidth_mhz: 0, // filled by decode
is_5ghz: false, // filled by decode
subcarrier_count: nsub,
}
}
#[test]
fn nexmon_udp_roundtrip_and_metadata() {
let nsub = 64u16; // 20 MHz
let chanspec = 0x1000u16 | 6; // 2.4G, ch6, 20 MHz
let hdr = synth_header(-58, chanspec, nsub);
let i: Vec<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|k| -(k as i16) as f32 + 5.0).collect();
let payload = encode_nexmon_udp(&hdr, &i, &q).expect("encode");
assert_eq!(payload.len(), NEXMON_HEADER_BYTES + (nsub as usize) * 4);
assert_eq!(u16::from_le_bytes([payload[0], payload[1]]), NEXMON_MAGIC);
// header-only parse
let h = parse_nexmon_udp_header(&payload).expect("hdr");
assert_eq!(h.rssi_dbm, -58);
assert_eq!(h.fctl, 0x08);
assert_eq!(h.src_mac, [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]);
assert_eq!(h.seq_cnt, 0x1234);
assert_eq!(h.core, 1);
assert_eq!(h.chanspec, chanspec);
assert_eq!(h.chip_ver, 0x4345);
assert_eq!(h.channel, 6);
assert_eq!(h.bandwidth_mhz, 20);
assert!(!h.is_5ghz);
assert_eq!(h.subcarrier_count, nsub);
// full decode — raw int16 counts come back exactly
let (h2, rec) = decode_nexmon_udp(&payload, NEXMON_CSI_FMT_INT16_IQ).expect("decode");
assert_eq!(h2, h);
assert_eq!(rec.subcarrier_count, nsub);
assert_eq!(rec.channel, 6);
assert_eq!(rec.bandwidth_mhz, 20);
assert_eq!(rec.rssi_dbm, Some(-58));
assert_eq!(rec.timestamp_ns, 0); // caller stamps from pcap
assert_eq!(rec.i_values.len(), nsub as usize);
assert_eq!(rec.i_values[0], -32.0);
assert_eq!(rec.i_values[33], 1.0);
assert_eq!(rec.q_values[0], 5.0);
assert_eq!(rec.q_values[10], -5.0);
}
#[test]
fn nexmon_udp_rejects_bad_magic_and_lengths() {
let hdr = synth_header(-60, 0x1000 | 11, 64);
let i = vec![1.0f32; 64];
let q = vec![0.0f32; 64];
let mut payload = encode_nexmon_udp(&hdr, &i, &q).unwrap();
// bad magic
payload[0] = 0xFF;
assert!(parse_nexmon_udp_header(&payload).is_err());
payload[0] = 0x11;
// too short for header
assert!(parse_nexmon_udp_header(&payload[..10]).is_err());
// CSI body not a multiple of 4
assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES + 3]).is_err());
// zero-length CSI body
assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES]).is_err());
// unknown CSI format
assert!(decode_nexmon_udp(&payload, 99).is_err());
}
#[test]
fn nexmon_udp_80mhz_and_160mhz_bandwidths() {
for (nsub, want_bw) in [(256u16, 80u16), (512u16, 160u16), (128u16, 40u16)] {
let hdr = synth_header(-55, 0xc000 | 0x2000 | 36, nsub);
let i = vec![0.0f32; nsub as usize];
let q = vec![0.0f32; nsub as usize];
let payload = encode_nexmon_udp(&hdr, &i, &q).unwrap();
let h = parse_nexmon_udp_header(&payload).unwrap();
assert_eq!(h.bandwidth_mhz, want_bw, "nsub={nsub}");
assert!(h.is_5ghz);
assert_eq!(h.channel, 36);
}
}
}
+677
View File
@@ -0,0 +1,677 @@
//! # rvCSI Nexmon adapter (napi-c boundary)
//!
//! Wraps the isolated C shim in `native/rvcsi_nexmon_shim.{c,h}` — the only C
//! in the rvCSI runtime (ADR-095 D2, ADR-096). The shim parses a compact,
//! byte-defined "rvCSI Nexmon record" (a normalized superset of the nexmon_csi
//! UDP payload). Everything above [`ffi`] is safe Rust; all `unsafe` is
//! confined to this crate, bounds-checked on the C side, and documented.
//!
//! Two source paths:
//!
//! * the compact, self-describing **rvCSI Nexmon record** — fed to
//! [`NexmonAdapter::from_bytes`] (records concatenated in a buffer/file);
//! * the **real nexmon_csi UDP payload** inside a libpcap capture
//! (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) — fed to
//! [`NexmonPcapAdapter::open`] / [`NexmonPcapAdapter::parse`].
//!
//! Both yield `Pending` [`CsiFrame`]s; the runtime runs
//! [`rvcsi_core::validate_frame`] on each before exposing it.
#![warn(missing_docs)]
use std::path::Path;
use rvcsi_core::{
AdapterKind, AdapterProfile, CsiFrame, CsiSource, RvcsiError, SessionId, SourceHealth, SourceId,
};
pub mod chips;
pub mod ffi;
pub mod pcap;
pub use chips::{
known_chips, known_pi_models, nexmon_adapter_profile, raspberry_pi_profile, NexmonChip,
RaspberryPiModel,
};
pub use ffi::{
decode_chanspec, decode_nexmon_udp, decode_record, encode_nexmon_udp, encode_record,
parse_nexmon_udp_header, shim_abi_version, DecodedChanspec, NexmonCsiHeader, NexmonFfiError,
NexmonRecord, NEXMON_CSI_FMT_INT16_IQ, NEXMON_HEADER_BYTES, NEXMON_MAGIC, RECORD_HEADER_BYTES,
};
pub use pcap::{
extract_udp_payload, synthetic_udp_pcap, PcapPacket, PcapReader, LINKTYPE_ETHERNET,
LINKTYPE_IPV4, LINKTYPE_LINUX_SLL, LINKTYPE_RAW, NEXMON_DEFAULT_PORT, PCAP_MAGIC_NS,
PCAP_MAGIC_US,
};
/// Build a synthetic nexmon_csi `.pcap` (LE/µs/Ethernet) from
/// `(timestamp_ns, NexmonCsiHeader, i_values, q_values)` entries, sending every
/// CSI packet to UDP port `port`. Useful for tests, examples and the `rvcsi`
/// self-tests; real captures come off a Pi running patched firmware.
pub fn synthetic_nexmon_pcap(
frames: &[(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)],
port: u16,
) -> Result<Vec<u8>, NexmonFfiError> {
let payloads: Vec<Vec<u8>> = frames
.iter()
.map(|(_, h, i, q)| encode_nexmon_udp(h, i, q))
.collect::<Result<_, _>>()?;
let refs: Vec<(u64, u16, &[u8])> = frames
.iter()
.zip(payloads.iter())
.map(|((ts, ..), p)| (*ts, port, p.as_slice()))
.collect();
Ok(pcap::synthetic_udp_pcap(&refs))
}
/// A [`CsiSource`] that replays a buffer of rvCSI Nexmon records.
///
/// Records are decoded lazily by [`CsiSource::next_frame`]; an exhausted buffer
/// returns `Ok(None)`. Frames are produced with `validation = Pending`.
pub struct NexmonAdapter {
source_id: SourceId,
session_id: SessionId,
profile: AdapterProfile,
buf: Vec<u8>,
cursor: usize,
next_frame_id: u64,
delivered: u64,
rejected: u64,
status: Option<String>,
}
impl NexmonAdapter {
/// Build an adapter from a buffer of concatenated records.
pub fn from_bytes(
source_id: impl Into<SourceId>,
session_id: SessionId,
bytes: impl Into<Vec<u8>>,
) -> Self {
// ABI guard — the static lib we linked must match the header we coded against.
debug_assert_eq!(
shim_abi_version() >> 16,
1,
"rvcsi_nexmon_shim major ABI mismatch"
);
NexmonAdapter {
source_id: source_id.into(),
session_id,
profile: AdapterProfile::nexmon_default(),
buf: bytes.into(),
cursor: 0,
next_frame_id: 0,
delivered: 0,
rejected: 0,
status: None,
}
}
/// Build an adapter from a capture file of concatenated records.
pub fn from_file(
source_id: impl Into<SourceId>,
session_id: SessionId,
path: impl AsRef<Path>,
) -> Result<Self, RvcsiError> {
let bytes = std::fs::read(path)?;
Ok(Self::from_bytes(source_id, session_id, bytes))
}
/// Override the capability profile (e.g. when the firmware version is known).
pub fn with_profile(mut self, profile: AdapterProfile) -> Self {
self.profile = profile;
self
}
/// Decode every record in `bytes` into `Pending` frames in one shot.
///
/// Stops at the first malformed record and returns what was decoded so far
/// alongside the error (`Err` carries the partial vec via the message; use
/// [`NexmonAdapter`] iteration if you need to inspect partial progress).
pub fn frames_from_bytes(
source_id: impl Into<SourceId>,
session_id: SessionId,
bytes: &[u8],
) -> Result<Vec<CsiFrame>, RvcsiError> {
let mut adapter = NexmonAdapter::from_bytes(source_id, session_id, bytes.to_vec());
let mut out = Vec::new();
while let Some(frame) = adapter.next_frame()? {
out.push(frame);
}
Ok(out)
}
fn record_to_frame(&mut self, rec: NexmonRecord) -> CsiFrame {
let fid = self.next_frame_id;
self.next_frame_id += 1;
let mut frame = CsiFrame::from_iq(
fid.into(),
self.session_id,
self.source_id.clone(),
AdapterKind::Nexmon,
rec.timestamp_ns,
rec.channel,
rec.bandwidth_mhz,
rec.i_values,
rec.q_values,
);
if let Some(r) = rec.rssi_dbm {
frame.rssi_dbm = Some(r);
}
if let Some(n) = rec.noise_floor_dbm {
frame.noise_floor_dbm = Some(n);
}
frame
}
}
impl CsiSource for NexmonAdapter {
fn profile(&self) -> &AdapterProfile {
&self.profile
}
fn session_id(&self) -> SessionId {
self.session_id
}
fn source_id(&self) -> &SourceId {
&self.source_id
}
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
if self.cursor >= self.buf.len() {
return Ok(None);
}
let remaining = &self.buf[self.cursor..];
match decode_record(remaining) {
Ok((rec, consumed)) => {
self.cursor += consumed;
self.delivered += 1;
Ok(Some(self.record_to_frame(rec)))
}
Err(e) => {
self.rejected += 1;
self.status = Some(format!("malformed record at byte {}: {e}", self.cursor));
// Skip the rest of the buffer — a corrupt record means we've lost
// framing; the daemon would reconnect/re-sync rather than guess.
self.cursor = self.buf.len();
Err(RvcsiError::adapter(
"nexmon",
format!("malformed record: {e}"),
))
}
}
}
fn health(&self) -> SourceHealth {
SourceHealth {
connected: self.cursor < self.buf.len(),
frames_delivered: self.delivered,
frames_rejected: self.rejected,
status: self.status.clone(),
}
}
}
/// A [`CsiSource`] that reads the *real* nexmon_csi UDP payloads out of a
/// libpcap (`.pcap`) capture (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`).
///
/// The pcap is parsed eagerly on construction: every UDP packet to the CSI port
/// is decoded via the napi-c shim ([`decode_nexmon_udp`]); packets that aren't
/// CSI (wrong port / not IPv4-UDP / bad nexmon magic) are counted as `rejected`
/// and skipped. Each surviving frame carries the pcap packet timestamp and
/// `validation = Pending`.
pub struct NexmonPcapAdapter {
source_id: SourceId,
session_id: SessionId,
profile: AdapterProfile,
detected_chip: NexmonChip,
frames: Vec<CsiFrame>,
headers: Vec<NexmonCsiHeader>,
link_type: u32,
cursor: usize,
skipped: u64,
}
/// Resolve the chip when every decoded packet agrees on `chip_ver`; otherwise
/// (mixed or empty) fall back to a generic 802.11ac default.
fn detect_chip(headers: &[NexmonCsiHeader]) -> NexmonChip {
match headers.first() {
None => NexmonChip::Bcm43455c0, // a sensible default; profile stays generic-enough
Some(h0) => {
let ver = h0.chip_ver;
if headers.iter().all(|h| h.chip_ver == ver) {
NexmonChip::from_chip_ver(ver)
} else {
NexmonChip::Unknown { chip_ver: 0 }
}
}
}
}
impl NexmonPcapAdapter {
/// Parse a libpcap byte buffer; `port` is the CSI UDP port to filter on
/// (`None` ⇒ [`NEXMON_DEFAULT_PORT`] = 5500). The chip is auto-detected from
/// the packets' `chip_ver` (e.g. a Raspberry Pi 5 capture ⇒ BCM43455c0);
/// override with [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`].
pub fn parse(
source_id: impl Into<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Self, RvcsiError> {
debug_assert_eq!(shim_abi_version() >> 16, 1, "rvcsi_nexmon_shim major ABI mismatch");
let source_id = source_id.into();
let reader = PcapReader::parse(pcap_bytes)?;
let link_type = reader.link_type();
let want_port = port.or(Some(NEXMON_DEFAULT_PORT));
let mut frames = Vec::new();
let mut headers = Vec::new();
let mut skipped = 0u64;
let mut next_fid = 0u64;
for (ts_ns, _dst_port, payload) in reader.udp_payloads(want_port) {
match decode_nexmon_udp(payload, NEXMON_CSI_FMT_INT16_IQ) {
Ok((hdr, rec)) => {
let mut frame = CsiFrame::from_iq(
next_fid.into(),
session_id,
source_id.clone(),
AdapterKind::Nexmon,
ts_ns,
rec.channel,
rec.bandwidth_mhz,
rec.i_values,
rec.q_values,
);
next_fid += 1;
frame.rssi_dbm = rec.rssi_dbm;
frame.noise_floor_dbm = rec.noise_floor_dbm;
frames.push(frame);
headers.push(hdr);
}
Err(_) => skipped += 1,
}
}
// Count non-CSI UDP packets on other ports as "skipped" too, for health.
if let Some(p) = want_port {
skipped += reader.udp_payloads(None).filter(|(_, dp, _)| *dp != p).count() as u64;
}
let detected_chip = detect_chip(&headers);
Ok(NexmonPcapAdapter {
source_id,
session_id,
profile: nexmon_adapter_profile(detected_chip),
detected_chip,
frames,
headers,
link_type,
cursor: 0,
skipped,
})
}
/// Override the validation profile to the given Nexmon chip (e.g. when the
/// `chip_ver` word is unreliable). This does not change the decoded frames.
pub fn with_chip(mut self, chip: NexmonChip) -> Self {
self.detected_chip = chip;
self.profile = nexmon_adapter_profile(chip);
self
}
/// Override the validation profile to a Raspberry Pi model's chip
/// (`RaspberryPiModel::Pi5` ⇒ BCM43455c0, 20/40/80 MHz, 64/128/256 sc).
pub fn with_pi_model(mut self, model: RaspberryPiModel) -> Self {
self.detected_chip = model.nexmon_chip();
self.profile = raspberry_pi_profile(model);
self
}
/// The chip resolved from the capture's `chip_ver` words (or set via
/// [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`]).
pub fn detected_chip(&self) -> NexmonChip {
self.detected_chip
}
/// Open and parse a `.pcap` file.
pub fn open(
source_id: impl Into<SourceId>,
session_id: SessionId,
path: impl AsRef<Path>,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
let bytes = std::fs::read(path)?;
Self::parse(source_id, session_id, &bytes, port)
}
/// Decode every CSI frame in a `.pcap` buffer in one shot (`Pending` frames).
pub fn frames_from_pcap_bytes(
source_id: impl Into<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Vec<CsiFrame>, RvcsiError> {
Ok(Self::parse(source_id, session_id, pcap_bytes, port)?.frames)
}
/// The capture's link-layer type.
pub fn link_type(&self) -> u32 {
self.link_type
}
/// The parsed nexmon_csi UDP headers, one per decoded frame, in order.
pub fn headers(&self) -> &[NexmonCsiHeader] {
&self.headers
}
/// Total CSI frames decoded from the capture.
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
impl CsiSource for NexmonPcapAdapter {
fn profile(&self) -> &AdapterProfile {
&self.profile
}
fn session_id(&self) -> SessionId {
self.session_id
}
fn source_id(&self) -> &SourceId {
&self.source_id
}
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
let frame = self.frames.get(self.cursor).cloned();
if frame.is_some() {
self.cursor += 1;
}
Ok(frame)
}
fn health(&self) -> SourceHealth {
SourceHealth {
connected: self.cursor < self.frames.len(),
frames_delivered: self.cursor as u64,
frames_rejected: self.skipped,
status: Some(format!(
"pcap link_type={}, {} CSI frame(s), {} non-CSI/skipped",
self.link_type,
self.frames.len(),
self.skipped
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{validate_frame, ValidationPolicy, ValidationStatus};
fn make_record(ts: u64, ch: u16, n: usize, rssi: Option<i16>) -> Vec<u8> {
let i: Vec<f32> = (0..n).map(|k| (k as f32) * 0.5).collect();
let q: Vec<f32> = (0..n).map(|k| -(k as f32) * 0.25).collect();
let rec = NexmonRecord {
subcarrier_count: n as u16,
channel: ch,
bandwidth_mhz: 80,
rssi_dbm: rssi,
noise_floor_dbm: Some(-92),
timestamp_ns: ts,
i_values: i,
q_values: q,
};
encode_record(&rec).expect("encode")
}
#[test]
fn abi_version_is_one_point_one() {
// 1.1 — minor bump when the nexmon_csi UDP/chanspec entry points landed.
assert_eq!(shim_abi_version(), 0x0001_0001);
assert_eq!(shim_abi_version() >> 16, 1, "major ABI must stay 1");
}
#[test]
fn roundtrip_single_record_via_c_shim() {
let bytes = make_record(123_456, 36, 64, Some(-58));
let (rec, consumed) = decode_record(&bytes).expect("decode");
assert_eq!(consumed, bytes.len());
assert_eq!(rec.subcarrier_count, 64);
assert_eq!(rec.channel, 36);
assert_eq!(rec.bandwidth_mhz, 80);
assert_eq!(rec.rssi_dbm, Some(-58));
assert_eq!(rec.noise_floor_dbm, Some(-92));
assert_eq!(rec.timestamp_ns, 123_456);
assert_eq!(rec.i_values.len(), 64);
// Q8.8 fixed point: 0.5 and -0.25 are exactly representable.
assert_eq!(rec.i_values[1], 0.5);
assert_eq!(rec.q_values[1], -0.25);
}
#[test]
fn adapter_streams_multiple_records_then_validates() {
let mut buf = make_record(1_000, 6, 56, Some(-60));
buf.extend(make_record(2_000, 6, 56, Some(-61)));
buf.extend(make_record(3_000, 6, 56, None));
let mut adapter = NexmonAdapter::from_bytes("nexmon-test", SessionId(7), buf);
let mut frames = Vec::new();
while let Some(f) = adapter.next_frame().unwrap() {
frames.push(f);
}
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].timestamp_ns, 1_000);
assert_eq!(frames[2].rssi_dbm, None);
assert_eq!(adapter.health().frames_delivered, 3);
assert!(!adapter.health().connected);
// 56 is not in the default Nexmon profile (64/128/256) → rejected.
let mut f = frames[0].clone();
let err = validate_frame(&mut f, adapter.profile(), &ValidationPolicy::default(), None);
assert!(err.is_err());
// With a permissive profile it validates fine.
let mut f = frames[0].clone();
validate_frame(
&mut f,
&AdapterProfile::offline(AdapterKind::Nexmon),
&ValidationPolicy::default(),
None,
)
.unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
}
#[test]
fn truncated_buffer_is_a_structured_error_not_a_panic() {
let bytes = make_record(1, 6, 64, Some(-60));
let truncated = &bytes[..bytes.len() - 10];
let err = decode_record(truncated).unwrap_err();
assert!(err.to_string().to_lowercase().contains("trunc") || err.to_string().to_lowercase().contains("short"));
let mut adapter = NexmonAdapter::from_bytes("t", SessionId(0), truncated.to_vec());
assert!(adapter.next_frame().is_err());
assert_eq!(adapter.health().frames_rejected, 1);
}
#[test]
fn bad_magic_is_rejected() {
let mut bytes = make_record(1, 6, 64, Some(-60));
bytes[0] = 0xFF;
assert!(decode_record(&bytes).is_err());
}
#[test]
fn frames_from_bytes_helper() {
let mut buf = make_record(10, 1, 64, Some(-50));
buf.extend(make_record(20, 1, 64, Some(-51)));
let frames = NexmonAdapter::frames_from_bytes("t", SessionId(1), &buf).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!(frames[1].timestamp_ns, 20);
}
// ----- NexmonPcapAdapter (real nexmon_csi UDP inside a libpcap file) -----
/// Build a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q).
fn synth_nexmon_payload(rssi: i16, chanspec: u16, nsub: u16, seq: u16) -> Vec<u8> {
let hdr = NexmonCsiHeader {
rssi_dbm: rssi,
fctl: 0x08,
src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x02],
seq_cnt: seq,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 0x4345,
channel: 0,
bandwidth_mhz: 0,
is_5ghz: false,
subcarrier_count: nsub,
};
let i: Vec<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|k| (seq as i16 + k as i16) as f32).collect();
encode_nexmon_udp(&hdr, &i, &q).expect("encode nexmon payload")
}
/// Wrap `payload` in an Ethernet/IPv4/UDP frame to `dst_port`.
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = vec![
1, 2, 3, 4, 5, 6, // dst mac
10, 11, 12, 13, 14, 15, // src mac
];
f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4
let total = (20 + 8 + payload.len()) as u16;
f.extend_from_slice(&[0x45, 0x00]);
f.extend_from_slice(&total.to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum
f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip
f.extend_from_slice(&54321u16.to_be_bytes()); // src port
f.extend_from_slice(&dst_port.to_be_bytes()); // dst port
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len
f.extend_from_slice(&[0, 0]); // udp cksum
f.extend_from_slice(payload);
f
}
/// Build a classic LE/microsecond pcap from `(ts_sec, ts_usec, frame)` records.
fn pcap_le_us(link_type: u32, recs: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&0xa1b2_c3d4u32.to_le_bytes());
b.extend_from_slice(&[2, 0, 4, 0]); // ver major/minor
b.extend_from_slice(&0u32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&link_type.to_le_bytes());
for (s, us, f) in recs {
b.extend_from_slice(&s.to_le_bytes());
b.extend_from_slice(&us.to_le_bytes());
b.extend_from_slice(&(f.len() as u32).to_le_bytes());
b.extend_from_slice(&(f.len() as u32).to_le_bytes());
b.extend_from_slice(f);
}
b
}
#[test]
fn pcap_adapter_decodes_real_nexmon_csi_packets() {
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz
let nsub = 256u16;
let recs = vec![
(1_000u32, 100_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))),
(1_000u32, 600_000u32, eth_ip_udp(9999, &[0xaa; 8])), // unrelated UDP
(1_001u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-61, chanspec, nsub, 2))),
(1_001u32, 50_000u32, eth_ip_udp(5500, &[0x42; 30])), // bad nexmon magic -> skipped
];
let pcap = pcap_le_us(LINKTYPE_ETHERNET, &recs);
let mut adapter = NexmonPcapAdapter::parse("nexmon-pcap", SessionId(9), &pcap, None).unwrap();
assert_eq!(adapter.link_type(), LINKTYPE_ETHERNET);
assert_eq!(adapter.frame_count(), 2);
assert_eq!(adapter.headers().len(), 2);
assert_eq!(adapter.headers()[0].chanspec, chanspec);
assert_eq!(adapter.headers()[0].channel, 36);
assert_eq!(adapter.headers()[0].bandwidth_mhz, 80);
assert!(adapter.headers()[0].is_5ghz);
assert_eq!(adapter.headers()[1].seq_cnt, 2);
let mut frames = Vec::new();
while let Some(f) = adapter.next_frame().unwrap() {
frames.push(f);
}
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].adapter_kind, AdapterKind::Nexmon);
assert_eq!(frames[0].channel, 36);
assert_eq!(frames[0].bandwidth_mhz, 80);
assert_eq!(frames[0].rssi_dbm, Some(-58));
assert_eq!(frames[0].subcarrier_count, nsub);
// pcap timestamp -> frame timestamp (1000 s + 100000 us)
assert_eq!(frames[0].timestamp_ns, 1_000 * 1_000_000_000 + 100_000 * 1_000);
assert_eq!(frames[1].timestamp_ns, 1_001 * 1_000_000_000);
let h = adapter.health();
assert!(!h.connected);
assert_eq!(h.frames_delivered, 2);
assert!(h.frames_rejected >= 2); // the bad-magic one + the unrelated-port one
}
#[test]
fn pcap_adapter_validates_decoded_frames() {
let pcap = pcap_le_us(
LINKTYPE_ETHERNET,
&[(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-60, 0x1000 | 6, 64, 7)))],
);
let frames = NexmonPcapAdapter::frames_from_pcap_bytes("p", SessionId(0), &pcap, Some(5500)).unwrap();
assert_eq!(frames.len(), 1);
// 64 sc, channel 6 — accepted by a permissive (offline) profile
let mut f = frames[0].clone();
validate_frame(
&mut f,
&AdapterProfile::offline(AdapterKind::Nexmon),
&ValidationPolicy::default(),
None,
)
.unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
assert_eq!(f.channel, 6);
assert_eq!(f.bandwidth_mhz, 20);
}
#[test]
fn pcap_adapter_rejects_garbage_pcap() {
assert!(NexmonPcapAdapter::parse("p", SessionId(0), &[0u8; 8], None).is_err());
assert!(NexmonPcapAdapter::open("p", SessionId(0), "/no/such/file.pcap", None).is_err());
}
#[test]
fn pcap_adapter_auto_detects_raspberry_pi_5_chip() {
// synth_nexmon_payload stamps chip_ver = 0x4345 (BCM4345 family chip ID),
// which is the CYW43455 / BCM43455c0 on a Raspberry Pi 3B+ / 4 / 400 / 5.
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz
let nsub = 256u16;
let pcap = pcap_le_us(
LINKTYPE_ETHERNET,
&[
(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))),
(1u32, 50_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-59, chanspec, nsub, 2))),
],
);
let adapter = NexmonPcapAdapter::parse("pi5-cap", SessionId(1), &pcap, None).unwrap();
assert_eq!(adapter.detected_chip(), NexmonChip::Bcm43455c0);
assert_eq!(adapter.headers()[0].chip(), NexmonChip::Bcm43455c0);
// the adapter's validation profile is the 43455c0 one (20/40/80, 64/128/256)
let p = adapter.profile();
assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]);
assert!(p.accepts_subcarrier_count(256));
assert!(p.accepts_channel(36));
// 256-sc, ch 36 frame validates fine against the Pi 5 profile
let mut f = adapter.frames[0].clone();
validate_frame(&mut f, &raspberry_pi_profile(RaspberryPiModel::Pi5), &ValidationPolicy::default(), None).unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
// explicit override to a Pi 5 also works
let a2 = NexmonPcapAdapter::parse("p", SessionId(0), &pcap, None).unwrap().with_pi_model(RaspberryPiModel::Pi5);
assert_eq!(a2.detected_chip(), NexmonChip::Bcm43455c0);
assert!(a2.profile().chip.as_deref().unwrap().contains("pi5"));
}
}
+381
View File
@@ -0,0 +1,381 @@
//! Minimal, dependency-free reader for the classic libpcap (`.pcap`) file
//! format — enough to pull the UDP payloads out of a nexmon_csi capture
//! (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`).
//!
//! Supports the standard byte-order / timestamp-resolution magics
//! (`0xa1b2c3d4`, `0xd4c3b2a1`, and the nanosecond variants `0xa1b23c4d` /
//! `0x4d3cb2a1`) and the link-layer types that show up for nexmon CSI captures:
//! Ethernet (`1`), raw IPv4 (`101` / `228`), and Linux SLL (`113`). pcapng is a
//! documented follow-up. No `unsafe`, no allocation beyond owning the packet
//! bytes, and every read is bounds-checked.
use rvcsi_core::RvcsiError;
/// Classic-pcap magic (microsecond timestamps), as the 32-bit value.
pub const PCAP_MAGIC_US: u32 = 0xa1b2_c3d4;
/// Classic-pcap magic (nanosecond timestamps), as the 32-bit value.
pub const PCAP_MAGIC_NS: u32 = 0xa1b2_3c4d;
/// Link-layer types we know how to peel down to an IPv4 packet.
pub const LINKTYPE_ETHERNET: u32 = 1;
/// Raw IPv4 (no link header).
pub const LINKTYPE_RAW: u32 = 101;
/// Linux "cooked" capture v1 (16-byte pseudo-header).
pub const LINKTYPE_LINUX_SLL: u32 = 113;
/// Raw IPv4 (the IANA-assigned value).
pub const LINKTYPE_IPV4: u32 = 228;
/// The default UDP port nexmon_csi sends CSI frames to.
pub const NEXMON_DEFAULT_PORT: u16 = 5500;
/// One captured packet: its timestamp (ns since the Unix epoch) and raw bytes
/// (starting at the link layer named by [`PcapReader::link_type`]).
#[derive(Debug, Clone)]
pub struct PcapPacket {
/// Capture timestamp, nanoseconds since the Unix epoch.
pub timestamp_ns: u64,
/// The packet bytes (truncated to the capture's snaplen, as on disk).
pub data: Vec<u8>,
}
/// A parsed classic-pcap file.
#[derive(Debug, Clone)]
pub struct PcapReader {
link_type: u32,
packets: Vec<PcapPacket>,
}
fn parse_err(offset: usize, msg: impl Into<String>) -> RvcsiError {
RvcsiError::parse(offset, format!("pcap: {}", msg.into()))
}
struct Endian(bool /* big-endian writer? */);
impl Endian {
fn u32(&self, b: &[u8]) -> u32 {
if self.0 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
} else {
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
}
}
impl PcapReader {
/// Parse a classic-pcap byte buffer.
pub fn parse(bytes: &[u8]) -> Result<PcapReader, RvcsiError> {
if bytes.len() < 24 {
return Err(parse_err(0, "buffer shorter than the 24-byte global header"));
}
// The 4 magic bytes on disk identify both byte order and ts resolution.
// 0xa1b2c3d4 written by a LE host -> [d4,c3,b2,a1]; by a BE host -> [a1,b2,c3,d4].
// 0xa1b23c4d (nanosecond ts): LE -> [4d,3c,b2,a1]; BE -> [a1,b2,3c,4d].
let m = [bytes[0], bytes[1], bytes[2], bytes[3]];
let (endian, ts_is_ns) = match m {
[0xd4, 0xc3, 0xb2, 0xa1] => (Endian(false), false),
[0xa1, 0xb2, 0xc3, 0xd4] => (Endian(true), false),
[0x4d, 0x3c, 0xb2, 0xa1] => (Endian(false), true),
[0xa1, 0xb2, 0x3c, 0x4d] => (Endian(true), true),
_ => {
let raw = u32::from_le_bytes(m);
return Err(parse_err(
0,
format!("unrecognised pcap magic 0x{raw:08x} (pcapng is not supported)"),
));
}
};
// bytes 4..6 version_major, 6..8 version_minor, 8..12 thiszone,
// 12..16 sigfigs, 16..20 snaplen, 20..24 network (link type)
let link_type = endian.u32(&bytes[20..24]);
let mut packets = Vec::new();
let mut off = 24usize;
while off + 16 <= bytes.len() {
let ts_sec = endian.u32(&bytes[off..off + 4]) as u64;
let ts_frac = endian.u32(&bytes[off + 4..off + 8]) as u64;
let incl_len = endian.u32(&bytes[off + 8..off + 12]) as usize;
// orig_len at off+12..off+16 is informational; ignored.
let data_start = off + 16;
if incl_len > bytes.len().saturating_sub(data_start) {
// Truncated final record — stop cleanly rather than erroring.
break;
}
let timestamp_ns = ts_sec
.saturating_mul(1_000_000_000)
.saturating_add(if ts_is_ns { ts_frac } else { ts_frac.saturating_mul(1_000) });
packets.push(PcapPacket {
timestamp_ns,
data: bytes[data_start..data_start + incl_len].to_vec(),
});
off = data_start + incl_len;
}
Ok(PcapReader { link_type, packets })
}
/// The capture's link-layer type (one of the `LINKTYPE_*` constants, or another value).
pub fn link_type(&self) -> u32 {
self.link_type
}
/// All captured packets, in file order.
pub fn packets(&self) -> &[PcapPacket] {
&self.packets
}
/// Iterate the UDP payloads in the capture whose destination port matches
/// `port` (or all UDP payloads if `port` is `None`), as `(timestamp_ns,
/// dst_port, payload)`. Non-IPv4 / non-UDP / non-matching packets are skipped.
pub fn udp_payloads(
&self,
port: Option<u16>,
) -> impl Iterator<Item = (u64, u16, &[u8])> + '_ {
let link_type = self.link_type;
self.packets.iter().filter_map(move |pkt| {
let (dst_port, payload) = extract_udp_payload(&pkt.data, link_type)?;
if let Some(p) = port {
if dst_port != p {
return None;
}
}
Some((pkt.timestamp_ns, dst_port, payload))
})
}
}
/// Strip the link / network / transport headers from a captured frame with the
/// given link type and return `(udp_dst_port, udp_payload)`, or `None` if it
/// isn't an IPv4/UDP packet we can peel.
pub fn extract_udp_payload(frame: &[u8], link_type: u32) -> Option<(u16, &[u8])> {
let ip = match link_type {
LINKTYPE_ETHERNET => {
if frame.len() < 14 {
return None;
}
let ethertype = u16::from_be_bytes([frame[12], frame[13]]);
if ethertype != 0x0800 {
return None; // not IPv4 (ignore VLAN-tagged for now)
}
&frame[14..]
}
LINKTYPE_LINUX_SLL => {
if frame.len() < 16 {
return None;
}
let proto = u16::from_be_bytes([frame[14], frame[15]]);
if proto != 0x0800 {
return None;
}
&frame[16..]
}
LINKTYPE_RAW | LINKTYPE_IPV4 => frame,
_ => return None,
};
// IPv4 header
if ip.len() < 20 {
return None;
}
if (ip[0] >> 4) != 4 {
return None; // not IPv4
}
let ihl = (ip[0] & 0x0f) as usize * 4;
if ihl < 20 || ip.len() < ihl {
return None;
}
if ip[9] != 17 {
return None; // not UDP
}
let udp = &ip[ihl..];
if udp.len() < 8 {
return None;
}
let dst_port = u16::from_be_bytes([udp[2], udp[3]]);
let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; // includes the 8-byte UDP header
let payload_len = udp_len.saturating_sub(8).min(udp.len() - 8);
Some((dst_port, &udp[8..8 + payload_len]))
}
/// Build a synthetic classic-pcap byte buffer — little-endian, microsecond
/// timestamps, [`LINKTYPE_ETHERNET`] — wrapping the given UDP payloads, one
/// Ethernet/IPv4/UDP packet each. Entries are `(timestamp_ns, dst_port,
/// payload)`. Intended for tests, examples and the `rvcsi` self-tests: real
/// captures come off a Raspberry Pi running patched firmware
/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`).
pub fn synthetic_udp_pcap(packets: &[(u64, u16, &[u8])]) -> Vec<u8> {
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = vec![
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, // dst mac
0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // src mac
];
f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4
let total = (20 + 8 + payload.len()) as u16;
f.extend_from_slice(&[0x45, 0x00]);
f.extend_from_slice(&total.to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum
f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip
f.extend_from_slice(&54321u16.to_be_bytes()); // src port
f.extend_from_slice(&dst_port.to_be_bytes()); // dst port
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len
f.extend_from_slice(&[0, 0]); // udp cksum
f.extend_from_slice(payload);
f
}
let mut b = Vec::new();
b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes());
b.extend_from_slice(&[2, 0, 4, 0]); // version major/minor
b.extend_from_slice(&0u32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&LINKTYPE_ETHERNET.to_le_bytes());
for (ts_ns, dst_port, payload) in packets {
let frame = eth_ip_udp(*dst_port, payload);
let ts_sec = (ts_ns / 1_000_000_000) as u32;
let ts_usec = ((ts_ns % 1_000_000_000) / 1_000) as u32;
b.extend_from_slice(&ts_sec.to_le_bytes());
b.extend_from_slice(&ts_usec.to_le_bytes());
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len
b.extend_from_slice(&frame);
}
b
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic Ethernet/IPv4/UDP frame carrying `payload` to `dst_port`.
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
// Ethernet II: dst[6] src[6] ethertype[2]
f.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
f.extend_from_slice(&[0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]);
f.extend_from_slice(&0x0800u16.to_be_bytes());
// IPv4: 20-byte header
let total_len = (20 + 8 + payload.len()) as u16;
let mut ip = vec![
0x45, 0x00, // version/IHL, DSCP/ECN
];
ip.extend_from_slice(&total_len.to_be_bytes());
ip.extend_from_slice(&[0, 0, 0, 0, 64, 17]); // id, flags/frag, ttl, proto=UDP
ip.extend_from_slice(&[0, 0]); // header checksum (not checked here)
ip.extend_from_slice(&[10, 0, 0, 1]); // src ip
ip.extend_from_slice(&[10, 0, 0, 20]); // dst ip
assert_eq!(ip.len(), 20);
f.extend_from_slice(&ip);
// UDP: src_port[2] dst_port[2] length[2] checksum[2]
f.extend_from_slice(&54321u16.to_be_bytes());
f.extend_from_slice(&dst_port.to_be_bytes());
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes());
f.extend_from_slice(&[0, 0]); // checksum
f.extend_from_slice(payload);
f
}
/// Build a minimal classic-pcap file (LE, microsecond) wrapping the frames.
fn pcap_le_us(link_type: u32, frames: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes()); // version major
b.extend_from_slice(&4u16.to_le_bytes()); // version minor
b.extend_from_slice(&0i32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&link_type.to_le_bytes());
for (ts_sec, ts_usec, frame) in frames {
b.extend_from_slice(&ts_sec.to_le_bytes());
b.extend_from_slice(&ts_usec.to_le_bytes());
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len
b.extend_from_slice(frame);
}
b
}
#[test]
fn parses_global_header_and_iterates_udp_payloads() {
let p1 = vec![0xaa; 30];
let p2 = vec![0xbb; 12];
let other = vec![0xcc; 8];
let frames = vec![
(100u32, 250_000u32, eth_ip_udp(5500, &p1)),
(101u32, 500_000u32, eth_ip_udp(9999, &other)), // different port
(102u32, 0u32, eth_ip_udp(5500, &p2)),
];
let file = pcap_le_us(LINKTYPE_ETHERNET, &frames);
let r = PcapReader::parse(&file).unwrap();
assert_eq!(r.link_type(), LINKTYPE_ETHERNET);
assert_eq!(r.packets().len(), 3);
let csi: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(csi.len(), 2);
assert_eq!(csi[0].0, 100 * 1_000_000_000 + 250_000 * 1_000); // ts_ns
assert_eq!(csi[0].1, 5500);
assert_eq!(csi[0].2, &p1[..]);
assert_eq!(csi[1].2, &p2[..]);
// no filter -> all 3 UDP payloads
assert_eq!(r.udp_payloads(None).count(), 3);
}
#[test]
fn handles_raw_ipv4_linktype() {
// raw IPv4 frame = the IPv4 packet directly (no Ethernet header)
let payload = vec![0x11; 20];
let eth = eth_ip_udp(5500, &payload);
let raw_ip = eth[14..].to_vec(); // strip the 14-byte Ethernet header
let file = pcap_le_us(LINKTYPE_RAW, &[(5u32, 0u32, raw_ip)]);
let r = PcapReader::parse(&file).unwrap();
let v: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(v.len(), 1);
assert_eq!(v[0].2, &payload[..]);
}
#[test]
fn nanosecond_magic_scales_timestamps_correctly() {
let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(7u32, 123u32, eth_ip_udp(5500, &[0u8; 8]))]);
// patch the magic to the nanosecond variant
file[0..4].copy_from_slice(&PCAP_MAGIC_NS.to_le_bytes());
let r = PcapReader::parse(&file).unwrap();
let v: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(v[0].0, 7 * 1_000_000_000 + 123); // ts_frac taken as ns, not us
}
#[test]
fn rejects_garbage_and_pcapng() {
assert!(PcapReader::parse(&[0u8; 10]).is_err()); // too short
assert!(PcapReader::parse(&[0u8; 24]).is_err()); // zero magic
// pcapng section-header-block magic (0x0a0d0d0a) — not supported
let mut ng = vec![0x0a, 0x0d, 0x0d, 0x0a];
ng.extend_from_slice(&[0u8; 24]);
assert!(PcapReader::parse(&ng).is_err());
}
#[test]
fn truncated_final_record_is_tolerated() {
let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(1u32, 0u32, eth_ip_udp(5500, &[0u8; 16]))]);
// append a partial record header + claim a huge incl_len
file.extend_from_slice(&2u32.to_le_bytes());
file.extend_from_slice(&0u32.to_le_bytes());
file.extend_from_slice(&9999u32.to_le_bytes()); // incl_len > remaining
file.extend_from_slice(&9999u32.to_le_bytes());
file.extend_from_slice(&[0xde, 0xad]); // only 2 bytes of "data"
let r = PcapReader::parse(&file).unwrap();
assert_eq!(r.packets().len(), 1); // the complete one only
}
#[test]
fn extract_udp_payload_rejects_non_udp() {
// build an Ethernet/IPv4 frame but with proto = TCP (6)
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
// IPv4 proto byte is at Ethernet(14) + 9 = 23
eth[14 + 9] = 6; // TCP
assert!(extract_udp_payload(&eth, LINKTYPE_ETHERNET).is_none());
// wrong ethertype
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
eth[12] = 0x86;
eth[13] = 0xdd; // IPv6
assert!(extract_udp_payload(&eth, LINKTYPE_ETHERNET).is_none());
// unknown link type
assert!(extract_udp_payload(&eth, 9999).is_none());
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "rvcsi-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI command-line tool — inspect, replay, stream, events, health, calibrate, export (ADR-095 FR7)"
repository.workspace = true
keywords = ["wifi", "csi", "cli", "rvcsi"]
categories = ["science", "command-line-utilities"]
[[bin]]
name = "rvcsi"
path = "src/main.rs"
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
rvcsi-adapter-file = { path = "../rvcsi-adapter-file" }
rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" }
rvcsi-runtime = { path = "../rvcsi-runtime" }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
tempfile = "3.10"
+667
View File
@@ -0,0 +1,667 @@
//! Implementations of the `rvcsi` subcommands (ADR-095 FR7).
//!
//! Each command writes to a caller-supplied `&mut dyn Write` so the bodies can
//! be unit-tested against an in-memory buffer.
use std::io::Write;
use anyhow::{Context, Result};
use rvcsi_adapter_file::{read_all, CaptureHeader, FileRecorder, FileReplayAdapter};
use rvcsi_adapter_nexmon::NexmonAdapter;
use rvcsi_core::{
validate_frame, AdapterKind, AdapterProfile, CsiFrame, CsiSource, SessionId, SourceId,
ValidationPolicy,
};
use rvcsi_runtime as runtime;
/// `rvcsi record --in <nexmon.bin> --out <cap.rvcsi>` — transcode a buffer of
/// "rvCSI Nexmon records" (the napi-c shim format) into a `.rvcsi` capture file,
/// validating each frame on the way in. This gives the CLI a way to produce
/// `.rvcsi` files without a live radio (which needs the not-yet-shipped daemon).
pub fn record_from_nexmon(
out: &mut dyn Write,
nexmon_path: &str,
out_path: &str,
source_id: &str,
session_id: u64,
) -> Result<()> {
let bytes = std::fs::read(nexmon_path).with_context(|| format!("reading {nexmon_path}"))?;
let mut src = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes);
let profile = AdapterProfile::offline(AdapterKind::Nexmon);
let policy = ValidationPolicy::default();
let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile.clone());
let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?;
let (mut written, mut skipped, mut prev_ts) = (0u64, 0u64, None);
loop {
match src.next_frame() {
Ok(None) => break,
Ok(Some(mut f)) => {
let ts = f.timestamp_ns;
match validate_frame(&mut f, &profile, &policy, prev_ts) {
Ok(()) if f.is_exposable() => {
prev_ts = Some(ts);
rec.write_frame(&f)?;
written += 1;
}
_ => skipped += 1,
}
}
Err(e) => {
writeln!(out, "warning: stopped at a malformed Nexmon record: {e}")?;
break;
}
}
}
rec.finish()?;
writeln!(out, "recorded {written} frame(s) to {out_path} ({skipped} dropped by validation)")?;
Ok(())
}
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi> [--chip pi5]` —
/// transcode the real nexmon_csi UDP payloads inside a libpcap capture
/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into a `.rvcsi` capture file,
/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). `chip` is
/// an optional chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, ...) —
/// when given, frames are validated against that device's profile and the
/// non-conforming ones dropped (and the profile is stamped on the capture).
pub fn record_from_nexmon_pcap(
out: &mut dyn Write,
pcap_path: &str,
out_path: &str,
source_id: &str,
session_id: u64,
port: Option<u16>,
chip: Option<&str>,
) -> Result<()> {
let bytes = std::fs::read(pcap_path).with_context(|| format!("reading {pcap_path}"))?;
let frames = runtime::decode_nexmon_pcap_for(&bytes, source_id, session_id, port, chip)
.with_context(|| format!("parsing nexmon pcap {pcap_path}"))?;
let profile = match chip {
Some(spec) => runtime::nexmon_profile_for(spec)
.ok_or_else(|| anyhow::anyhow!("unknown nexmon chip / Raspberry Pi model `{spec}`"))?,
None => AdapterProfile::nexmon_default(),
};
let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile);
let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?;
for f in &frames {
rec.write_frame(f)?;
}
rec.finish()?;
let chip_note = chip.map(|c| format!(" (chip {c})")).unwrap_or_default();
writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}{chip_note}", frames.len())?;
Ok(())
}
/// `rvcsi nexmon-chips` — list the Broadcom/Cypress chips nexmon_csi runs on and
/// the Raspberry Pi models that carry them (incl. the Pi 5 → BCM43455c0).
pub fn nexmon_chips_cmd(out: &mut dyn Write, json: bool) -> Result<()> {
use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip};
if json {
let chips: Vec<_> = known_chips()
.iter()
.map(|c| {
let p = nexmon_adapter_profile(*c);
serde_json::json!({
"slug": c.slug(), "description": c.description(),
"dual_band": c.dual_band(), "int16_iq_export": c.uses_int16_iq(),
"bandwidths_mhz": p.supported_bandwidths_mhz,
"expected_subcarrier_counts": p.expected_subcarrier_counts,
})
})
.collect();
let pis: Vec<_> = known_pi_models()
.iter()
.map(|m| serde_json::json!({
"slug": m.slug(), "chip": m.nexmon_chip().slug(), "csi_supported": m.csi_supported(),
}))
.collect();
writeln!(out, "{}", serde_json::to_string_pretty(&serde_json::json!({ "chips": chips, "raspberry_pi_models": pis }))?)?;
return Ok(());
}
writeln!(out, "Nexmon-supported Broadcom/Cypress chips:")?;
for c in known_chips() {
let p = nexmon_adapter_profile(*c);
writeln!(
out,
" {:<12} {} [bw {:?} MHz, sc {:?}{}]",
c.slug(),
c.description(),
p.supported_bandwidths_mhz,
p.expected_subcarrier_counts,
if c.uses_int16_iq() { "" } else { ", legacy packed-float export" }
)?;
}
writeln!(out, "\nRaspberry Pi models:")?;
for m in known_pi_models() {
let chip = m.nexmon_chip();
let chip_slug = if matches!(chip, NexmonChip::Unknown { .. }) { "(no CSI support)".to_string() } else { chip.slug() };
writeln!(out, " {:<10} -> {}{}", m.slug(), chip_slug, if m.csi_supported() { "" } else { " [WiFi present but not CSI-capable]" })?;
}
Ok(())
}
/// `rvcsi inspect-nexmon <csi.pcap>` — summarize a nexmon_csi `.pcap` (link
/// type, CSI frame count, channels, bandwidths, chip versions, RSSI range,
/// time span). `port` is the CSI UDP port (`None` ⇒ 5500).
pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option<u16>, json: bool) -> Result<()> {
let s = runtime::summarize_nexmon_pcap(pcap_path, port).with_context(|| format!("inspecting {pcap_path}"))?;
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&s)?)?;
return Ok(());
}
writeln!(out, "nexmon pcap : {pcap_path}")?;
writeln!(out, " link type : {}", s.link_type)?;
writeln!(out, " CSI frames : {}", s.csi_frame_count)?;
writeln!(out, " skipped pkts : {}", s.skipped_packets)?;
writeln!(
out,
" time span : {} .. {} ns ({} ns)",
s.first_timestamp_ns,
s.last_timestamp_ns,
s.last_timestamp_ns.saturating_sub(s.first_timestamp_ns)
)?;
writeln!(out, " channels : {:?}", s.channels)?;
writeln!(out, " bandwidths : {:?} MHz", s.bandwidths_mhz)?;
writeln!(out, " subcarriers : {:?}", s.subcarrier_counts)?;
writeln!(
out,
" chip versions: {}",
s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::<Vec<_>>().join(", ")
)?;
writeln!(out, " chip : {} (seen: {})", s.detected_chip, s.chip_names.join(", "))?;
match s.rssi_dbm_range {
Some((lo, hi)) => writeln!(out, " rssi range : {lo} .. {hi} dBm")?,
None => writeln!(out, " rssi range : (none)")?,
}
Ok(())
}
/// `rvcsi decode-chanspec <hex-or-dec>` — decode a Broadcom d11ac chanspec word
/// to `{channel, bandwidth_mhz, is_5ghz}` (JSON, or a human line).
pub fn decode_chanspec_cmd(out: &mut dyn Write, chanspec_str: &str, json: bool) -> Result<()> {
let s = chanspec_str.trim();
let value: u32 = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).with_context(|| format!("not a hex u16: {s}"))?
} else {
s.parse::<u32>().with_context(|| format!("not a decimal u16: {s}"))?
};
let d = rvcsi_adapter_nexmon::decode_chanspec((value & 0xFFFF) as u16);
if json {
writeln!(
out,
"{}",
serde_json::to_string(&serde_json::json!({
"chanspec": d.chanspec, "channel": d.channel,
"bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz
}))?
)?;
} else {
writeln!(
out,
"chanspec 0x{:04x}: channel {} @ {} MHz ({})",
d.chanspec,
d.channel,
d.bandwidth_mhz,
if d.is_5ghz { "5 GHz" } else { "2.4 GHz" }
)?;
}
Ok(())
}
/// `rvcsi inspect <path>` — print a summary of a `.rvcsi` capture file.
pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?;
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&summary)?)?;
return Ok(());
}
writeln!(out, "capture : {path}")?;
writeln!(out, " version : {}", summary.capture_version)?;
writeln!(out, " session : {}", summary.session_id)?;
writeln!(out, " source : {}", summary.source_id)?;
writeln!(out, " adapter : {}", summary.adapter_kind)?;
if let Some(chip) = &summary.chip {
writeln!(out, " chip : {chip}")?;
}
writeln!(out, " frames : {}", summary.frame_count)?;
writeln!(
out,
" time span : {} .. {} ns ({} ns)",
summary.first_timestamp_ns,
summary.last_timestamp_ns,
summary.last_timestamp_ns.saturating_sub(summary.first_timestamp_ns)
)?;
writeln!(out, " channels : {:?}", summary.channels)?;
writeln!(out, " subcarriers : {:?}", summary.subcarrier_counts)?;
writeln!(out, " mean quality : {:.3}", summary.mean_quality)?;
let b = summary.validation_breakdown;
writeln!(
out,
" validation : accepted={} degraded={} recovered={} rejected={} pending={}",
b.accepted, b.degraded, b.recovered, b.rejected, b.pending
)?;
writeln!(out, " calibration : {}", summary.calibration_version.as_deref().unwrap_or("(none)"))?;
Ok(())
}
/// `rvcsi replay <path>` / `rvcsi stream --in <path> --format json` — emit one
/// line per frame. With `json`, the full `CsiFrame` JSON; otherwise a compact
/// `frame_id ts ch rssi quality validation` line. `limit` caps the count
/// (`None` = all). `speed` is accepted but not enforced here (the daemon paces
/// real-time replay); a non-1.0 value is noted on stderr by the caller.
pub fn replay(out: &mut dyn Write, path: &str, json: bool, limit: Option<usize>) -> Result<()> {
let mut adapter = FileReplayAdapter::open(path).with_context(|| format!("opening {path}"))?;
let mut n = 0usize;
while let Some(frame) = adapter.next_frame()? {
if json {
writeln!(out, "{}", serde_json::to_string(&frame)?)?;
} else {
writeln!(
out,
"{:>8} {:>16} ch{:<3} rssi={:>5} q={:.3} {:?}",
frame.frame_id.value(),
frame.timestamp_ns,
frame.channel,
frame.rssi_dbm.map(|r| r.to_string()).unwrap_or_else(|| "-".into()),
frame.quality_score,
frame.validation,
)?;
}
n += 1;
if let Some(lim) = limit {
if n >= lim {
break;
}
}
}
if !json {
writeln!(out, "-- {n} frame(s)")?;
}
Ok(())
}
/// `rvcsi events <path>` — replay the capture through DSP + the event pipeline
/// and print the emitted events (compact, or full JSON with `json`).
pub fn events(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
let evs = runtime::events_from_capture(path).with_context(|| format!("processing {path}"))?;
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&evs)?)?;
return Ok(());
}
for e in &evs {
writeln!(
out,
"{:>16} ns {:<22} conf={:.3} evidence={:?}{}",
e.timestamp_ns,
e.kind.slug(),
e.confidence,
e.evidence_window_ids.iter().map(|w| w.value()).collect::<Vec<_>>(),
e.calibration_version.as_deref().map(|c| format!(" calib={c}")).unwrap_or_default(),
)?;
}
writeln!(out, "-- {} event(s)", evs.len())?;
Ok(())
}
/// `rvcsi health --source <slug> [--target <path>]` — open the source, drain it,
/// and print the final `SourceHealth` as JSON. File and Nexmon sources work
/// offline; live radios are not available in this build.
pub fn health(out: &mut dyn Write, source: &str, target: Option<&str>) -> Result<()> {
let h = match source {
"file" | "replay" => {
let path = target.context("`--target <path>` is required for the file source")?;
let mut a = FileReplayAdapter::open(path)?;
while a.next_frame()?.is_some() {}
a.health()
}
"nexmon" => {
let path = target.context("`--target <path>` is required for the nexmon source")?;
let bytes = std::fs::read(path)?;
let mut a = NexmonAdapter::from_bytes(SourceId::from("nexmon"), SessionId(0), bytes);
// pull until exhausted or a malformed record stops us
while let Ok(Some(_)) = a.next_frame() {}
a.health()
}
"esp32" | "intel" | "atheros" => {
anyhow::bail!("live capture for source `{source}` is not available in this build; use the `rvcsi-daemon` (not yet shipped) or replay a `.rvcsi` capture");
}
other => anyhow::bail!("unknown source `{other}` (expected: file, replay, nexmon, esp32, intel, atheros)"),
};
writeln!(out, "{}", serde_json::to_string_pretty(&h)?)?;
Ok(())
}
/// `rvcsi export ruvector --in <capture> --out <jsonl>` — window the capture and
/// store each window's embedding into a JSONL RF-memory file.
pub fn export_ruvector(out: &mut dyn Write, capture: &str, out_jsonl: &str) -> Result<()> {
let stored = runtime::export_capture_to_rf_memory(capture, out_jsonl)
.with_context(|| format!("exporting {capture} -> {out_jsonl}"))?;
writeln!(out, "stored {stored} window embedding(s) to {out_jsonl}")?;
Ok(())
}
/// `rvcsi calibrate --in <capture> [--out <baseline.json>]` — a v0 calibration:
/// learn the per-subcarrier mean amplitude (the "baseline") over all exposable
/// frames in a capture and emit it as JSON. Real, versioned, room-scoped
/// calibration (ADR-095 D14) lands with the daemon.
pub fn calibrate(out: &mut dyn Write, capture: &str, out_path: Option<&str>) -> Result<()> {
let (header, frames) = read_all(capture).with_context(|| format!("reading {capture}"))?;
let exposable: Vec<&CsiFrame> = frames.iter().filter(|f| f.is_exposable()).collect();
if exposable.is_empty() {
anyhow::bail!("no exposable frames in {capture} — cannot calibrate");
}
let n = exposable[0].subcarrier_count as usize;
let mut acc = vec![0.0f64; n];
let mut count = 0usize;
for f in &exposable {
if f.subcarrier_count as usize != n {
continue;
}
for (a, v) in acc.iter_mut().zip(f.amplitude.iter()) {
*a += *v as f64;
}
count += 1;
}
let baseline: Vec<f32> = acc.iter().map(|a| (*a / count.max(1) as f64) as f32).collect();
#[derive(serde::Serialize)]
struct Baseline<'a> {
source_id: &'a str,
session_id: u64,
version: String,
subcarrier_count: usize,
frames_used: usize,
baseline_amplitude: Vec<f32>,
}
let payload = Baseline {
source_id: header.source_id.as_str(),
session_id: header.session_id.value(),
version: format!("{}@auto-{count}", header.source_id.as_str()),
subcarrier_count: n,
frames_used: count,
baseline_amplitude: baseline,
};
let json = serde_json::to_string_pretty(&payload)?;
if let Some(p) = out_path {
std::fs::write(p, &json)?;
writeln!(out, "wrote baseline ({n} subcarriers, {count} frames) to {p}")?;
} else {
writeln!(out, "{json}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_adapter_nexmon::{encode_record, NexmonRecord};
use rvcsi_core::{FrameId, ValidationStatus};
fn write_capture(path: &std::path::Path, n: usize) {
let header = CaptureHeader::new(
SessionId(2),
SourceId::from("cli-it"),
AdapterProfile::offline(AdapterKind::File),
);
let mut rec = FileRecorder::create(path, &header).unwrap();
for k in 0..n {
let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 };
let i: Vec<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
let q: Vec<f32> = (0..32).map(|_| 0.5).collect();
let mut f = CsiFrame::from_iq(
FrameId(k as u64),
SessionId(2),
SourceId::from("cli-it"),
AdapterKind::File,
1_000 + k as u64 * 50_000_000,
6,
20,
i,
q,
)
.with_rssi(-55);
f.validation = ValidationStatus::Accepted;
f.quality_score = 0.9;
rec.write_frame(&f).unwrap();
}
rec.finish().unwrap();
}
fn run<F: FnOnce(&mut Vec<u8>) -> Result<()>>(f: F) -> String {
let mut buf = Vec::new();
f(&mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn inspect_human_and_json() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 12);
let p = tmp.path().to_str().unwrap();
let human = run(|o| inspect(o, p, false));
assert!(human.contains("frames : 12"));
assert!(human.contains("channels : [6]"));
let json = run(|o| inspect(o, p, true));
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["frame_count"], 12);
}
#[test]
fn replay_compact_and_json_and_limit() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 5);
let p = tmp.path().to_str().unwrap();
let compact = run(|o| replay(o, p, false, None));
assert!(compact.contains("-- 5 frame(s)"));
let json = run(|o| replay(o, p, true, Some(3)));
assert_eq!(json.lines().count(), 3);
for line in json.lines() {
let _: CsiFrame = serde_json::from_str(line).unwrap();
}
}
#[test]
fn events_command_emits_something() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 64);
let p = tmp.path().to_str().unwrap();
let out = run(|o| events(o, p, false));
assert!(out.contains("event(s)"));
let json = run(|o| events(o, p, true));
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.is_array());
}
#[test]
fn health_file_source() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 7);
let p = tmp.path().to_str().unwrap();
let out = run(|o| health(o, "file", Some(p)));
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["frames_delivered"], 7);
assert_eq!(v["connected"], false);
// unknown / live sources error cleanly
let mut buf = Vec::new();
assert!(health(&mut buf, "esp32", Some(p)).is_err());
assert!(health(&mut buf, "bogus", None).is_err());
assert!(health(&mut buf, "file", None).is_err()); // missing --target
}
#[test]
fn export_and_calibrate() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 64);
let p = tmp.path().to_str().unwrap();
let out_jsonl = tempfile::NamedTempFile::new().unwrap();
let out = run(|o| export_ruvector(o, p, out_jsonl.path().to_str().unwrap()));
assert!(out.contains("stored "));
// calibrate to stdout
let calib = run(|o| calibrate(o, p, None));
let v: serde_json::Value = serde_json::from_str(&calib).unwrap();
assert_eq!(v["subcarrier_count"], 32);
assert!(v["baseline_amplitude"].as_array().unwrap().len() == 32);
// calibrate to file
let baseline_file = tempfile::NamedTempFile::new().unwrap();
let out2 = run(|o| calibrate(o, p, Some(baseline_file.path().to_str().unwrap())));
assert!(out2.contains("wrote baseline"));
let written = std::fs::read_to_string(baseline_file.path()).unwrap();
assert!(written.contains("baseline_amplitude"));
}
#[test]
fn record_from_nexmon_then_inspect_and_replay() {
// build a small Nexmon record dump (64-subcarrier, the default profile)
let mut dump = Vec::new();
for k in 0..6u64 {
let rec = NexmonRecord {
subcarrier_count: 64,
channel: 36,
bandwidth_mhz: 80,
rssi_dbm: Some(-60 - k as i16),
noise_floor_dbm: Some(-92),
timestamp_ns: 1_000 + k * 50_000_000,
i_values: (0..64).map(|s| (s as f32 % 3.0) - 1.0).collect(),
q_values: (0..64).map(|s| (s as f32 % 5.0) * 0.1).collect(),
};
dump.extend(encode_record(&rec).unwrap());
}
let dump_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(dump_file.path(), &dump).unwrap();
let cap_file = tempfile::NamedTempFile::new().unwrap();
let out = run(|o| {
record_from_nexmon(
o,
dump_file.path().to_str().unwrap(),
cap_file.path().to_str().unwrap(),
"nexmon-rec",
3,
)
});
assert!(out.contains("recorded 6 frame(s)"), "{out}");
// the produced capture is a real .rvcsi the other commands can read
let summary = run(|o| inspect(o, cap_file.path().to_str().unwrap(), false));
assert!(summary.contains("frames : 6"));
assert!(summary.contains("source : nexmon-rec"));
let replayed = run(|o| replay(o, cap_file.path().to_str().unwrap(), false, None));
assert!(replayed.contains("-- 6 frame(s)"));
}
#[test]
fn nexmon_pcap_record_and_inspect_roundtrip() {
use rvcsi_adapter_nexmon::NexmonCsiHeader;
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz
let nsub = 256u16;
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..8u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|s| (s as i16 % 5 + k as i16) as f32).collect();
(
1_000_000_000 + k * 50_000_000,
NexmonCsiHeader {
rssi_dbm: -55 - k as i16,
fctl: 8,
src_mac: [0, 1, 2, 3, 4, 5],
seq_cnt: k as u16,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 0x4345,
channel: 0,
bandwidth_mhz: 0,
is_5ghz: false,
subcarrier_count: nsub,
},
i,
q,
)
})
.collect();
let pcap_bytes = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap();
let pcap_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(pcap_file.path(), &pcap_bytes).unwrap();
let pcap_path = pcap_file.path().to_str().unwrap();
// inspect-nexmon (human + json) — chip_ver 0x4345 resolves to the BCM43455c0
// (the Raspberry Pi 3B+/4/400/5 chip)
let human = run(|o| inspect_nexmon(o, pcap_path, None, false));
assert!(human.contains("CSI frames : 8"), "{human}");
assert!(human.contains("channels : [36]"));
assert!(human.contains("0x4345"));
assert!(human.contains("chip : bcm43455c0"), "{human}");
let j = run(|o| inspect_nexmon(o, pcap_path, None, true));
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
assert_eq!(v["csi_frame_count"], 8);
assert_eq!(v["bandwidths_mhz"][0], 80);
assert_eq!(v["detected_chip"], "bcm43455c0");
assert_eq!(v["chip_names"][0], "bcm43455c0");
// record --source nexmon-pcap --chip pi5 -> .rvcsi; the 256-sc VHT80 ch36
// frames all fit a Raspberry Pi 5 (BCM43455c0)
let cap_file = tempfile::NamedTempFile::new().unwrap();
let cap_path = cap_file.path().to_str().unwrap();
let out = run(|o| record_from_nexmon_pcap(o, pcap_path, cap_path, "nx-pcap", 3, None, Some("pi5")));
assert!(out.contains("recorded 8 frame(s)") && out.contains("chip pi5"), "{out}");
let summary = run(|o| inspect(o, cap_path, false));
assert!(summary.contains("frames : 8"));
assert!(summary.contains("source : nx-pcap"));
assert!(summary.contains("channels : [36]"));
assert!(summary.contains("pi5"), "{summary}"); // the Pi 5 profile was stamped on the capture
// --chip pizero2w (2.4 GHz only, ≤128 sc) drops every 256-sc frame
let cap2 = tempfile::NamedTempFile::new().unwrap();
let out2 = run(|o| record_from_nexmon_pcap(o, pcap_path, cap2.path().to_str().unwrap(), "z", 0, None, Some("pizero2w")));
assert!(out2.contains("recorded 0 frame(s)"), "{out2}");
// unknown --chip is an error
let mut buf = Vec::new();
assert!(record_from_nexmon_pcap(&mut buf, pcap_path, cap_path, "x", 0, None, Some("not-a-chip")).is_err());
}
#[test]
fn nexmon_chips_listing_includes_pi5() {
let human = run(|o| nexmon_chips_cmd(o, false));
assert!(human.contains("bcm43455c0"), "{human}");
assert!(human.contains("pi5"), "{human}");
assert!(human.to_lowercase().contains("raspberry pi"), "{human}");
let j = run(|o| nexmon_chips_cmd(o, true));
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
let chips = v["chips"].as_array().unwrap();
assert!(chips.iter().any(|c| c["slug"] == "bcm43455c0"));
let pis = v["raspberry_pi_models"].as_array().unwrap();
let pi5 = pis.iter().find(|m| m["slug"] == "pi5").expect("pi5 in listing");
assert_eq!(pi5["chip"], "bcm43455c0");
assert_eq!(pi5["csi_supported"], true);
}
#[test]
fn decode_chanspec_command() {
let out = run(|o| decode_chanspec_cmd(o, "0xe024", false)); // 5G | BW80(0x2000) | ch36 ... 0xe024 = 0xc000|0x2000|0x24
assert!(out.contains("channel 36"), "{out}");
assert!(out.contains("80 MHz"));
assert!(out.contains("5 GHz"));
let out = run(|o| decode_chanspec_cmd(o, "4102", false)); // 0x1006 = BW20(0x1000)|ch6
assert!(out.contains("channel 6"));
assert!(out.contains("2.4 GHz"));
let j = run(|o| decode_chanspec_cmd(o, "0x1006", true));
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
assert_eq!(v["channel"], 6);
// bad input errors cleanly
let mut buf = Vec::new();
assert!(decode_chanspec_cmd(&mut buf, "0xZZZZ", false).is_err());
assert!(decode_chanspec_cmd(&mut buf, "not-a-number", false).is_err());
}
#[test]
fn errors_on_missing_capture() {
let mut buf = Vec::new();
assert!(inspect(&mut buf, "/no/such/file.rvcsi", false).is_err());
assert!(replay(&mut buf, "/no/such/file.rvcsi", false, None).is_err());
assert!(events(&mut buf, "/no/such/file.rvcsi", false).is_err());
assert!(calibrate(&mut buf, "/no/such/file.rvcsi", None).is_err());
assert!(record_from_nexmon(&mut buf, "/no/x.bin", "/tmp/y.rvcsi", "s", 0).is_err());
assert!(record_from_nexmon_pcap(&mut buf, "/no/x.pcap", "/tmp/y.rvcsi", "s", 0, None, None).is_err());
assert!(inspect_nexmon(&mut buf, "/no/such/file.pcap", None, false).is_err());
}
}
+202
View File
@@ -0,0 +1,202 @@
//! `rvcsi` — the rvCSI command-line tool (ADR-095 FR7).
//!
//! Subcommands: `inspect`, `replay`, `stream`, `events`, `health`, `calibrate`,
//! `export`. Long-running capture / WebSocket streaming live in the (not-yet-
//! shipped) `rvcsi-daemon`; this CLI works against `.rvcsi` capture files and
//! Nexmon record dumps.
mod commands;
use std::io::{self, Write};
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Transcode a Nexmon source into a `.rvcsi` capture (validating each frame).
Record {
/// Input format: `nexmon` (a buffer of "rvCSI Nexmon records", the napi-c
/// shim format) or `nexmon-pcap` (a real nexmon_csi libpcap capture,
/// `tcpdump -i wlan0 dst port 5500 -w csi.pcap`).
#[arg(long, default_value = "nexmon")]
source: String,
/// Path to the input (`.bin` of records, or a `.pcap`).
#[arg(long = "in")]
input: String,
/// Path to write the `.rvcsi` capture file.
#[arg(long = "out")]
output: String,
/// Source id to stamp on the capture.
#[arg(long, default_value = "nexmon")]
source_id: String,
/// Session id for the capture.
#[arg(long, default_value_t = 0)]
session: u64,
/// CSI UDP port (for `--source nexmon-pcap`; defaults to 5500).
#[arg(long)]
port: Option<u16>,
/// Validate against a specific chip / Raspberry Pi model — e.g. `pi5`,
/// `pi4`, `pi3b+`, `pizero2w`, `bcm43455c0`, `bcm4366c0` — dropping
/// frames that don't fit it. Default: permissive (any subcarrier count).
#[arg(long)]
chip: Option<String>,
},
/// List the Broadcom/Cypress chips nexmon_csi runs on + the Raspberry Pi models (incl. Pi 5).
NexmonChips {
/// Emit JSON instead of a human listing.
#[arg(long)]
json: bool,
},
/// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...).
InspectNexmon {
/// Path to a nexmon_csi `.pcap` capture.
path: String,
/// CSI UDP port (defaults to 5500).
#[arg(long)]
port: Option<u16>,
/// Emit machine-readable JSON instead of a human summary.
#[arg(long)]
json: bool,
},
/// Decode a Broadcom d11ac chanspec word (hex `0x…` or decimal).
DecodeChanspec {
/// The chanspec value, e.g. `0xe024` or `57380`.
chanspec: String,
/// Emit JSON instead of a human line.
#[arg(long)]
json: bool,
},
/// Summarize a `.rvcsi` capture file (frame count, channels, quality, ...).
Inspect {
/// Path to a `.rvcsi` capture file.
path: String,
/// Emit machine-readable JSON instead of a human summary.
#[arg(long)]
json: bool,
},
/// Replay a `.rvcsi` capture, emitting one line per frame.
Replay {
/// Path to a `.rvcsi` capture file.
path: String,
/// Emit each frame as a full JSON object instead of a compact line.
#[arg(long)]
json: bool,
/// Stop after this many frames.
#[arg(long)]
limit: Option<usize>,
/// Real-time pacing multiplier. Accepted for compatibility but not
/// enforced by the CLI (the `rvcsi-daemon` paces real-time replay);
/// a value other than `1.0` is noted on stderr.
#[arg(long, default_value_t = 1.0)]
speed: f32,
},
/// Stream frames from a source to stdout as JSON lines (a v0 stand-in for
/// the daemon's WebSocket output). Currently supports `.rvcsi` files via `--in`.
Stream {
/// Path to a `.rvcsi` capture file to stream.
#[arg(long = "in")]
input: String,
/// Output format (only `json` is supported in this build).
#[arg(long, default_value = "json")]
format: String,
/// WebSocket port. Accepted but not served by the CLI — needs `rvcsi-daemon`.
#[arg(long)]
port: Option<u16>,
},
/// Replay a capture through the DSP + event pipeline and print the events.
Events {
/// Path to a `.rvcsi` capture file.
path: String,
/// Emit events as JSON instead of compact lines.
#[arg(long)]
json: bool,
},
/// Open a source, drain it, and print its `SourceHealth` as JSON.
Health {
/// Source slug: `file`, `replay`, `nexmon` (offline); `esp32`/`intel`/`atheros` need the daemon.
#[arg(long)]
source: String,
/// Path / interface for the source (required for `file`/`replay`/`nexmon`).
#[arg(long)]
target: Option<String>,
},
/// Learn a v0 baseline (per-subcarrier mean amplitude) from a capture.
Calibrate {
/// Path to a `.rvcsi` capture file.
#[arg(long = "in")]
input: String,
/// Write the baseline JSON here instead of stdout.
#[arg(long = "out")]
output: Option<String>,
},
/// Export data derived from a capture.
Export {
#[command(subcommand)]
target: ExportTarget,
},
}
#[derive(Subcommand)]
enum ExportTarget {
/// Window a capture and store each window's embedding into a JSONL RF-memory file.
Ruvector(ExportRuvector),
}
#[derive(Args)]
struct ExportRuvector {
/// Path to a `.rvcsi` capture file.
#[arg(long = "in")]
input: String,
/// Path to the output JSONL RF-memory file.
#[arg(long = "out")]
output: String,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let stdout = io::stdout();
let mut out = stdout.lock();
match cli.command {
Command::Record { source, input, output, source_id, session, port, chip } => match source.as_str() {
"nexmon" => commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)?,
"nexmon-pcap" => commands::record_from_nexmon_pcap(
&mut out, &input, &output, &source_id, session, port, chip.as_deref(),
)?,
other => anyhow::bail!("unknown --source `{other}` (expected `nexmon` or `nexmon-pcap`)"),
},
Command::NexmonChips { json } => commands::nexmon_chips_cmd(&mut out, json)?,
Command::InspectNexmon { path, port, json } => commands::inspect_nexmon(&mut out, &path, port, json)?,
Command::DecodeChanspec { chanspec, json } => commands::decode_chanspec_cmd(&mut out, &chanspec, json)?,
Command::Inspect { path, json } => commands::inspect(&mut out, &path, json)?,
Command::Replay { path, json, limit, speed } => {
if (speed - 1.0).abs() > f32::EPSILON {
eprintln!("note: --speed {speed} is not enforced by the CLI; replaying as fast as possible");
}
commands::replay(&mut out, &path, json, limit)?;
}
Command::Stream { input, format, port } => {
if format != "json" {
anyhow::bail!("unsupported --format `{format}` (only `json` is available in this build)");
}
if let Some(p) = port {
eprintln!("note: --port {p} (WebSocket) needs the rvcsi-daemon; streaming JSON lines to stdout instead");
}
commands::replay(&mut out, &input, true, None)?;
}
Command::Events { path, json } => commands::events(&mut out, &path, json)?,
Command::Health { source, target } => commands::health(&mut out, &source, target.as_deref())?,
Command::Calibrate { input, output } => commands::calibrate(&mut out, &input, output.as_deref())?,
Command::Export { target } => match target {
ExportTarget::Ruvector(a) => commands::export_ruvector(&mut out, &a.input, &a.output)?,
},
}
out.flush()?;
Ok(())
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "rvcsi-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI core — normalized CsiFrame/CsiWindow/CsiEvent schema, AdapterProfile, CsiSource trait, validation pipeline (ADR-095, ADR-096)"
repository.workspace = true
keywords = ["wifi", "csi", "rf-sensing", "rvcsi"]
categories = ["science"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+293
View File
@@ -0,0 +1,293 @@
//! Source adapters — the [`CsiSource`] plugin trait (ADR-095 D15) plus the
//! [`AdapterProfile`] capability descriptor and [`SourceConfig`] open params.
use serde::{Deserialize, Serialize};
use crate::error::RvcsiError;
use crate::frame::CsiFrame;
use crate::ids::SessionId;
/// Which family of source produced a frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AdapterKind {
/// A recorded `.rvcsi` capture file.
File,
/// Deterministic replay of a capture session.
Replay,
/// Nexmon CSI (via the isolated C shim).
Nexmon,
/// ESP32 CSI over serial/UDP.
Esp32,
/// Intel `iwlwifi` CSI tool logs.
Intel,
/// Atheros CSI tool logs.
Atheros,
/// An in-memory / synthetic source (tests, simulation).
Synthetic,
}
impl AdapterKind {
/// Stable lower-case slug (`"file"`, `"nexmon"`, ...).
pub fn slug(self) -> &'static str {
match self {
AdapterKind::File => "file",
AdapterKind::Replay => "replay",
AdapterKind::Nexmon => "nexmon",
AdapterKind::Esp32 => "esp32",
AdapterKind::Intel => "intel",
AdapterKind::Atheros => "atheros",
AdapterKind::Synthetic => "synthetic",
}
}
}
impl core::fmt::Display for AdapterKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.slug())
}
}
/// Capability descriptor for a source — used by validation to bound frames and
/// by health checks to flag unsupported firmware/driver state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdapterProfile {
/// Adapter family.
pub adapter_kind: AdapterKind,
/// Radio chip, if known (`"BCM43455c0"`, `"ESP32-S3"`, ...).
pub chip: Option<String>,
/// Firmware version string, if known.
pub firmware_version: Option<String>,
/// Driver version string, if known.
pub driver_version: Option<String>,
/// Channels the source can capture on.
pub supported_channels: Vec<u16>,
/// Bandwidths (MHz) the source supports.
pub supported_bandwidths_mhz: Vec<u16>,
/// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`).
pub expected_subcarrier_counts: Vec<u16>,
/// Whether live capture is possible (false for files/replay).
pub supports_live_capture: bool,
/// Whether frame injection is possible.
pub supports_injection: bool,
/// Whether monitor mode is available.
pub supports_monitor_mode: bool,
}
impl AdapterProfile {
/// A permissive profile for file/replay/synthetic sources: any channel,
/// any bandwidth, any subcarrier count, no live capabilities.
pub fn offline(adapter_kind: AdapterKind) -> Self {
AdapterProfile {
adapter_kind,
chip: None,
firmware_version: None,
driver_version: None,
supported_channels: Vec::new(),
supported_bandwidths_mhz: Vec::new(),
expected_subcarrier_counts: Vec::new(),
supports_live_capture: false,
supports_injection: false,
supports_monitor_mode: false,
}
}
/// A typical ESP32-S3 HT20 CSI profile (192 raw subcarriers on HT40,
/// 64 on HT20 — both listed; channels 113, 2.4 GHz).
pub fn esp32_default() -> Self {
AdapterProfile {
adapter_kind: AdapterKind::Esp32,
chip: Some("ESP32-S3".to_string()),
firmware_version: None,
driver_version: None,
supported_channels: (1..=13).collect(),
supported_bandwidths_mhz: vec![20, 40],
expected_subcarrier_counts: vec![64, 128, 192],
supports_live_capture: true,
supports_injection: false,
supports_monitor_mode: false,
}
}
/// A typical Nexmon (BCM43455c0) CSI profile: 802.11ac, 20/40/80 MHz.
pub fn nexmon_default() -> Self {
AdapterProfile {
adapter_kind: AdapterKind::Nexmon,
chip: Some("BCM43455c0".to_string()),
firmware_version: None,
driver_version: None,
supported_channels: vec![1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161],
supported_bandwidths_mhz: vec![20, 40, 80],
expected_subcarrier_counts: vec![64, 128, 256],
supports_live_capture: true,
supports_injection: true,
supports_monitor_mode: true,
}
}
/// `true` if `count` is acceptable for this profile (always true when the
/// expected list is empty, e.g. offline sources).
pub fn accepts_subcarrier_count(&self, count: u16) -> bool {
self.expected_subcarrier_counts.is_empty()
|| self.expected_subcarrier_counts.contains(&count)
}
/// `true` if `channel` is acceptable (always true when the list is empty).
pub fn accepts_channel(&self, channel: u16) -> bool {
self.supported_channels.is_empty() || self.supported_channels.contains(&channel)
}
}
/// Health snapshot for a source (returned by [`CsiSource::health`] and the
/// `rvcsi health` CLI / `rvcsi_health_report` MCP tool).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceHealth {
/// `true` while the source is producing frames.
pub connected: bool,
/// Frames delivered since the session started.
pub frames_delivered: u64,
/// Frames rejected by validation since the session started.
pub frames_rejected: u64,
/// Optional human-readable status / last error.
pub status: Option<String>,
}
impl SourceHealth {
/// A "just opened, nothing yet" snapshot.
pub fn fresh(connected: bool) -> Self {
SourceHealth {
connected,
frames_delivered: 0,
frames_rejected: 0,
status: None,
}
}
}
/// Parameters for opening a source (mirrors the TS SDK `RvCsi.open(...)` shape).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceConfig {
/// Source slug: `"file"`, `"replay"`, `"nexmon"`, `"esp32"`, `"intel"`, `"atheros"`.
pub source: String,
/// Network interface (`"wlan0"`), serial port (`"/dev/ttyUSB0"`), or file path.
#[serde(default)]
pub target: Option<String>,
/// WiFi channel (live sources only).
#[serde(default)]
pub channel: Option<u16>,
/// Bandwidth in MHz (live sources only).
#[serde(default)]
pub bandwidth_mhz: Option<u16>,
/// Replay speed multiplier (`1.0` = real time); replay source only.
#[serde(default)]
pub replay_speed: Option<f32>,
/// Free-form adapter-specific options.
#[serde(default)]
pub options_json: Option<String>,
}
impl SourceConfig {
/// Build a config for the given source slug with no other options set.
pub fn new(source: impl Into<String>) -> Self {
SourceConfig {
source: source.into(),
target: None,
channel: None,
bandwidth_mhz: None,
replay_speed: None,
options_json: None,
}
}
/// Builder: set the target (iface/port/path).
pub fn target(mut self, t: impl Into<String>) -> Self {
self.target = Some(t.into());
self
}
/// Builder: set the channel.
pub fn channel(mut self, c: u16) -> Self {
self.channel = Some(c);
self
}
/// Builder: set the bandwidth.
pub fn bandwidth_mhz(mut self, b: u16) -> Self {
self.bandwidth_mhz = Some(b);
self
}
}
/// The plugin trait every CSI source implements.
///
/// Object-safe so the runtime can hold `Box<dyn CsiSource>`. Adapters produce
/// frames with `validation = Pending`; the runtime runs [`crate::validate_frame`]
/// before exposing anything.
pub trait CsiSource: Send {
/// The source's capability descriptor.
fn profile(&self) -> &AdapterProfile;
/// The capture session id this source is bound to.
fn session_id(&self) -> SessionId;
/// Stable source id for logs / RuVector records.
fn source_id(&self) -> &crate::ids::SourceId;
/// Pull the next frame. `Ok(None)` signals end-of-stream (file exhausted,
/// replay finished). Live sources block until a frame is available or
/// return an [`RvcsiError::Adapter`] on disconnect.
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError>;
/// Current health snapshot.
fn health(&self) -> SourceHealth;
/// Stop the source and release resources. Default: no-op.
fn stop(&mut self) -> Result<(), RvcsiError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_profile_accepts_anything() {
let p = AdapterProfile::offline(AdapterKind::File);
assert!(p.accepts_subcarrier_count(57));
assert!(p.accepts_channel(999));
assert!(!p.supports_live_capture);
}
#[test]
fn esp32_profile_bounds() {
let p = AdapterProfile::esp32_default();
assert!(p.accepts_subcarrier_count(64));
assert!(!p.accepts_subcarrier_count(57));
assert!(p.accepts_channel(6));
assert!(!p.accepts_channel(36));
assert!(p.supports_live_capture);
}
#[test]
fn source_config_builder() {
let c = SourceConfig::new("nexmon").target("wlan0").channel(6).bandwidth_mhz(20);
assert_eq!(c.source, "nexmon");
assert_eq!(c.target.as_deref(), Some("wlan0"));
assert_eq!(c.channel, Some(6));
let json = serde_json::to_string(&c).unwrap();
assert_eq!(serde_json::from_str::<SourceConfig>(&json).unwrap(), c);
}
#[test]
fn adapter_kind_slug_display() {
assert_eq!(AdapterKind::Nexmon.slug(), "nexmon");
assert_eq!(AdapterKind::Esp32.to_string(), "esp32");
}
#[test]
fn health_fresh() {
let h = SourceHealth::fresh(true);
assert!(h.connected);
assert_eq!(h.frames_delivered, 0);
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Error type for the rvCSI runtime.
use thiserror::Error;
use crate::validation::ValidationError;
/// Errors surfaced by the rvCSI core, adapters, DSP and event pipeline.
///
/// Parser failures are structured (never panics, never raw pointers across
/// boundaries — ADR-095 D6). A `Validation` error means a frame was *rejected*;
/// a *degraded* frame is not an error and is returned normally with reduced
/// `quality_score`.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RvcsiError {
/// A source/adapter could not be opened or talked to.
#[error("adapter '{kind}' failed: {message}")]
Adapter {
/// The adapter kind (`"file"`, `"nexmon"`, `"esp32"`, ...).
kind: String,
/// Human-readable detail.
message: String,
},
/// A raw byte buffer could not be parsed into a frame.
#[error("parse error at offset {offset}: {message}")]
Parse {
/// Byte offset where parsing failed (best effort).
offset: usize,
/// Human-readable detail.
message: String,
},
/// A frame failed validation and was rejected.
#[error("frame rejected: {0}")]
Validation(#[from] ValidationError),
/// A configuration value was out of range or inconsistent.
#[error("invalid configuration: {0}")]
Config(String),
/// An I/O error (file capture, replay, WebSocket, ...).
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// Serialization / deserialization error (JSON capture sidecars, RuVector export).
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
/// The requested operation is not supported by this source/adapter.
#[error("unsupported: {0}")]
Unsupported(String),
}
impl RvcsiError {
/// Convenience constructor for adapter errors.
pub fn adapter(kind: impl Into<String>, message: impl Into<String>) -> Self {
RvcsiError::Adapter {
kind: kind.into(),
message: message.into(),
}
}
/// Convenience constructor for parse errors.
pub fn parse(offset: usize, message: impl Into<String>) -> Self {
RvcsiError::Parse {
offset,
message: message.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_messages_are_useful() {
let e = RvcsiError::adapter("nexmon", "device /dev/wlan0 not in monitor mode");
assert!(e.to_string().contains("nexmon"));
assert!(e.to_string().contains("monitor mode"));
let e = RvcsiError::parse(12, "frame length 0");
assert!(e.to_string().contains("offset 12"));
}
}
+189
View File
@@ -0,0 +1,189 @@
//! The [`CsiEvent`] aggregate — semantic interpretation of one or more windows.
use serde::{Deserialize, Serialize};
use crate::ids::{EventId, SessionId, SourceId, WindowId};
/// Kinds of event the runtime emits (ADR-095 FR5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CsiEventKind {
/// Presence appeared in the sensed space.
PresenceStarted,
/// Presence ended.
PresenceEnded,
/// Motion above threshold detected.
MotionDetected,
/// Motion fell back to baseline.
MotionSettled,
/// The learned baseline shifted (re-calibration may be warranted).
BaselineChanged,
/// Signal quality dropped below a usable threshold.
SignalQualityDropped,
/// The source disconnected.
DeviceDisconnected,
/// A candidate breathing-rate observation (when signal quality permits).
BreathingCandidate,
/// A significant unexplained deviation.
AnomalyDetected,
/// Calibration is required before detection can be trusted.
CalibrationRequired,
}
impl CsiEventKind {
/// Stable lower-case slug used in logs and the SDK (`"presence_started"`...).
pub fn slug(self) -> &'static str {
match self {
CsiEventKind::PresenceStarted => "presence_started",
CsiEventKind::PresenceEnded => "presence_ended",
CsiEventKind::MotionDetected => "motion_detected",
CsiEventKind::MotionSettled => "motion_settled",
CsiEventKind::BaselineChanged => "baseline_changed",
CsiEventKind::SignalQualityDropped => "signal_quality_dropped",
CsiEventKind::DeviceDisconnected => "device_disconnected",
CsiEventKind::BreathingCandidate => "breathing_candidate",
CsiEventKind::AnomalyDetected => "anomaly_detected",
CsiEventKind::CalibrationRequired => "calibration_required",
}
}
}
/// A detected event with confidence and the evidence windows that justify it.
///
/// Invariant: `evidence_window_ids` is non-empty and `0.0 <= confidence <= 1.0`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiEvent {
/// Event id.
pub event_id: EventId,
/// What happened.
pub kind: CsiEventKind,
/// Owning session.
pub session_id: SessionId,
/// Source that produced the evidence.
pub source_id: SourceId,
/// When the event was detected (ns).
pub timestamp_ns: u64,
/// Confidence in `[0.0, 1.0]`.
pub confidence: f32,
/// Windows that justify this event (at least one).
pub evidence_window_ids: Vec<WindowId>,
/// Calibration version detection ran against, if any.
pub calibration_version: Option<String>,
/// Free-form JSON metadata (motion energy, estimated rate, ...).
pub metadata_json: String,
}
/// Why a [`CsiEvent`] is malformed.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum EventError {
/// No evidence window referenced.
#[error("event has no evidence window")]
NoEvidence,
/// `confidence` escaped `[0, 1]`.
#[error("confidence {0} out of [0,1]")]
ConfidenceOutOfRange(f32),
}
impl CsiEvent {
/// Minimal constructor; sets `metadata_json` to `"{}"`.
pub fn new(
event_id: EventId,
kind: CsiEventKind,
session_id: SessionId,
source_id: SourceId,
timestamp_ns: u64,
confidence: f32,
evidence_window_ids: Vec<WindowId>,
) -> Self {
CsiEvent {
event_id,
kind,
session_id,
source_id,
timestamp_ns,
confidence,
evidence_window_ids,
calibration_version: None,
metadata_json: "{}".to_string(),
}
}
/// Attach a calibration version.
pub fn with_calibration(mut self, version: impl Into<String>) -> Self {
self.calibration_version = Some(version.into());
self
}
/// Attach metadata (any serializable value).
pub fn with_metadata<T: Serialize>(mut self, meta: &T) -> Result<Self, serde_json::Error> {
self.metadata_json = serde_json::to_string(meta)?;
Ok(self)
}
/// Check the aggregate invariant.
pub fn validate(&self) -> Result<(), EventError> {
if self.evidence_window_ids.is_empty() {
return Err(EventError::NoEvidence);
}
if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() {
return Err(EventError::ConfidenceOutOfRange(self.confidence));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs_are_stable() {
assert_eq!(CsiEventKind::PresenceStarted.slug(), "presence_started");
assert_eq!(CsiEventKind::AnomalyDetected.slug(), "anomaly_detected");
}
#[test]
fn requires_evidence_and_bounded_confidence() {
let mut e = CsiEvent::new(
EventId(0),
CsiEventKind::MotionDetected,
SessionId(0),
SourceId::from("t"),
1_000,
0.7,
vec![WindowId(3)],
);
assert!(e.validate().is_ok());
e.evidence_window_ids.clear();
assert_eq!(e.validate(), Err(EventError::NoEvidence));
e.evidence_window_ids.push(WindowId(3));
e.confidence = 1.2;
assert_eq!(e.validate(), Err(EventError::ConfidenceOutOfRange(1.2)));
}
#[test]
fn metadata_and_calibration_roundtrip() {
#[derive(Serialize)]
struct M {
motion_energy: f32,
}
let e = CsiEvent::new(
EventId(1),
CsiEventKind::PresenceStarted,
SessionId(0),
SourceId::from("t"),
5,
0.9,
vec![WindowId(0)],
)
.with_calibration("livingroom@v3")
.with_metadata(&M { motion_energy: 1.25 })
.unwrap();
assert_eq!(e.calibration_version.as_deref(), Some("livingroom@v3"));
assert!(e.metadata_json.contains("1.25"));
let json = serde_json::to_string(&e).unwrap();
assert_eq!(serde_json::from_str::<CsiEvent>(&json).unwrap(), e);
}
}
+229
View File
@@ -0,0 +1,229 @@
//! The normalized [`CsiFrame`] — the FFI-safe boundary object (ADR-095 D5/D6).
use serde::{Deserialize, Serialize};
use crate::adapter::AdapterKind;
use crate::ids::{FrameId, SessionId, SourceId};
/// Outcome of the validation pipeline for a frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationStatus {
/// Not yet validated — set by adapters before [`crate::validate_frame`] runs.
/// A `Pending` frame must never cross a language boundary.
Pending,
/// Passed all checks.
Accepted,
/// Usable but with reduced confidence; carries a reason in `quality_reasons`.
Degraded,
/// Failed a hard check; quarantined when quarantine is enabled, otherwise dropped.
Rejected,
/// Reconstructed during replay or gap-recovery; timestamp monotonicity is waived.
Recovered,
}
impl ValidationStatus {
/// Whether a frame with this status may be exposed to SDK/DSP/memory/agents.
#[inline]
pub fn is_exposable(self) -> bool {
matches!(
self,
ValidationStatus::Accepted | ValidationStatus::Degraded | ValidationStatus::Recovered
)
}
}
/// One CSI observation at a timestamp, normalized across all sources.
///
/// Invariants enforced by [`crate::validate_frame`]:
/// * `i_values.len() == q_values.len() == amplitude.len() == phase.len() == subcarrier_count`
/// * all of `i_values`/`q_values`/`amplitude`/`phase` are finite
/// * `subcarrier_count` is within the source's [`crate::AdapterProfile`]
/// * `rssi_dbm`, when present, is within plausible device bounds
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiFrame {
/// Monotonic id within the session.
pub frame_id: FrameId,
/// Owning capture session.
pub session_id: SessionId,
/// Human-readable source id.
pub source_id: SourceId,
/// Which adapter produced this frame.
pub adapter_kind: AdapterKind,
/// Source timestamp in nanoseconds.
pub timestamp_ns: u64,
/// WiFi channel number.
pub channel: u16,
/// Channel bandwidth in MHz (20, 40, 80, 160).
pub bandwidth_mhz: u16,
/// Received signal strength, dBm, if reported.
pub rssi_dbm: Option<i16>,
/// Noise floor, dBm, if reported.
pub noise_floor_dbm: Option<i16>,
/// Receive-antenna index, if reported.
pub antenna_index: Option<u8>,
/// Transmit chain index, if reported.
pub tx_chain: Option<u8>,
/// Receive chain index, if reported.
pub rx_chain: Option<u8>,
/// Number of subcarriers (== length of the four vectors below).
pub subcarrier_count: u16,
/// In-phase components, one per subcarrier.
pub i_values: Vec<f32>,
/// Quadrature components, one per subcarrier.
pub q_values: Vec<f32>,
/// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier.
pub amplitude: Vec<f32>,
/// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later).
pub phase: Vec<f32>,
/// Validation outcome.
pub validation: ValidationStatus,
/// Quality / usability confidence in `[0.0, 1.0]`.
pub quality_score: f32,
/// Reasons a frame was degraded (empty when `Accepted`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quality_reasons: Vec<String>,
/// Calibration version this frame was processed against, if any.
pub calibration_version: Option<String>,
}
impl CsiFrame {
/// Build a raw (un-validated) frame from interleaved-free I/Q vectors.
///
/// `amplitude` and `phase` are derived from `i_values`/`q_values`. The
/// frame is returned with `validation = Pending` and `quality_score = 0.0`;
/// run [`crate::validate_frame`] before exposing it.
#[allow(clippy::too_many_arguments)]
pub fn from_iq(
frame_id: FrameId,
session_id: SessionId,
source_id: SourceId,
adapter_kind: AdapterKind,
timestamp_ns: u64,
channel: u16,
bandwidth_mhz: u16,
i_values: Vec<f32>,
q_values: Vec<f32>,
) -> Self {
let n = i_values.len();
let mut amplitude = Vec::with_capacity(n);
let mut phase = Vec::with_capacity(n);
for (i, q) in i_values.iter().zip(q_values.iter()) {
amplitude.push((i * i + q * q).sqrt());
phase.push(q.atan2(*i));
}
CsiFrame {
frame_id,
session_id,
source_id,
adapter_kind,
timestamp_ns,
channel,
bandwidth_mhz,
rssi_dbm: None,
noise_floor_dbm: None,
antenna_index: None,
tx_chain: None,
rx_chain: None,
subcarrier_count: n as u16,
i_values,
q_values,
amplitude,
phase,
validation: ValidationStatus::Pending,
quality_score: 0.0,
quality_reasons: Vec::new(),
calibration_version: None,
}
}
/// Builder-style setter for RSSI.
pub fn with_rssi(mut self, rssi_dbm: i16) -> Self {
self.rssi_dbm = Some(rssi_dbm);
self
}
/// Builder-style setter for noise floor.
pub fn with_noise_floor(mut self, noise_floor_dbm: i16) -> Self {
self.noise_floor_dbm = Some(noise_floor_dbm);
self
}
/// Builder-style setter for antenna / chain metadata.
pub fn with_chains(mut self, antenna: Option<u8>, tx: Option<u8>, rx: Option<u8>) -> Self {
self.antenna_index = antenna;
self.tx_chain = tx;
self.rx_chain = rx;
self
}
/// Mean amplitude across subcarriers (0.0 for an empty frame).
pub fn mean_amplitude(&self) -> f32 {
if self.amplitude.is_empty() {
0.0
} else {
self.amplitude.iter().sum::<f32>() / self.amplitude.len() as f32
}
}
/// Whether this frame may be exposed across a language boundary.
pub fn is_exposable(&self) -> bool {
self.validation.is_exposable()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> CsiFrame {
CsiFrame::from_iq(
FrameId(0),
SessionId(0),
SourceId::from("test"),
AdapterKind::File,
1_000,
6,
20,
vec![3.0, 0.0, -1.0],
vec![4.0, 2.0, 0.0],
)
}
#[test]
fn derives_amplitude_and_phase() {
let f = sample();
assert_eq!(f.subcarrier_count, 3);
assert!((f.amplitude[0] - 5.0).abs() < 1e-6); // 3-4-5 triangle
assert!((f.amplitude[1] - 2.0).abs() < 1e-6);
assert!((f.phase[0] - (4.0f32).atan2(3.0)).abs() < 1e-6);
assert_eq!(f.validation, ValidationStatus::Pending);
assert_eq!(f.quality_score, 0.0);
}
#[test]
fn builder_setters_and_mean() {
let f = sample().with_rssi(-55).with_noise_floor(-92).with_chains(Some(0), None, Some(1));
assert_eq!(f.rssi_dbm, Some(-55));
assert_eq!(f.noise_floor_dbm, Some(-92));
assert_eq!(f.antenna_index, Some(0));
assert_eq!(f.rx_chain, Some(1));
assert!((f.mean_amplitude() - (5.0 + 2.0 + 1.0) / 3.0).abs() < 1e-6);
}
#[test]
fn exposability_rules() {
assert!(!ValidationStatus::Pending.is_exposable());
assert!(!ValidationStatus::Rejected.is_exposable());
assert!(ValidationStatus::Accepted.is_exposable());
assert!(ValidationStatus::Degraded.is_exposable());
assert!(ValidationStatus::Recovered.is_exposable());
}
#[test]
fn frame_json_roundtrips() {
let f = sample().with_rssi(-60);
let json = serde_json::to_string(&f).unwrap();
let back: CsiFrame = serde_json::from_str(&json).unwrap();
assert_eq!(f, back);
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Identifier value objects.
//!
//! `FrameId`, `WindowId` and `EventId` are monotonic `u64` newtypes minted by
//! an [`IdGenerator`]. `SessionId` is also a `u64` (one per capture session).
//! `SourceId` wraps a human-readable string (`"esp32-com7"`, `"pcap:lab.pcap"`)
//! so logs and RuVector records stay legible.
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
macro_rules! u64_newtype {
($(#[$m:meta])* $name:ident) => {
$(#[$m])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct $name(pub u64);
impl $name {
/// The raw integer value.
#[inline]
pub const fn value(self) -> u64 {
self.0
}
}
impl From<u64> for $name {
#[inline]
fn from(v: u64) -> Self {
$name(v)
}
}
impl core::fmt::Display for $name {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}#{}", stringify!($name), self.0)
}
}
};
}
u64_newtype!(
/// Identifies one CSI observation within a capture session.
FrameId
);
u64_newtype!(
/// Identifies a capture session (one source + one runtime config).
SessionId
);
u64_newtype!(
/// Identifies a bounded window of frames.
WindowId
);
u64_newtype!(
/// Identifies a semantic event.
EventId
);
/// Human-readable identifier for a CSI source.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SourceId(pub String);
impl SourceId {
/// Construct from anything string-like.
pub fn new(s: impl Into<String>) -> Self {
SourceId(s.into())
}
/// Borrow the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SourceId {
fn from(s: &str) -> Self {
SourceId(s.to_string())
}
}
impl From<String> for SourceId {
fn from(s: String) -> Self {
SourceId(s)
}
}
impl core::fmt::Display for SourceId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
/// Monotonic id minter shared by a runtime instance.
///
/// Frame, window and event id spaces are independent. The generator is
/// `Send + Sync` (atomic counters) so it can be shared across the capture,
/// signal and event tasks.
#[derive(Debug, Default)]
pub struct IdGenerator {
frame: AtomicU64,
window: AtomicU64,
event: AtomicU64,
session: AtomicU64,
}
impl IdGenerator {
/// A fresh generator with all counters at zero.
pub const fn new() -> Self {
IdGenerator {
frame: AtomicU64::new(0),
window: AtomicU64::new(0),
event: AtomicU64::new(0),
session: AtomicU64::new(0),
}
}
/// Next frame id.
pub fn next_frame(&self) -> FrameId {
FrameId(self.frame.fetch_add(1, Ordering::Relaxed))
}
/// Next window id.
pub fn next_window(&self) -> WindowId {
WindowId(self.window.fetch_add(1, Ordering::Relaxed))
}
/// Next event id.
pub fn next_event(&self) -> EventId {
EventId(self.event.fetch_add(1, Ordering::Relaxed))
}
/// Next session id.
pub fn next_session(&self) -> SessionId {
SessionId(self.session.fetch_add(1, Ordering::Relaxed))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_generator_is_monotonic_and_independent() {
let g = IdGenerator::new();
assert_eq!(g.next_frame(), FrameId(0));
assert_eq!(g.next_frame(), FrameId(1));
assert_eq!(g.next_window(), WindowId(0));
assert_eq!(g.next_event(), EventId(0));
assert_eq!(g.next_frame(), FrameId(2));
assert_eq!(g.next_session(), SessionId(0));
}
#[test]
fn source_id_roundtrips_and_displays() {
let s = SourceId::from("esp32-com7");
assert_eq!(s.as_str(), "esp32-com7");
assert_eq!(s.to_string(), "esp32-com7");
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<SourceId>(&json).unwrap(), s);
}
#[test]
fn u64_newtype_display_and_serde() {
let f = FrameId(42);
assert_eq!(f.value(), 42);
assert_eq!(f.to_string(), "FrameId#42");
let json = serde_json::to_string(&f).unwrap();
assert_eq!(json, "42");
assert_eq!(serde_json::from_str::<FrameId>(&json).unwrap(), f);
}
}
+35
View File
@@ -0,0 +1,35 @@
//! # rvCSI core
//!
//! Foundation types for the rvCSI edge RF sensing runtime (ADR-095, ADR-096).
//!
//! Every CSI source is normalized into a [`CsiFrame`]; bounded sequences of
//! frames become a [`CsiWindow`]; semantic interpretations become a
//! [`CsiEvent`]. A [`CsiSource`] is the plugin trait every hardware/file/replay
//! adapter implements. Nothing crosses a language boundary (napi-rs / napi-c)
//! until [`validate_frame`] has run and the frame's [`ValidationStatus`] is
//! `Accepted` or `Degraded`.
//!
//! This crate is dependency-light (serde + thiserror only) and `no_std`-clean
//! in spirit so it can be reused from WASM later.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod adapter;
mod error;
mod event;
mod frame;
mod ids;
mod validation;
mod window;
pub use adapter::{AdapterKind, AdapterProfile, CsiSource, SourceConfig, SourceHealth};
pub use error::RvcsiError;
pub use event::{CsiEvent, CsiEventKind};
pub use frame::{CsiFrame, ValidationStatus};
pub use ids::{EventId, FrameId, IdGenerator, SessionId, SourceId, WindowId};
pub use validation::{validate_frame, QualityScore, ValidationError, ValidationPolicy};
pub use window::CsiWindow;
/// Re-exported result type for the runtime.
pub type Result<T> = core::result::Result<T, RvcsiError>;
+420
View File
@@ -0,0 +1,420 @@
//! The validation pipeline (ADR-095 D6/D13).
//!
//! [`validate_frame`] is the only door between raw adapter output and anything
//! downstream (DSP, events, the napi boundary, RuVector). It mutates a frame in
//! place: on success it sets `validation` to `Accepted` or `Degraded` and fills
//! `quality_score`; on a hard failure it returns a [`ValidationError`] and the
//! caller quarantines the frame (when quarantine is enabled) or drops it.
use serde::{Deserialize, Serialize};
use crate::adapter::AdapterProfile;
use crate::frame::{CsiFrame, ValidationStatus};
/// Tunable bounds for the validation pipeline.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValidationPolicy {
/// Minimum acceptable subcarrier count.
pub min_subcarriers: u16,
/// Maximum acceptable subcarrier count.
pub max_subcarriers: u16,
/// Plausible RSSI range, dBm (inclusive).
pub rssi_dbm_bounds: (i16, i16),
/// If `true`, a non-monotonic timestamp is a hard reject; if `false`, the
/// frame is marked [`ValidationStatus::Recovered`] and accepted.
pub strict_monotonic_time: bool,
/// If `true`, frames that fail a soft check become `Degraded` instead of
/// being rejected; if `false`, soft failures are rejected too.
pub degrade_instead_of_reject: bool,
/// Frames whose computed quality is below this become `Degraded`
/// (or rejected if `degrade_instead_of_reject` is false).
pub min_quality: f32,
}
impl Default for ValidationPolicy {
fn default() -> Self {
ValidationPolicy {
min_subcarriers: 1,
max_subcarriers: 4096,
rssi_dbm_bounds: (-110, 0),
strict_monotonic_time: false,
degrade_instead_of_reject: true,
min_quality: 0.25,
}
}
}
/// Computed usability confidence for a frame, in `[0.0, 1.0]`.
///
/// Starts at `1.0` and accrues multiplicative penalties for: out-of-range
/// (but non-fatal) RSSI, near-zero amplitude (dead subcarriers), excessive
/// amplitude spikes, and missing optional metadata that the profile implies
/// should be present.
#[derive(Debug, Clone, PartialEq)]
pub struct QualityScore {
/// The final score.
pub value: f32,
/// Human-readable reasons it was reduced (empty when `value == 1.0`).
pub reasons: Vec<String>,
}
impl QualityScore {
fn full() -> Self {
QualityScore {
value: 1.0,
reasons: Vec::new(),
}
}
fn penalize(&mut self, factor: f32, reason: impl Into<String>) {
self.value = (self.value * factor).clamp(0.0, 1.0);
self.reasons.push(reason.into());
}
}
/// Why a frame was rejected (a hard failure).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ValidationError {
/// The four parallel vectors disagree in length, or none match `subcarrier_count`.
#[error("vector length mismatch: i={i}, q={q}, amp={amp}, phase={phase}, subcarrier_count={sc}")]
LengthMismatch {
/// i_values length
i: usize,
/// q_values length
q: usize,
/// amplitude length
amp: usize,
/// phase length
phase: usize,
/// declared subcarrier_count
sc: usize,
},
/// Subcarrier count is outside `[policy.min, policy.max]` or not in the profile.
#[error("subcarrier count {count} not allowed (policy {min}..={max}, profile-allowed: {profile_ok})")]
SubcarrierCount {
/// the count
count: u16,
/// policy minimum
min: u16,
/// policy maximum
max: u16,
/// whether the profile's expected list allowed it
profile_ok: bool,
},
/// A non-finite (NaN / inf) value in one of the vectors.
#[error("non-finite value in '{vector}' at index {index}")]
NonFinite {
/// which vector
vector: &'static str,
/// index of the offending element
index: usize,
},
/// RSSI is so far out of range it's implausible (hard reject).
#[error("implausible RSSI {rssi} dBm (bounds {min}..={max})")]
ImplausibleRssi {
/// reported rssi
rssi: i16,
/// lower bound
min: i16,
/// upper bound
max: i16,
},
/// Timestamp went backwards and `strict_monotonic_time` is set.
#[error("non-monotonic timestamp: {ts} <= previous {prev}")]
NonMonotonicTime {
/// this frame's timestamp
ts: u64,
/// previous frame's timestamp
prev: u64,
},
/// Channel is not supported by the source profile.
#[error("channel {channel} not in source profile")]
UnsupportedChannel {
/// the channel
channel: u16,
},
/// Computed quality fell below `policy.min_quality` and degradation is disabled.
#[error("quality {quality} below minimum {min}")]
BelowMinQuality {
/// computed quality
quality: f32,
/// configured minimum
min: f32,
},
}
/// How implausibly far outside the bounds an RSSI must be before it's a hard
/// reject rather than a quality penalty.
const RSSI_HARD_MARGIN: i16 = 30;
/// Validate `frame` against `profile` and `policy`, mutating it in place.
///
/// `prev_timestamp_ns` is the timestamp of the previous accepted frame in the
/// same session (or `None` for the first frame); it is used for the
/// monotonicity check.
///
/// On `Ok(())` the frame's `validation` is `Accepted` / `Degraded` /
/// `Recovered` and `quality_score` is set. On `Err`, the frame's `validation`
/// has been set to `Rejected` (so a caller that ignores the error still won't
/// expose it) and the error explains why.
pub fn validate_frame(
frame: &mut CsiFrame,
profile: &AdapterProfile,
policy: &ValidationPolicy,
prev_timestamp_ns: Option<u64>,
) -> Result<(), ValidationError> {
// -- hard checks ---------------------------------------------------------
let sc = frame.subcarrier_count as usize;
if frame.i_values.len() != sc
|| frame.q_values.len() != sc
|| frame.amplitude.len() != sc
|| frame.phase.len() != sc
{
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::LengthMismatch {
i: frame.i_values.len(),
q: frame.q_values.len(),
amp: frame.amplitude.len(),
phase: frame.phase.len(),
sc,
});
}
let profile_ok = profile.accepts_subcarrier_count(frame.subcarrier_count);
if frame.subcarrier_count < policy.min_subcarriers
|| frame.subcarrier_count > policy.max_subcarriers
|| !profile_ok
{
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::SubcarrierCount {
count: frame.subcarrier_count,
min: policy.min_subcarriers,
max: policy.max_subcarriers,
profile_ok,
});
}
for (name, v) in [
("i_values", &frame.i_values),
("q_values", &frame.q_values),
("amplitude", &frame.amplitude),
("phase", &frame.phase),
] {
if let Some(idx) = v.iter().position(|x| !x.is_finite()) {
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::NonFinite {
vector: name,
index: idx,
});
}
}
if !profile.accepts_channel(frame.channel) {
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::UnsupportedChannel {
channel: frame.channel,
});
}
let (rssi_lo, rssi_hi) = policy.rssi_dbm_bounds;
if let Some(rssi) = frame.rssi_dbm {
if rssi < rssi_lo - RSSI_HARD_MARGIN || rssi > rssi_hi + RSSI_HARD_MARGIN {
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::ImplausibleRssi {
rssi,
min: rssi_lo,
max: rssi_hi,
});
}
}
let mut recovered_time = false;
if let Some(prev) = prev_timestamp_ns {
if frame.timestamp_ns <= prev {
if policy.strict_monotonic_time {
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::NonMonotonicTime {
ts: frame.timestamp_ns,
prev,
});
}
recovered_time = true;
}
}
// -- quality scoring (soft) ---------------------------------------------
let mut q = QualityScore::full();
if let Some(rssi) = frame.rssi_dbm {
if rssi < rssi_lo || rssi > rssi_hi {
q.penalize(0.6, format!("rssi {rssi} dBm outside [{rssi_lo},{rssi_hi}]"));
}
}
// dead subcarriers (amplitude ~ 0)
let dead = frame.amplitude.iter().filter(|a| **a < 1e-6).count();
if dead > 0 {
let frac = dead as f32 / sc.max(1) as f32;
q.penalize((1.0 - frac).max(0.05), format!("{dead}/{sc} dead subcarriers"));
}
// amplitude spikes (a single subcarrier >> the median magnitude)
if sc >= 3 {
let mut sorted: Vec<f32> = frame.amplitude.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let median = sorted[sc / 2].max(1e-9);
let max = *sorted.last().unwrap();
if max > median * 50.0 {
q.penalize(0.7, format!("amplitude spike: max {max:.3} vs median {median:.3}"));
}
}
// implied-but-missing metadata
if frame.rssi_dbm.is_none() {
q.penalize(0.95, "missing rssi");
}
let status = if recovered_time {
ValidationStatus::Recovered
} else if q.value < policy.min_quality {
if policy.degrade_instead_of_reject {
ValidationStatus::Degraded
} else {
frame.validation = ValidationStatus::Rejected;
return Err(ValidationError::BelowMinQuality {
quality: q.value,
min: policy.min_quality,
});
}
} else if q.reasons.is_empty() {
ValidationStatus::Accepted
} else if policy.degrade_instead_of_reject {
// soft penalties but above the floor → still acceptable, just note them
ValidationStatus::Accepted
} else {
ValidationStatus::Accepted
};
frame.validation = status;
frame.quality_score = q.value;
frame.quality_reasons = q.reasons;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::AdapterKind;
use crate::ids::{FrameId, SessionId, SourceId};
fn raw(sc: usize) -> CsiFrame {
CsiFrame::from_iq(
FrameId(0),
SessionId(0),
SourceId::from("t"),
AdapterKind::File,
1_000,
6,
20,
vec![1.0; sc],
vec![1.0; sc],
)
}
#[test]
fn clean_frame_is_accepted_with_perfect_quality() {
let mut f = raw(56).with_rssi(-55);
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
assert_eq!(f.quality_score, 1.0);
assert!(f.quality_reasons.is_empty());
assert!(f.is_exposable());
}
#[test]
fn missing_rssi_is_a_minor_penalty_not_a_reject() {
let mut f = raw(56);
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
assert!(f.quality_score < 1.0);
assert!(f.quality_reasons.iter().any(|r| r.contains("rssi")));
}
#[test]
fn length_mismatch_is_rejected() {
let mut f = raw(56);
f.q_values.pop();
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
assert!(matches!(err, ValidationError::LengthMismatch { .. }));
assert_eq!(f.validation, ValidationStatus::Rejected);
assert!(!f.is_exposable());
}
#[test]
fn non_finite_is_rejected() {
let mut f = raw(4);
f.amplitude[2] = f32::NAN;
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
assert!(matches!(err, ValidationError::NonFinite { vector: "amplitude", index: 2 }));
}
#[test]
fn subcarrier_count_must_match_profile() {
let mut f = raw(57); // ESP32 expects 64/128/192
let err = validate_frame(&mut f, &AdapterProfile::esp32_default(), &ValidationPolicy::default(), None).unwrap_err();
assert!(matches!(err, ValidationError::SubcarrierCount { count: 57, .. }));
}
#[test]
fn non_monotonic_time_is_recovered_when_lenient_rejected_when_strict() {
let mut f = raw(56).with_rssi(-50);
// lenient
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), Some(2_000)).unwrap();
assert_eq!(f.validation, ValidationStatus::Recovered);
// strict
let mut g = raw(56).with_rssi(-50);
let policy = ValidationPolicy { strict_monotonic_time: true, ..Default::default() };
let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, Some(2_000)).unwrap_err();
assert!(matches!(err, ValidationError::NonMonotonicTime { .. }));
}
#[test]
fn dead_subcarriers_degrade_quality() {
let mut f = raw(10).with_rssi(-50);
for a in f.amplitude.iter_mut().take(8) {
*a = 0.0;
}
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
assert!(f.quality_score < 0.5);
assert!(f.quality_reasons.iter().any(|r| r.contains("dead subcarriers")));
}
#[test]
fn very_low_quality_can_be_degraded_or_rejected() {
// 9/10 dead → quality ~0.1 < min_quality 0.25
let mk = || {
let mut f = raw(10).with_rssi(-50);
for a in f.amplitude.iter_mut().take(9) {
*a = 0.0;
}
f
};
let mut f = mk();
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
assert_eq!(f.validation, ValidationStatus::Degraded);
let mut g = mk();
let policy = ValidationPolicy { degrade_instead_of_reject: false, ..Default::default() };
let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, None).unwrap_err();
assert!(matches!(err, ValidationError::BelowMinQuality { .. }));
assert_eq!(g.validation, ValidationStatus::Rejected);
}
#[test]
fn implausible_rssi_is_hard_reject() {
let mut f = raw(56).with_rssi(50); // way above 0 + margin
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
assert!(matches!(err, ValidationError::ImplausibleRssi { .. }));
}
}
+174
View File
@@ -0,0 +1,174 @@
//! The [`CsiWindow`] aggregate — a bounded sequence of frames from one source.
use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, SourceId, WindowId};
/// A bounded window of frames, summarized into per-subcarrier statistics plus
/// scalar motion / presence / quality scores.
///
/// Invariants (enforced by the DSP windowing stage, [`CsiWindow::validate`]):
/// * all frames came from one `source_id` and one `session_id`
/// * `start_ns < end_ns`
/// * `0.0 <= presence_score <= 1.0` and `0.0 <= quality_score <= 1.0`
/// * `mean_amplitude.len() == phase_variance.len()`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiWindow {
/// Window id.
pub window_id: WindowId,
/// Owning session.
pub session_id: SessionId,
/// Source the frames came from.
pub source_id: SourceId,
/// Timestamp of the first frame, ns.
pub start_ns: u64,
/// Timestamp of the last frame, ns.
pub end_ns: u64,
/// Number of frames aggregated.
pub frame_count: u32,
/// Mean amplitude per subcarrier.
pub mean_amplitude: Vec<f32>,
/// Phase variance per subcarrier.
pub phase_variance: Vec<f32>,
/// Scalar motion energy (>= 0).
pub motion_energy: f32,
/// Presence score in `[0.0, 1.0]`.
pub presence_score: f32,
/// Window quality in `[0.0, 1.0]`.
pub quality_score: f32,
}
/// Reasons a [`CsiWindow`] failed its invariants.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum WindowError {
/// `start_ns >= end_ns`.
#[error("window start {start_ns} not before end {end_ns}")]
BadTimeOrder {
/// start
start_ns: u64,
/// end
end_ns: u64,
},
/// A score escaped `[0, 1]`.
#[error("score '{name}' = {value} out of [0,1]")]
ScoreOutOfRange {
/// which score
name: &'static str,
/// the value
value: f32,
},
/// `mean_amplitude` and `phase_variance` disagree in length.
#[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")]
StatLengthMismatch {
/// mean_amplitude length
a: usize,
/// phase_variance length
b: usize,
},
/// Zero frames in the window.
#[error("empty window")]
Empty,
}
impl CsiWindow {
/// Duration covered by the window, ns.
pub fn duration_ns(&self) -> u64 {
self.end_ns.saturating_sub(self.start_ns)
}
/// Number of subcarriers summarized.
pub fn subcarrier_count(&self) -> usize {
self.mean_amplitude.len()
}
/// Check the aggregate invariants.
pub fn validate(&self) -> Result<(), WindowError> {
if self.frame_count == 0 {
return Err(WindowError::Empty);
}
if self.start_ns >= self.end_ns {
return Err(WindowError::BadTimeOrder {
start_ns: self.start_ns,
end_ns: self.end_ns,
});
}
if self.mean_amplitude.len() != self.phase_variance.len() {
return Err(WindowError::StatLengthMismatch {
a: self.mean_amplitude.len(),
b: self.phase_variance.len(),
});
}
for (name, v) in [
("presence_score", self.presence_score),
("quality_score", self.quality_score),
] {
if !(0.0..=1.0).contains(&v) || !v.is_finite() {
return Err(WindowError::ScoreOutOfRange { name, value: v });
}
}
if !self.motion_energy.is_finite() || self.motion_energy < 0.0 {
return Err(WindowError::ScoreOutOfRange {
name: "motion_energy",
value: self.motion_energy,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn good() -> CsiWindow {
CsiWindow {
window_id: WindowId(0),
session_id: SessionId(0),
source_id: SourceId::from("test"),
start_ns: 1_000,
end_ns: 2_000,
frame_count: 10,
mean_amplitude: vec![1.0, 2.0, 3.0],
phase_variance: vec![0.1, 0.1, 0.2],
motion_energy: 0.5,
presence_score: 0.8,
quality_score: 0.9,
}
}
#[test]
fn valid_window_passes() {
let w = good();
assert!(w.validate().is_ok());
assert_eq!(w.duration_ns(), 1_000);
assert_eq!(w.subcarrier_count(), 3);
}
#[test]
fn rejects_bad_time_order() {
let mut w = good();
w.end_ns = w.start_ns;
assert!(matches!(w.validate(), Err(WindowError::BadTimeOrder { .. })));
}
#[test]
fn rejects_out_of_range_score() {
let mut w = good();
w.presence_score = 1.5;
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "presence_score", .. })));
let mut w = good();
w.motion_energy = -0.1;
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "motion_energy", .. })));
}
#[test]
fn rejects_stat_mismatch_and_empty() {
let mut w = good();
w.phase_variance.push(0.3);
assert!(matches!(w.validate(), Err(WindowError::StatLengthMismatch { .. })));
let mut w = good();
w.frame_count = 0;
assert!(matches!(w.validate(), Err(WindowError::Empty)));
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "rvcsi-dsp"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI DSP — reusable signal-processing stages (DC removal, phase unwrap, smoothing, Hampel, variance, baseline, motion energy, presence) (ADR-095 FR4)"
repository.workspace = true
keywords = ["wifi", "csi", "dsp", "rvcsi"]
categories = ["science"]
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+263
View File
@@ -0,0 +1,263 @@
//! Frame/window-level scalar features (ADR-095 FR4).
//!
//! These are deterministic, dependency-light feature extractors that turn
//! cleaned amplitude/quality series into the small scalar signals downstream
//! components (presence, breathing, confidence) expose. Anything labelled
//! "heuristic" is best-effort and is meant to be quality-gated by the caller.
use crate::stages::{mean, moving_average, std_dev};
/// Per-subcarrier RMS amplitude delta between two consecutive frames.
///
/// Defined as `||cur - prev||_2 / sqrt(n)`. Returns `0.0` if either slice is
/// empty or the lengths differ (a quiet zero rather than an error keeps the
/// streaming call sites simple).
pub fn motion_energy(prev_amplitude: &[f32], cur_amplitude: &[f32]) -> f32 {
if prev_amplitude.is_empty()
|| cur_amplitude.is_empty()
|| prev_amplitude.len() != cur_amplitude.len()
{
return 0.0;
}
let sum_sq: f32 = prev_amplitude
.iter()
.zip(cur_amplitude.iter())
.map(|(p, c)| {
let d = c - p;
d * d
})
.sum();
(sum_sq / prev_amplitude.len() as f32).sqrt()
}
/// Mean of [`motion_energy`] over every consecutive pair in the series.
///
/// Returns `0.0` if fewer than two amplitude vectors are supplied.
pub fn motion_energy_series(amplitudes: &[Vec<f32>]) -> f32 {
if amplitudes.len() < 2 {
return 0.0;
}
let mut acc = 0.0f32;
for w in amplitudes.windows(2) {
acc += motion_energy(&w[0], &w[1]);
}
acc / (amplitudes.len() - 1) as f32
}
/// Fixed logistic steepness for [`presence_score`].
const PRESENCE_STEEPNESS: f32 = 8.0;
/// Logistic squash of motion energy into a `[0, 1]` presence score.
///
/// Formula: `1 / (1 + exp(-(motion_energy - threshold) * k))` with a fixed
/// steepness `k = 8.0`. Monotone increasing in `motion_energy`, bounded to
/// `[0, 1]`, and exactly `0.5` when `motion_energy == threshold`.
pub fn presence_score(motion_energy: f32, threshold: f32) -> f32 {
let z = (motion_energy - threshold) * PRESENCE_STEEPNESS;
1.0 / (1.0 + (-z).exp())
}
/// Robust aggregate of per-frame quality scores in `[0, 1]`.
///
/// Computes `mean - 0.5 * std_dev` over the supplied per-frame quality scores
/// and clamps the result to `[0, 1]`. Returns `0.0` for an empty input. The
/// `-0.5*std` term penalizes windows whose quality is uneven.
pub fn confidence_score(quality_scores: &[f32]) -> f32 {
if quality_scores.is_empty() {
return 0.0;
}
(mean(quality_scores) - 0.5 * std_dev(quality_scores)).clamp(0.0, 1.0)
}
/// Minimum number of full periods of data required before [`breathing_band_estimate`]
/// will attempt anything.
const MIN_PERIODS: f32 = 2.0;
/// Low edge of the respiration band, Hz (~6 bpm).
const RESP_LO_HZ: f32 = 0.1;
/// High edge of the respiration band, Hz (~30 bpm).
const RESP_HI_HZ: f32 = 0.5;
/// Minimum normalized autocorrelation peak to accept an estimate.
const PEAK_THRESHOLD: f32 = 0.3;
/// Best-effort respiration-rate estimate, in **breaths per minute**.
///
/// Heuristic, FFT-free pipeline:
/// 1. detrend the series by subtracting a moving average,
/// 2. compute the biased autocorrelation for lags in the 0.10.5 Hz band
/// (630 bpm),
/// 3. if there is a clear dominant peak — its normalized autocorrelation
/// (peak / zero-lag) exceeds `~0.3` and it is a local maximum — return
/// `Some(60 * sample_rate_hz / best_lag)`, otherwise `None`.
///
/// Returns `None` unless there are at least two full periods of data at the
/// slowest band edge (so the caller need not pre-trim). This is **heuristic**
/// and is meant to be quality-gated by the caller; do not treat the result as
/// a medical-grade vital sign.
pub fn breathing_band_estimate(amplitude_series: &[f32], sample_rate_hz: f32) -> Option<f32> {
if sample_rate_hz <= 0.0 || amplitude_series.len() < 4 {
return None;
}
// Lag (in samples) bounds for the respiration band.
let min_lag = (sample_rate_hz / RESP_HI_HZ).floor() as usize;
let mut max_lag = (sample_rate_hz / RESP_LO_HZ).ceil() as usize;
if min_lag < 1 {
return None;
}
// Need at least MIN_PERIODS periods at the *fast* edge of the band before
// it is worth attempting anything (a shorter series cannot resolve even the
// quickest breathing rate). The slow edge is handled by clamping `max_lag`
// to half the series length below.
let needed = (MIN_PERIODS * sample_rate_hz / RESP_HI_HZ).ceil() as usize;
if amplitude_series.len() < needed.max(2 * min_lag) {
return None;
}
max_lag = max_lag.min(amplitude_series.len() / 2);
if max_lag <= min_lag {
return None;
}
// 1. Detrend: subtract a moving average whose window spans roughly one slow
// period (clamped to the series length) so the trend, not the
// oscillation, is removed.
let trend_window = ((sample_rate_hz / RESP_LO_HZ).round() as usize)
.max(3)
.min(amplitude_series.len());
let trend = moving_average(amplitude_series, trend_window);
let detrended: Vec<f32> = amplitude_series
.iter()
.zip(trend.iter())
.map(|(x, t)| x - t)
.collect();
// 2. Biased autocorrelation (divide by N for every lag).
let n = detrended.len() as f32;
let autocorr = |lag: usize| -> f32 {
let mut s = 0.0f32;
for i in lag..detrended.len() {
s += detrended[i] * detrended[i - lag];
}
s / n
};
let zero_lag = autocorr(0);
if zero_lag <= 0.0 {
return None;
}
// 3. Find the dominant local-max lag inside the band.
let mut best_lag = 0usize;
let mut best_val = f32::NEG_INFINITY;
for lag in min_lag..=max_lag {
let v = autocorr(lag);
if v > best_val {
best_val = v;
best_lag = lag;
}
}
if best_lag == 0 {
return None;
}
// Local maximum check (compare against immediate neighbours).
let left = autocorr(best_lag - 1);
let right = if best_lag < max_lag.min(detrended.len().saturating_sub(1)) {
autocorr(best_lag + 1)
} else {
f32::NEG_INFINITY
};
let is_local_max = best_val >= left && best_val >= right;
let normalized = best_val / zero_lag;
if !is_local_max || normalized < PEAK_THRESHOLD {
return None;
}
Some(60.0 * sample_rate_hz / best_lag as f32)
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32, eps: f32) {
assert!((a - b).abs() < eps, "{a} !~= {b} (eps {eps})");
}
#[test]
fn motion_energy_zero_for_identical() {
let a = vec![1.0, 2.0, 3.0];
approx(motion_energy(&a, &a), 0.0, 1e-6);
}
#[test]
fn motion_energy_positive_for_different() {
let a = vec![0.0, 0.0, 0.0];
let b = vec![1.0, 1.0, 1.0];
// diff all 1 -> sum_sq 3, /3 = 1, sqrt = 1
approx(motion_energy(&a, &b), 1.0, 1e-6);
}
#[test]
fn motion_energy_mismatch_or_empty_is_zero() {
approx(motion_energy(&[], &[1.0]), 0.0, 1e-6);
approx(motion_energy(&[1.0, 2.0], &[1.0]), 0.0, 1e-6);
}
#[test]
fn motion_energy_series_averages() {
// frames: [0,0],[1,1],[1,1] -> energies: 1.0, 0.0 -> mean 0.5
let frames = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![1.0, 1.0]];
approx(motion_energy_series(&frames), 0.5, 1e-6);
// fewer than 2 -> 0
approx(motion_energy_series(&[vec![1.0]]), 0.0, 1e-6);
approx(motion_energy_series(&[]), 0.0, 1e-6);
}
#[test]
fn presence_score_bounded_monotone_half_at_threshold() {
let t = 0.5;
approx(presence_score(t, t), 0.5, 1e-6);
let lo = presence_score(0.0, t);
let mid = presence_score(0.5, t);
let hi = presence_score(2.0, t);
assert!(lo < mid && mid < hi, "{lo} {mid} {hi}");
assert!((0.0..=1.0).contains(&lo));
assert!((0.0..=1.0).contains(&hi));
// very small / very large saturate
assert!(presence_score(-100.0, t) < 1e-3);
assert!(presence_score(100.0, t) > 1.0 - 1e-3);
}
#[test]
fn confidence_score_basic() {
approx(confidence_score(&[0.9, 0.9, 0.9]), 0.9, 1e-6); // std 0
approx(confidence_score(&[]), 0.0, 1e-6);
// uneven quality -> penalized below the mean
let c = confidence_score(&[0.2, 1.0, 0.6]);
assert!(c < 0.6, "{c}");
assert!((0.0..=1.0).contains(&c));
}
#[test]
fn breathing_estimate_detects_quarter_hz_sine() {
// 0.25 Hz sine (15 bpm) sampled at 10 Hz for 12 s -> 120 samples.
let fs = 10.0f32;
let n = 120usize;
let freq = 0.25f32;
let mut series = Vec::with_capacity(n);
// tiny deterministic "noise" via a fixed sequence
for i in 0..n {
let t = i as f32 / fs;
let noise = 0.02 * ((i as f32 * 1.7).sin());
series.push(1.0 + 0.5 * (2.0 * core::f32::consts::PI * freq * t).sin() + noise);
}
let bpm = breathing_band_estimate(&series, fs).expect("should detect a peak");
approx(bpm, 15.0, 3.0);
}
#[test]
fn breathing_estimate_none_for_short_or_noise() {
// too short
assert!(breathing_band_estimate(&[1.0, 2.0, 3.0], 10.0).is_none());
// a flat constant -> zero-lag autocorr 0 after detrend -> None
assert!(breathing_band_estimate(&vec![1.0; 200], 10.0).is_none());
// bad sample rate
assert!(breathing_band_estimate(&vec![1.0; 200], 0.0).is_none());
}
}
+52
View File
@@ -0,0 +1,52 @@
//! # rvCSI DSP — reusable signal-processing stages (ADR-095 FR4)
//!
//! `rvcsi-dsp` is the dependency-light DSP layer of the rvCSI edge RF sensing
//! runtime. It implements **FR4 of [ADR-095]** — *"reusable Rust
//! signal-processing stages"* — as a small library of deterministic primitives
//! plus a composable per-frame [`SignalPipeline`].
//!
//! The crate is split into three modules:
//!
//! * [`stages`] — pure per-vector DSP primitives operating on `&[f32]` /
//! `&mut [f32]`: [`mean`](stages::mean), [`variance`](stages::variance),
//! [`std_dev`](stages::std_dev), [`median`](stages::median),
//! [`remove_dc_offset`](stages::remove_dc_offset),
//! [`unwrap_phase`](stages::unwrap_phase),
//! [`moving_average`](stages::moving_average), [`ewma`](stages::ewma),
//! [`hampel_filter`](stages::hampel_filter) /
//! [`hampel_filter_count`](stages::hampel_filter_count),
//! [`short_window_variance`](stages::short_window_variance),
//! [`subtract_baseline`](stages::subtract_baseline). Failable stages report
//! [`DspError`](stages::DspError).
//! * [`features`] — frame/window-level scalar features:
//! [`motion_energy`](features::motion_energy) /
//! [`motion_energy_series`](features::motion_energy_series),
//! [`presence_score`](features::presence_score),
//! [`confidence_score`](features::confidence_score),
//! [`breathing_band_estimate`](features::breathing_band_estimate) (heuristic,
//! FFT-free, meant to be quality-gated by the caller).
//! * [`pipeline`] — the [`SignalPipeline`](pipeline::SignalPipeline): a tiny
//! configuration bag with a non-destructive `process_frame` step that cleans a
//! [`rvcsi_core::CsiFrame`]'s `amplitude` / `phase` vectors *after*
//! `rvcsi_core::validate_frame` has run, never touching validation state.
//!
//! Everything here is deterministic: the same input always produces the same
//! output. There are no heavy dependencies — the math is hand-rolled.
//!
//! [ADR-095]: ../../../docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod features;
pub mod pipeline;
pub mod stages;
pub use features::{
breathing_band_estimate, confidence_score, motion_energy, motion_energy_series, presence_score,
};
pub use pipeline::SignalPipeline;
pub use stages::{
ewma, hampel_filter, hampel_filter_count, mean, median, moving_average, remove_dc_offset,
short_window_variance, std_dev, subtract_baseline, unwrap_phase, variance, DspError,
};
+322
View File
@@ -0,0 +1,322 @@
//! The composable [`SignalPipeline`] (ADR-095 FR4).
//!
//! A pipeline is a small bag of configuration plus a non-destructive
//! `process_frame` step that cleans a [`CsiFrame`]'s `amplitude` / `phase`
//! vectors *after* `rvcsi_core::validate_frame` has run. It deliberately never
//! mutates `validation`, `quality_score`, or `quality_reasons` — those belong to
//! the validation stage, and a DSP cleanup pass must not silently "upgrade" or
//! "downgrade" a frame's trust state.
use rvcsi_core::CsiFrame;
use crate::stages::{hampel_filter, moving_average, remove_dc_offset, unwrap_phase};
/// Configurable signal-cleaning pipeline applied per frame.
///
/// The processing order in [`SignalPipeline::process_frame`] is fixed:
/// 1. Hampel outlier filter on `amplitude`
/// 2. centered moving-average smoothing on `amplitude`
/// 3. DC-offset removal on `amplitude` (if [`remove_dc`](Self::remove_dc))
/// 4. baseline subtraction on `amplitude` (if a learned baseline of matching
/// length is present)
/// 5. phase unwrap on `phase` (if [`unwrap_phase`](Self::unwrap_phase))
#[derive(Debug, Clone, PartialEq)]
pub struct SignalPipeline {
/// Window length for the moving-average smoothing of amplitude
/// (`0`/`1` disables smoothing).
pub smoothing_window: usize,
/// Half-window for the Hampel outlier filter on amplitude.
pub hampel_half_window: usize,
/// Outlier threshold (in robust sigmas) for the Hampel filter.
pub hampel_n_sigmas: f32,
/// Whether to unwrap the phase vector.
pub unwrap_phase: bool,
/// Whether to subtract the DC offset (mean) from the amplitude vector.
pub remove_dc: bool,
/// Optional learned per-subcarrier baseline amplitude; subtracted from
/// `amplitude` when its length matches the frame's subcarrier count.
pub baseline_amplitude: Option<Vec<f32>>,
}
impl Default for SignalPipeline {
fn default() -> Self {
SignalPipeline {
smoothing_window: 3,
hampel_half_window: 3,
hampel_n_sigmas: 3.0,
unwrap_phase: true,
remove_dc: true,
baseline_amplitude: None,
}
}
}
impl SignalPipeline {
/// Construct a pipeline with the [default](Self::default) configuration.
pub fn new() -> Self {
Self::default()
}
/// Builder-style setter for [`smoothing_window`](Self::smoothing_window).
pub fn with_smoothing_window(mut self, window: usize) -> Self {
self.smoothing_window = window;
self
}
/// Builder-style setter for the Hampel half-window.
pub fn with_hampel_half_window(mut self, half_window: usize) -> Self {
self.hampel_half_window = half_window;
self
}
/// Builder-style setter for the Hampel sigma threshold.
pub fn with_hampel_n_sigmas(mut self, n_sigmas: f32) -> Self {
self.hampel_n_sigmas = n_sigmas;
self
}
/// Builder-style setter for [`unwrap_phase`](Self::unwrap_phase).
pub fn with_unwrap_phase(mut self, on: bool) -> Self {
self.unwrap_phase = on;
self
}
/// Builder-style setter for [`remove_dc`](Self::remove_dc).
pub fn with_remove_dc(mut self, on: bool) -> Self {
self.remove_dc = on;
self
}
/// Builder-style setter for an explicit baseline amplitude vector.
pub fn with_baseline_amplitude(mut self, baseline: Option<Vec<f32>>) -> Self {
self.baseline_amplitude = baseline;
self
}
/// Clean a frame's `amplitude` and `phase` vectors in place.
///
/// See the [type docs](SignalPipeline) for the fixed processing order. This
/// method does **not** read or write `frame.validation`,
/// `frame.quality_score`, or `frame.quality_reasons`, and is a no-op for a
/// frame with `subcarrier_count == 0`. The lengths of `amplitude` and
/// `phase` are preserved.
pub fn process_frame(&self, frame: &mut CsiFrame) {
if frame.subcarrier_count == 0 || frame.amplitude.is_empty() {
return;
}
// 1. Hampel outlier rejection on amplitude.
if self.hampel_half_window > 0 {
frame.amplitude =
hampel_filter(&frame.amplitude, self.hampel_half_window, self.hampel_n_sigmas);
}
// 2. Moving-average smoothing on amplitude.
if self.smoothing_window > 1 {
frame.amplitude = moving_average(&frame.amplitude, self.smoothing_window);
}
// 3. DC-offset removal on amplitude.
if self.remove_dc {
remove_dc_offset(&mut frame.amplitude);
}
// 4. Baseline subtraction (only when lengths match).
if let Some(baseline) = &self.baseline_amplitude {
if baseline.len() == frame.amplitude.len() {
for (a, b) in frame.amplitude.iter_mut().zip(baseline.iter()) {
*a -= *b;
}
}
}
// 5. Phase unwrap.
if self.unwrap_phase {
unwrap_phase(&mut frame.phase);
}
}
/// Learn a per-subcarrier baseline amplitude from a batch of frames.
///
/// Sets [`baseline_amplitude`](Self::baseline_amplitude) to the element-wise
/// mean amplitude over the supplied frames, considering only frames whose
/// `subcarrier_count` equals the first frame's and whose `amplitude` vector
/// is non-empty. A no-op when `frames` is empty (or yields no usable frame).
pub fn learn_baseline(&mut self, frames: &[CsiFrame]) {
let Some(first) = frames.iter().find(|f| !f.amplitude.is_empty()) else {
return;
};
let n = first.amplitude.len();
let reference_count = first.subcarrier_count;
let mut acc = vec![0.0f32; n];
let mut used = 0usize;
for f in frames {
if f.subcarrier_count != reference_count || f.amplitude.len() != n {
continue;
}
for (a, &v) in acc.iter_mut().zip(f.amplitude.iter()) {
*a += v;
}
used += 1;
}
if used == 0 {
return;
}
let used_f = used as f32;
for a in acc.iter_mut() {
*a /= used_f;
}
self.baseline_amplitude = Some(acc);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{AdapterKind, FrameId, SessionId, SourceId, ValidationStatus};
fn frame_with_amplitude(amp: Vec<f32>) -> CsiFrame {
let n = amp.len();
// Build a frame from I/Q so phase/amplitude are consistent, then
// overwrite amplitude with the test fixture.
let i: Vec<f32> = amp.clone();
let q: Vec<f32> = vec![0.0; n];
let mut f = CsiFrame::from_iq(
FrameId(1),
SessionId(1),
SourceId::from("pipe-test"),
AdapterKind::Synthetic,
10_000,
6,
20,
i,
q,
);
f.amplitude = amp;
f.phase = vec![0.0; n];
// Pretend validation already ran.
f.validation = ValidationStatus::Accepted;
f.quality_score = 0.77;
f.quality_reasons = vec!["fixture".to_string()];
f
}
#[test]
fn process_frame_removes_spike_and_preserves_validation() {
let mut f = frame_with_amplitude(vec![5.0, 5.0, 5.0, 200.0, 5.0, 5.0, 5.0]);
let n_before = f.amplitude.len();
let pipe = SignalPipeline::default();
pipe.process_frame(&mut f);
assert_eq!(f.amplitude.len(), n_before);
assert_eq!(f.phase.len(), n_before);
// The huge spike must be gone: after hampel+smoothing+DC removal the
// amplitude should be near zero everywhere (constant signal -> ~0 mean).
for v in &f.amplitude {
assert!(v.abs() < 1.0, "spike not removed, residual {v}");
}
// Validation state untouched.
assert_eq!(f.validation, ValidationStatus::Accepted);
assert!((f.quality_score - 0.77).abs() < 1e-6);
assert_eq!(f.quality_reasons, vec!["fixture".to_string()]);
}
#[test]
fn process_frame_is_noop_on_empty_frame() {
let mut f = CsiFrame::from_iq(
FrameId(2),
SessionId(1),
SourceId::from("empty"),
AdapterKind::Synthetic,
1,
6,
20,
Vec::new(),
Vec::new(),
);
f.validation = ValidationStatus::Degraded;
let pipe = SignalPipeline::default();
pipe.process_frame(&mut f);
assert!(f.amplitude.is_empty());
assert!(f.phase.is_empty());
assert_eq!(f.validation, ValidationStatus::Degraded);
}
#[test]
fn unwrap_phase_can_be_disabled() {
let mut f = frame_with_amplitude(vec![1.0, 1.0, 1.0, 1.0]);
f.phase = vec![0.0, 3.0, -3.0, 0.0];
let pipe = SignalPipeline::default()
.with_unwrap_phase(false)
.with_hampel_half_window(0)
.with_smoothing_window(0)
.with_remove_dc(false);
pipe.process_frame(&mut f);
// phase left exactly as-is
assert_eq!(f.phase, vec![0.0, 3.0, -3.0, 0.0]);
// amplitude untouched too
assert_eq!(f.amplitude, vec![1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn learn_baseline_then_process_subtracts_it() {
// Three frames whose mean amplitude is [2, 4, 6, 8].
let frames = vec![
frame_with_amplitude(vec![1.0, 3.0, 5.0, 7.0]),
frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]),
frame_with_amplitude(vec![3.0, 5.0, 7.0, 9.0]),
];
let mut pipe = SignalPipeline::default()
.with_hampel_half_window(0)
.with_smoothing_window(0);
pipe.learn_baseline(&frames);
assert_eq!(pipe.baseline_amplitude, Some(vec![2.0, 4.0, 6.0, 8.0]));
// Process a frame equal to the baseline. After DC removal (mean 5 ->
// [-3,-1,1,3]) then baseline subtraction ([-3-2,-1-4,1-6,3-8] =
// [-5,-5,-5,-5]) — the point is just that it's "small" and bounded.
let mut f = frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]);
pipe.process_frame(&mut f);
assert_eq!(f.amplitude.len(), 4);
for v in &f.amplitude {
assert!(v.abs() < 10.0, "baseline-subtracted residual too large: {v}");
}
// With DC removal turned off, a frame equal to the baseline goes to
// exactly zero.
let mut pipe2 = pipe.clone();
pipe2.remove_dc = false;
let mut f2 = frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]);
pipe2.process_frame(&mut f2);
for v in &f2.amplitude {
assert!(v.abs() < 1e-5, "expected ~0, got {v}");
}
}
#[test]
fn learn_baseline_ignores_mismatched_and_empty() {
let frames = vec![
frame_with_amplitude(vec![2.0, 2.0, 2.0]),
frame_with_amplitude(vec![1.0, 2.0]), // wrong length -> ignored
frame_with_amplitude(vec![4.0, 4.0, 4.0]),
];
let mut pipe = SignalPipeline::default();
pipe.learn_baseline(&frames);
assert_eq!(pipe.baseline_amplitude, Some(vec![3.0, 3.0, 3.0]));
// empty input -> no change
let mut pipe2 = SignalPipeline::default();
pipe2.learn_baseline(&[]);
assert_eq!(pipe2.baseline_amplitude, None);
}
#[test]
fn pipeline_is_deterministic() {
let make = || frame_with_amplitude(vec![5.0, 6.0, 7.0, 50.0, 7.0, 6.0, 5.0]);
let pipe = SignalPipeline::default();
let mut a = make();
let mut b = make();
pipe.process_frame(&mut a);
pipe.process_frame(&mut b);
assert_eq!(a.amplitude, b.amplitude);
assert_eq!(a.phase, b.phase);
}
}
+394
View File
@@ -0,0 +1,394 @@
//! Pure per-vector DSP primitives (ADR-095 FR4).
//!
//! Every function here is deterministic and operates on plain `&[f32]` /
//! `&mut [f32]` slices — no allocation-heavy dependencies, no hidden state.
//! Errors are reported via [`DspError`].
use core::f32::consts::PI;
use thiserror::Error;
/// Errors produced by DSP stages that can fail.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DspError {
/// Two slices that were required to be the same length were not.
#[error("length mismatch: {a} vs {b}")]
LengthMismatch {
/// Length of the first slice.
a: usize,
/// Length of the second slice.
b: usize,
},
/// An operation that requires at least one sample received an empty slice.
#[error("empty input")]
EmptyInput,
}
/// Arithmetic mean of the slice. Returns `0.0` for an empty slice.
pub fn mean(xs: &[f32]) -> f32 {
if xs.is_empty() {
0.0
} else {
xs.iter().sum::<f32>() / xs.len() as f32
}
}
/// Population variance (divides by `n`, not `n - 1`). Returns `0.0` for an
/// empty slice.
pub fn variance(xs: &[f32]) -> f32 {
if xs.is_empty() {
return 0.0;
}
let m = mean(xs);
xs.iter().map(|x| {
let d = x - m;
d * d
}).sum::<f32>()
/ xs.len() as f32
}
/// Population standard deviation. Returns `0.0` for an empty slice.
pub fn std_dev(xs: &[f32]) -> f32 {
variance(xs).sqrt()
}
/// Median of the slice (clones and sorts internally). Returns `0.0` for an
/// empty slice. For an even count, returns the average of the two central
/// values.
pub fn median(xs: &[f32]) -> f32 {
if xs.is_empty() {
return 0.0;
}
let mut v = xs.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let n = v.len();
if n % 2 == 1 {
v[n / 2]
} else {
0.5 * (v[n / 2 - 1] + v[n / 2])
}
}
/// Subtract the mean of the slice from every element, in place.
pub fn remove_dc_offset(xs: &mut [f32]) {
let m = mean(xs);
for x in xs.iter_mut() {
*x -= m;
}
}
/// In-place 1-D phase unwrap.
///
/// Walks left→right; whenever the raw step `phase[i] - phase[i-1]` exceeds
/// `+PI` we accumulate a `-2*PI` correction, and whenever it is below `-PI`
/// we accumulate a `+2*PI` correction. The running correction is added to
/// every subsequent sample, producing a continuous series with no step larger
/// than `PI` in magnitude.
pub fn unwrap_phase(phase: &mut [f32]) {
if phase.len() < 2 {
return;
}
let mut correction = 0.0f32;
let mut prev_raw = phase[0];
// We read `phase[i]` and write `phase[i]` in the same step; an index loop
// is the clearest way to express that, hence the lint allowance.
#[allow(clippy::needless_range_loop)]
for i in 1..phase.len() {
let raw = phase[i];
let step = raw - prev_raw;
if step > PI {
correction -= 2.0 * PI;
} else if step < -PI {
correction += 2.0 * PI;
}
prev_raw = raw;
phase[i] = raw + correction;
}
}
/// Centered moving average with edge clamping (the window shrinks at the ends).
///
/// `window == 0 || window == 1` returns a plain copy. The result has the same
/// length as the input.
pub fn moving_average(xs: &[f32], window: usize) -> Vec<f32> {
if window <= 1 || xs.is_empty() {
return xs.to_vec();
}
let half = window / 2;
let n = xs.len();
let mut out = Vec::with_capacity(n);
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half + 1).min(n);
let slice = &xs[lo..hi];
out.push(mean(slice));
}
out
}
/// Exponentially-weighted moving average.
///
/// `y[0] = x[0]`, `y[i] = alpha * x[i] + (1 - alpha) * y[i-1]`. `alpha` is
/// clamped to `(0.0, 1.0]` (values `<= 0` become a tiny positive epsilon,
/// values `> 1` become `1.0`). An empty input yields an empty output.
pub fn ewma(xs: &[f32], alpha: f32) -> Vec<f32> {
if xs.is_empty() {
return Vec::new();
}
let a = if alpha > 1.0 {
1.0
} else if alpha <= 0.0 {
f32::EPSILON
} else {
alpha
};
let mut out = Vec::with_capacity(xs.len());
let mut y = xs[0];
out.push(y);
for &x in &xs[1..] {
y = a * x + (1.0 - a) * y;
out.push(y);
}
out
}
/// Hampel outlier filter.
///
/// For each index `i`, take the window `[i - half_window, i + half_window]`
/// (clamped to the slice), compute the median `m` and
/// `MAD = 1.4826 * median(|x - m|)`. If `|x[i] - m| > n_sigmas * MAD`, the
/// sample is replaced with `m`; otherwise it is kept. Returns a new `Vec` of
/// the same length.
pub fn hampel_filter(xs: &[f32], half_window: usize, n_sigmas: f32) -> Vec<f32> {
hampel_filter_count(xs, half_window, n_sigmas).0
}
/// Like [`hampel_filter`] but also reports how many samples were replaced.
pub fn hampel_filter_count(xs: &[f32], half_window: usize, n_sigmas: f32) -> (Vec<f32>, usize) {
if xs.is_empty() {
return (Vec::new(), 0);
}
let n = xs.len();
let mut out = Vec::with_capacity(n);
let mut replaced = 0usize;
for i in 0..n {
let lo = i.saturating_sub(half_window);
let hi = (i + half_window + 1).min(n);
let window = &xs[lo..hi];
let m = median(window);
let deviations: Vec<f32> = window.iter().map(|x| (x - m).abs()).collect();
let mad = 1.4826 * median(&deviations);
// When `mad == 0` (a majority of the window is identical) the test
// `dev > n_sigmas * 0` reduces to `dev > 0`, i.e. any sample that
// differs from the window median is treated as an outlier — this is the
// standard degenerate-MAD behaviour for the Hampel identifier.
if (xs[i] - m).abs() > n_sigmas * mad {
out.push(m);
replaced += 1;
} else {
out.push(xs[i]);
}
}
(out, replaced)
}
/// Sliding population variance over a centered window with edge clamping.
///
/// `window <= 1` produces an all-zero series the same length as the input
/// (a single-sample window has zero variance). The result has the same length
/// as the input.
pub fn short_window_variance(xs: &[f32], window: usize) -> Vec<f32> {
let n = xs.len();
if n == 0 {
return Vec::new();
}
if window <= 1 {
return vec![0.0; n];
}
let half = window / 2;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half + 1).min(n);
out.push(variance(&xs[lo..hi]));
}
out
}
/// Elementwise `current - baseline`. Errors if the lengths differ.
pub fn subtract_baseline(current: &[f32], baseline: &[f32]) -> Result<Vec<f32>, DspError> {
if current.len() != baseline.len() {
return Err(DspError::LengthMismatch {
a: current.len(),
b: baseline.len(),
});
}
Ok(current
.iter()
.zip(baseline.iter())
.map(|(c, b)| c - b)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32) {
assert!((a - b).abs() < 1e-5, "{a} !~= {b}");
}
#[test]
fn mean_variance_median_basic() {
let xs = [1.0, 2.0, 3.0, 4.0];
approx(mean(&xs), 2.5);
// population variance of 1..4: mean 2.5, devs^2 = 2.25,0.25,0.25,2.25 -> 5/4 = 1.25
approx(variance(&xs), 1.25);
approx(std_dev(&xs), 1.25f32.sqrt());
// even-count median: avg of 2 and 3
approx(median(&xs), 2.5);
approx(median(&[3.0, 1.0, 2.0]), 2.0);
}
#[test]
fn empty_inputs_are_zero() {
approx(mean(&[]), 0.0);
approx(variance(&[]), 0.0);
approx(std_dev(&[]), 0.0);
approx(median(&[]), 0.0);
}
#[test]
fn remove_dc_offset_centers() {
let mut xs = [1.0, 2.0, 3.0, 4.0];
remove_dc_offset(&mut xs);
approx(mean(&xs), 0.0);
approx(xs[0], -1.5);
approx(xs[3], 1.5);
}
#[test]
fn unwrap_phase_is_continuous() {
// raw: 0, 3, -3, 0. step 3->-3 is -6 < -PI so +2PI; etc.
let mut p = [0.0f32, 3.0, -3.0, 0.0];
unwrap_phase(&mut p);
for w in p.windows(2) {
assert!((w[1] - w[0]).abs() <= PI + 1e-5, "jump too big: {w:?}");
}
// first sample untouched
approx(p[0], 0.0);
}
#[test]
fn unwrap_phase_short_slices() {
let mut a: [f32; 0] = [];
unwrap_phase(&mut a);
let mut b = [1.23f32];
unwrap_phase(&mut b);
approx(b[0], 1.23);
}
#[test]
fn moving_average_window_three() {
// [1,2,3,4,5], window 3, half=1, edge clamp:
// i=0: [1,2] -> 1.5
// i=1: [1,2,3] -> 2
// i=2: [2,3,4] -> 3
// i=3: [3,4,5] -> 4
// i=4: [4,5] -> 4.5
let out = moving_average(&[1.0, 2.0, 3.0, 4.0, 5.0], 3);
assert_eq!(out.len(), 5);
approx(out[0], 1.5);
approx(out[1], 2.0);
approx(out[2], 3.0);
approx(out[3], 4.0);
approx(out[4], 4.5);
}
#[test]
fn moving_average_window_one_is_copy() {
let xs = [1.0, 2.0, 3.0];
assert_eq!(moving_average(&xs, 1), xs.to_vec());
assert_eq!(moving_average(&xs, 0), xs.to_vec());
}
#[test]
fn ewma_first_element_and_alpha_one() {
let xs = [2.0, 4.0, 8.0];
let out = ewma(&xs, 0.5);
approx(out[0], 2.0);
approx(out[1], 0.5 * 4.0 + 0.5 * 2.0); // 3.0
approx(out[2], 0.5 * 8.0 + 0.5 * 3.0); // 5.5
// alpha = 1.0 -> copy
assert_eq!(ewma(&xs, 1.0), xs.to_vec());
// clamped: alpha > 1 also a copy
assert_eq!(ewma(&xs, 5.0), xs.to_vec());
// empty
assert!(ewma(&[], 0.5).is_empty());
}
#[test]
fn hampel_replaces_spike() {
let xs = [1.0, 1.0, 1.0, 100.0, 1.0, 1.0, 1.0];
let (out, count) = hampel_filter_count(&xs, 3, 3.0);
approx(out[3], 1.0);
assert_eq!(count, 1);
// all other points unchanged
for i in [0, 1, 2, 4, 5, 6] {
approx(out[i], 1.0);
}
// hampel_filter agrees
assert_eq!(hampel_filter(&xs, 3, 3.0), out);
}
#[test]
fn hampel_clean_signal_unchanged() {
let xs = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
let (out, count) = hampel_filter_count(&xs, 2, 3.0);
assert_eq!(count, 0);
assert_eq!(out, xs.to_vec());
}
#[test]
fn hampel_empty() {
let (out, count) = hampel_filter_count(&[], 2, 3.0);
assert!(out.is_empty());
assert_eq!(count, 0);
}
#[test]
fn short_window_variance_constant_is_zero() {
let xs = [5.0; 8];
let out = short_window_variance(&xs, 3);
assert_eq!(out.len(), 8);
for v in out {
approx(v, 0.0);
}
// window 1 -> all zeros
let out2 = short_window_variance(&xs, 1);
assert_eq!(out2, vec![0.0; 8]);
assert!(short_window_variance(&[], 3).is_empty());
}
#[test]
fn short_window_variance_nonconstant() {
// [0, 0, 9], window 3, half 1:
// i=0: [0,0] var 0
// i=1: [0,0,9] mean 3, devs^2 9,9,36 -> 54/3 = 18
// i=2: [0,9] mean 4.5, devs^2 20.25,20.25 -> 40.5/2 = 20.25
let out = short_window_variance(&[0.0, 0.0, 9.0], 3);
approx(out[0], 0.0);
approx(out[1], 18.0);
approx(out[2], 20.25);
}
#[test]
fn subtract_baseline_works_and_errors() {
let c = [3.0, 5.0, 7.0];
let b = [1.0, 2.0, 3.0];
let out = subtract_baseline(&c, &b).unwrap();
assert_eq!(out, vec![2.0, 3.0, 4.0]);
let err = subtract_baseline(&c, &[1.0, 2.0]).unwrap_err();
assert_eq!(err, DspError::LengthMismatch { a: 3, b: 2 });
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "rvcsi-events"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI events — window aggregation + presence/motion/anomaly state machines producing CsiEvent (ADR-095 FR5)"
repository.workspace = true
keywords = ["wifi", "csi", "events", "rvcsi"]
categories = ["science"]
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+858
View File
@@ -0,0 +1,858 @@
//! Event detectors — small deterministic state machines over [`CsiWindow`]s.
//!
//! Every detector implements [`EventDetector`]; an [`crate::EventPipeline`]
//! runs each in turn on every closed window and concatenates the emitted
//! [`CsiEvent`]s. Detectors are intentionally tiny and side-effect-free: the
//! only state they keep is the bare minimum to debounce / hysteresis-gate, so
//! replaying the same window stream is fully deterministic.
use rvcsi_core::{CsiEvent, CsiEventKind, CsiWindow, IdGenerator, WindowId};
/// Consumes [`CsiWindow`]s and emits [`CsiEvent`]s.
pub trait EventDetector {
/// Process one window; return any events it triggers (possibly empty).
fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec<CsiEvent>;
/// Stable name for logging / inspection.
fn name(&self) -> &'static str;
}
/// Build a single-window-evidence [`CsiEvent`] (validated in debug builds).
fn make_event(
ids: &IdGenerator,
kind: CsiEventKind,
window: &CsiWindow,
timestamp_ns: u64,
confidence: f32,
) -> CsiEvent {
let evidence: Vec<WindowId> = vec![window.window_id];
let confidence = confidence.clamp(0.0, 1.0);
let event = CsiEvent::new(
ids.next_event(),
kind,
window.session_id,
window.source_id.clone(),
timestamp_ns,
confidence,
evidence,
);
debug_assert!(
event.validate().is_ok(),
"detector produced an invalid CsiEvent: {:?}",
event.validate()
);
event
}
// ---------------------------------------------------------------------------
// PresenceDetector
// ---------------------------------------------------------------------------
/// Tunables for [`PresenceDetector`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PresenceConfig {
/// Enter `Present` when `presence_score >= on_threshold` for `enter_windows` windows.
pub on_threshold: f32,
/// Exit to `Absent` when `presence_score <= off_threshold` for `exit_windows` windows.
pub off_threshold: f32,
/// Consecutive high windows required to declare presence.
pub enter_windows: u32,
/// Consecutive low windows required to declare absence.
pub exit_windows: u32,
}
impl Default for PresenceConfig {
fn default() -> Self {
// A truly quiet window has `presence_score ≈ 0.40` (the
// `WindowBuffer` logistic floor at zero motion), so `off_threshold`
// sits just above that and `on_threshold` well above it.
PresenceConfig {
on_threshold: 0.7,
off_threshold: 0.45,
enter_windows: 2,
exit_windows: 3,
}
}
}
impl PresenceConfig {
/// Validate the relationship `on_threshold > off_threshold` and positivity.
fn checked(self) -> Self {
assert!(
self.on_threshold > self.off_threshold,
"PresenceConfig requires on_threshold > off_threshold"
);
assert!(self.enter_windows >= 1 && self.exit_windows >= 1);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum PresenceState {
Absent,
Present,
}
/// Hysteresis state machine over [`CsiWindow::presence_score`].
///
/// Emits a single [`CsiEventKind::PresenceStarted`] when the score has been
/// high for `enter_windows` consecutive windows, and a single
/// [`CsiEventKind::PresenceEnded`] when it has been low for `exit_windows`
/// consecutive windows. A window that breaks the streak resets the counter.
#[derive(Debug, Clone)]
pub struct PresenceDetector {
cfg: PresenceConfig,
state: PresenceState,
streak: u32,
}
impl Default for PresenceDetector {
fn default() -> Self {
Self::new()
}
}
impl PresenceDetector {
/// New detector with default thresholds.
pub fn new() -> Self {
Self::with_config(PresenceConfig::default())
}
/// New detector with explicit config.
///
/// # Panics
/// Panics if `on_threshold <= off_threshold` or a window count is zero.
pub fn with_config(cfg: PresenceConfig) -> Self {
PresenceDetector {
cfg: cfg.checked(),
state: PresenceState::Absent,
streak: 0,
}
}
}
impl EventDetector for PresenceDetector {
fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec<CsiEvent> {
let p = window.presence_score;
match self.state {
PresenceState::Absent => {
if p >= self.cfg.on_threshold {
self.streak += 1;
if self.streak >= self.cfg.enter_windows {
self.state = PresenceState::Present;
self.streak = 0;
return vec![make_event(
ids,
CsiEventKind::PresenceStarted,
window,
window.end_ns,
p,
)];
}
} else {
self.streak = 0;
}
}
PresenceState::Present => {
if p <= self.cfg.off_threshold {
self.streak += 1;
if self.streak >= self.cfg.exit_windows {
self.state = PresenceState::Absent;
self.streak = 0;
return vec![make_event(
ids,
CsiEventKind::PresenceEnded,
window,
window.end_ns,
(1.0 - p).clamp(0.0, 1.0),
)];
}
} else {
self.streak = 0;
}
}
}
Vec::new()
}
fn name(&self) -> &'static str {
"presence"
}
}
// ---------------------------------------------------------------------------
// MotionDetector
// ---------------------------------------------------------------------------
/// Tunables for [`MotionDetector`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionConfig {
/// Rising-edge threshold on `motion_energy`.
pub on_threshold: f32,
/// Falling-edge threshold on `motion_energy` (`< on_threshold`).
pub off_threshold: f32,
/// Consecutive windows above/below the relevant threshold before firing.
pub debounce_windows: u32,
}
impl Default for MotionConfig {
fn default() -> Self {
MotionConfig {
on_threshold: 0.05,
off_threshold: 0.02,
debounce_windows: 2,
}
}
}
impl MotionConfig {
fn checked(self) -> Self {
assert!(
self.on_threshold > self.off_threshold,
"MotionConfig requires on_threshold > off_threshold"
);
assert!(self.debounce_windows >= 1);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum MotionState {
Settled,
Moving,
}
/// State machine over [`CsiWindow::motion_energy`].
///
/// Emits [`CsiEventKind::MotionDetected`] on a debounced rising edge and
/// [`CsiEventKind::MotionSettled`] on a debounced falling edge.
#[derive(Debug, Clone)]
pub struct MotionDetector {
cfg: MotionConfig,
state: MotionState,
streak: u32,
}
impl Default for MotionDetector {
fn default() -> Self {
Self::new()
}
}
impl MotionDetector {
/// New detector with default thresholds.
pub fn new() -> Self {
Self::with_config(MotionConfig::default())
}
/// New detector with explicit config.
///
/// # Panics
/// Panics if `on_threshold <= off_threshold` or `debounce_windows == 0`.
pub fn with_config(cfg: MotionConfig) -> Self {
MotionDetector {
cfg: cfg.checked(),
state: MotionState::Settled,
streak: 0,
}
}
}
impl EventDetector for MotionDetector {
fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec<CsiEvent> {
let m = window.motion_energy;
match self.state {
MotionState::Settled => {
if m > self.cfg.on_threshold {
self.streak += 1;
if self.streak >= self.cfg.debounce_windows {
self.state = MotionState::Moving;
self.streak = 0;
let conf = (m / (2.0 * self.cfg.on_threshold)).clamp(0.0, 1.0);
return vec![make_event(
ids,
CsiEventKind::MotionDetected,
window,
window.end_ns,
conf,
)];
}
} else {
self.streak = 0;
}
}
MotionState::Moving => {
if m < self.cfg.off_threshold {
self.streak += 1;
if self.streak >= self.cfg.debounce_windows {
self.state = MotionState::Settled;
self.streak = 0;
let rise = (m / (2.0 * self.cfg.on_threshold)).clamp(0.0, 1.0);
return vec![make_event(
ids,
CsiEventKind::MotionSettled,
window,
window.end_ns,
(1.0 - rise).clamp(0.0, 1.0),
)];
}
} else {
self.streak = 0;
}
}
}
Vec::new()
}
fn name(&self) -> &'static str {
"motion"
}
}
// ---------------------------------------------------------------------------
// QualityDetector
// ---------------------------------------------------------------------------
/// Tunables for [`QualityDetector`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QualityConfig {
/// `quality_score` below this (debounced) raises [`CsiEventKind::SignalQualityDropped`].
pub drop_threshold: f32,
/// Consecutive low windows before [`CsiEventKind::SignalQualityDropped`] fires.
pub debounce_windows: u32,
/// Consecutive low windows (counting from the first low one) before
/// [`CsiEventKind::CalibrationRequired`] also fires — once per low stretch.
pub calib_windows: u32,
}
impl Default for QualityConfig {
fn default() -> Self {
QualityConfig {
drop_threshold: 0.4,
debounce_windows: 2,
calib_windows: 4,
}
}
}
impl QualityConfig {
fn checked(self) -> Self {
assert!(self.debounce_windows >= 1 && self.calib_windows >= 1);
self
}
}
/// State machine over [`CsiWindow::quality_score`].
///
/// While `quality_score` stays below `drop_threshold` it counts a low streak.
/// At `debounce_windows` it emits [`CsiEventKind::SignalQualityDropped`]; at
/// `calib_windows` it additionally emits [`CsiEventKind::CalibrationRequired`]
/// (only once until quality recovers). Any window at or above `drop_threshold`
/// resets the streak and re-arms both events.
#[derive(Debug, Clone)]
pub struct QualityDetector {
cfg: QualityConfig,
low_streak: u32,
dropped_emitted: bool,
calib_emitted: bool,
}
impl Default for QualityDetector {
fn default() -> Self {
Self::new()
}
}
impl QualityDetector {
/// New detector with default thresholds.
pub fn new() -> Self {
Self::with_config(QualityConfig::default())
}
/// New detector with explicit config.
pub fn with_config(cfg: QualityConfig) -> Self {
QualityDetector {
cfg: cfg.checked(),
low_streak: 0,
dropped_emitted: false,
calib_emitted: false,
}
}
}
impl EventDetector for QualityDetector {
fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec<CsiEvent> {
let q = window.quality_score;
if q < self.cfg.drop_threshold {
self.low_streak += 1;
let mut out = Vec::new();
if !self.dropped_emitted && self.low_streak >= self.cfg.debounce_windows {
self.dropped_emitted = true;
out.push(make_event(
ids,
CsiEventKind::SignalQualityDropped,
window,
window.end_ns,
(1.0 - q).clamp(0.0, 1.0),
));
}
if !self.calib_emitted && self.low_streak >= self.cfg.calib_windows {
self.calib_emitted = true;
out.push(make_event(
ids,
CsiEventKind::CalibrationRequired,
window,
window.end_ns,
(1.0 - q).clamp(0.0, 1.0),
));
}
out
} else {
self.low_streak = 0;
self.dropped_emitted = false;
self.calib_emitted = false;
Vec::new()
}
}
fn name(&self) -> &'static str {
"quality"
}
}
// ---------------------------------------------------------------------------
// BaselineDriftDetector
// ---------------------------------------------------------------------------
/// Tunables for [`BaselineDriftDetector`].
///
/// `drift_threshold` and `anomaly_threshold` are **relative** — they are
/// fractions of the running baseline's RMS magnitude, not absolute amplitude
/// units. This keeps the detector source-agnostic: ESP32 emits raw `int8` I/Q
/// (amplitudes up to ~128), Nexmon emits `int16`-scaled CSI, and a
/// baseline-subtracted pipeline emits values near zero — an *absolute* threshold
/// can only ever be right for one of them, a *relative* one is right for all.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaselineDriftConfig {
/// Relative per-window drift `||mean_amplitude - baseline||_2 / ||baseline||_2`
/// above this for `drift_windows` windows in a row triggers
/// [`CsiEventKind::BaselineChanged`]. `0.15` ≈ "the room moved ~15 %".
pub drift_threshold: f32,
/// Consecutive drifting windows before [`CsiEventKind::BaselineChanged`] fires.
pub drift_windows: u32,
/// A single window whose relative drift exceeds this (much larger) value
/// triggers [`CsiEventKind::AnomalyDetected`]. `1.0` ≈ "this window differs
/// from the baseline by as much as the baseline's own magnitude".
pub anomaly_threshold: f32,
/// EWMA smoothing factor for the running baseline (`baseline = a*current + (1-a)*baseline`).
pub ewma_alpha: f32,
}
impl Default for BaselineDriftConfig {
fn default() -> Self {
BaselineDriftConfig {
drift_threshold: 0.15,
drift_windows: 3,
anomaly_threshold: 1.0,
ewma_alpha: 0.1,
}
}
}
impl BaselineDriftConfig {
fn checked(self) -> Self {
assert!(self.drift_windows >= 1);
assert!(self.anomaly_threshold > self.drift_threshold);
assert!(self.ewma_alpha > 0.0 && self.ewma_alpha <= 1.0);
self
}
}
/// Tracks an EWMA baseline of `mean_amplitude` and flags sustained drift /
/// single-window anomalies.
#[derive(Debug, Clone)]
pub struct BaselineDriftDetector {
cfg: BaselineDriftConfig,
baseline: Option<Vec<f32>>,
drift_streak: u32,
}
impl Default for BaselineDriftDetector {
fn default() -> Self {
Self::new()
}
}
impl BaselineDriftDetector {
/// New detector with default thresholds.
pub fn new() -> Self {
Self::with_config(BaselineDriftConfig::default())
}
/// New detector with explicit config.
pub fn with_config(cfg: BaselineDriftConfig) -> Self {
BaselineDriftDetector {
cfg: cfg.checked(),
baseline: None,
drift_streak: 0,
}
}
/// L2 distance between two equal-length vectors, normalized by `sqrt(len)`.
fn rms_distance(a: &[f32], b: &[f32]) -> f32 {
let n = a.len();
if n == 0 {
return 0.0;
}
let mut sq = 0.0f64;
for k in 0..n {
let d = (a[k] - b[k]) as f64;
sq += d * d;
}
(sq.sqrt() / (n as f64).sqrt()) as f32
}
/// Root-mean-square magnitude of a vector (`0.0` for an empty one).
fn rms(v: &[f32]) -> f32 {
let n = v.len();
if n == 0 {
return 0.0;
}
let sq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
(sq.sqrt() / (n as f64).sqrt()) as f32
}
/// Drift of `current` from `baseline` as a fraction of the baseline's RMS
/// magnitude. Source-agnostic (see [`BaselineDriftConfig`]). The `eps` floor
/// keeps a near-zero baseline (e.g. just after a baseline-subtraction stage)
/// from blowing the ratio up to infinity — when the baseline carries
/// essentially no energy there is nothing to drift *relative to*, so the
/// detector treats it as quiet.
fn relative_drift(current: &[f32], baseline: &[f32]) -> f32 {
let abs_drift = Self::rms_distance(current, baseline);
let baseline_rms = Self::rms(baseline);
// 1e-3 is well below any real CSI amplitude scale (ESP32 int8 ⇒ O(10),
// Nexmon int16 ⇒ O(100s)) yet above f32 noise.
const EPS: f32 = 1e-3;
if baseline_rms <= EPS {
// Degenerate baseline: fall back to an absolute reading so a sudden
// jump away from a flat-zero baseline still registers.
abs_drift
} else {
abs_drift / baseline_rms
}
}
fn update_ewma(&mut self, current: &[f32]) {
match &mut self.baseline {
None => self.baseline = Some(current.to_vec()),
Some(b) if b.len() != current.len() => {
self.baseline = Some(current.to_vec());
}
Some(b) => {
let a = self.cfg.ewma_alpha;
for k in 0..b.len() {
b[k] = a * current[k] + (1.0 - a) * b[k];
}
}
}
}
}
impl EventDetector for BaselineDriftDetector {
fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec<CsiEvent> {
let current = &window.mean_amplitude;
let baseline = match &self.baseline {
None => {
// First window establishes the baseline; no drift possible yet.
self.baseline = Some(current.clone());
return Vec::new();
}
Some(b) if b.len() != current.len() => {
// Subcarrier count changed — reset and skip this window.
self.baseline = Some(current.clone());
self.drift_streak = 0;
return Vec::new();
}
Some(b) => b.clone(),
};
let drift = Self::relative_drift(current, &baseline);
let mut out = Vec::new();
if drift > self.cfg.anomaly_threshold {
out.push(make_event(
ids,
CsiEventKind::AnomalyDetected,
window,
window.end_ns,
(drift / (2.0 * self.cfg.anomaly_threshold)).clamp(0.0, 1.0),
));
}
if drift > self.cfg.drift_threshold {
self.drift_streak += 1;
if self.drift_streak >= self.cfg.drift_windows {
out.push(make_event(
ids,
CsiEventKind::BaselineChanged,
window,
window.end_ns,
(drift / (2.0 * self.cfg.drift_threshold)).clamp(0.0, 1.0),
));
self.drift_streak = 0;
// Hard-reset the baseline to the new operating point.
self.baseline = Some(current.clone());
return out;
}
} else {
self.drift_streak = 0;
}
self.update_ewma(current);
out
}
fn name(&self) -> &'static str {
"baseline_drift"
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{SessionId, SourceId};
fn window(window_id: u64, end_ns: u64, motion: f32, presence: f32, quality: f32) -> CsiWindow {
let end_ns = end_ns.max(1);
CsiWindow {
window_id: WindowId(window_id),
session_id: SessionId(0),
source_id: SourceId::from("s"),
start_ns: end_ns.saturating_sub(1_000),
end_ns,
frame_count: 8,
mean_amplitude: vec![1.0; 8],
phase_variance: vec![0.0; 8],
motion_energy: motion,
presence_score: presence,
quality_score: quality,
}
}
fn window_amp(window_id: u64, end_ns: u64, amp: Vec<f32>) -> CsiWindow {
let n = amp.len();
CsiWindow {
window_id: WindowId(window_id),
session_id: SessionId(0),
source_id: SourceId::from("s"),
start_ns: 0,
end_ns: end_ns.max(1),
frame_count: 8,
mean_amplitude: amp,
phase_variance: vec![0.0; n],
motion_energy: 0.0,
presence_score: 0.0,
quality_score: 0.9,
}
}
#[test]
fn presence_detector_emits_started_then_ended() {
let g = IdGenerator::new();
let mut d = PresenceDetector::with_config(PresenceConfig {
on_threshold: 0.6,
off_threshold: 0.35,
enter_windows: 2,
exit_windows: 3,
});
let mut events = Vec::new();
// Low windows.
for k in 0..3u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.05, 0.9), &g));
}
assert!(events.is_empty());
// High run -> PresenceStarted after the 2nd one.
for k in 3..8u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.5, 0.95, 0.9), &g));
}
// Low run -> PresenceEnded after the 3rd low one.
for k in 8..13u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.05, 0.9), &g));
}
assert_eq!(events.len(), 2, "events = {events:?}");
assert_eq!(events[0].kind, CsiEventKind::PresenceStarted);
assert_eq!(events[1].kind, CsiEventKind::PresenceEnded);
for e in &events {
assert!(e.validate().is_ok());
assert!(!e.evidence_window_ids.is_empty());
assert!((0.0..=1.0).contains(&e.confidence));
}
}
#[test]
fn presence_detector_streak_reset() {
let g = IdGenerator::new();
let mut d = PresenceDetector::new();
// 1 high, 1 low (resets), then enough highs.
assert!(d.on_window(&window(0, 1_000, 0.0, 0.95, 0.9), &g).is_empty());
assert!(d.on_window(&window(1, 2_000, 0.0, 0.05, 0.9), &g).is_empty());
assert!(d.on_window(&window(2, 3_000, 0.0, 0.95, 0.9), &g).is_empty());
let e = d.on_window(&window(3, 4_000, 0.0, 0.95, 0.9), &g);
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, CsiEventKind::PresenceStarted);
}
#[test]
fn motion_detector_emits_detected_then_settled() {
let g = IdGenerator::new();
let mut d = MotionDetector::with_config(MotionConfig {
on_threshold: 0.05,
off_threshold: 0.02,
debounce_windows: 2,
});
let mut events = Vec::new();
for k in 0..2u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.001, 0.0, 0.9), &g));
}
for k in 2..6u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.3, 0.0, 0.9), &g));
}
for k in 6..10u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.9), &g));
}
assert_eq!(events.len(), 2, "events = {events:?}");
assert_eq!(events[0].kind, CsiEventKind::MotionDetected);
assert_eq!(events[1].kind, CsiEventKind::MotionSettled);
for e in &events {
assert!(e.validate().is_ok());
}
}
#[test]
fn quality_detector_drop_then_calibration_once() {
let g = IdGenerator::new();
let mut d = QualityDetector::with_config(QualityConfig {
drop_threshold: 0.4,
debounce_windows: 2,
calib_windows: 4,
});
let mut events = Vec::new();
// Good window first.
events.extend(d.on_window(&window(0, 1_000, 0.0, 0.0, 0.9), &g));
// Low run.
for k in 1..8u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.1), &g));
}
let dropped = events
.iter()
.filter(|e| e.kind == CsiEventKind::SignalQualityDropped)
.count();
let calib = events
.iter()
.filter(|e| e.kind == CsiEventKind::CalibrationRequired)
.count();
assert_eq!(dropped, 1, "events = {events:?}");
assert_eq!(calib, 1, "events = {events:?}");
for e in &events {
assert!(e.validate().is_ok());
}
// Recover and drop again -> re-armed.
events.clear();
events.extend(d.on_window(&window(8, 9_000, 0.0, 0.0, 0.95), &g));
for k in 9..14u64 {
events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.1), &g));
}
assert_eq!(
events
.iter()
.filter(|e| e.kind == CsiEventKind::SignalQualityDropped)
.count(),
1
);
}
#[test]
fn baseline_drift_stable_then_shift_then_anomaly() {
let g = IdGenerator::new();
let mut d = BaselineDriftDetector::with_config(BaselineDriftConfig {
drift_threshold: 0.15,
drift_windows: 3,
anomaly_threshold: 1.0,
ewma_alpha: 0.1,
});
// Stable baseline -> no events.
let mut events = Vec::new();
for k in 0..5u64 {
events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, vec![1.0; 8]), &g));
}
assert!(events.is_empty(), "events = {events:?}");
// Sustained shift -> BaselineChanged.
for k in 5..10u64 {
events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, vec![1.5; 8]), &g));
}
assert!(
events.iter().any(|e| e.kind == CsiEventKind::BaselineChanged),
"events = {events:?}"
);
// Single huge spike -> AnomalyDetected.
events.clear();
events.extend(d.on_window(&window_amp(10, 11_000, vec![50.0; 8]), &g));
assert!(
events.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected),
"events = {events:?}"
);
for e in &events {
assert!(e.validate().is_ok());
}
}
#[test]
fn baseline_drift_is_scale_invariant_no_anomaly_storm() {
// Regression for the ESP32 live-capture finding: raw int8 CSI amplitudes
// are O(10128), so an *absolute* anomaly_threshold of 1.0 fired on
// essentially every window. With a *relative* threshold a few-percent
// wobble around a large baseline must stay quiet.
let g = IdGenerator::new();
let mut d = BaselineDriftDetector::new(); // defaults: drift 0.15, anomaly 1.0
// A realistic ESP32-ish window: two big "DC/pilot" subcarriers plus a
// band of small data subcarriers; ±3 % jitter window to window.
let base: Vec<f32> = {
let mut v = vec![128.0, 110.0];
v.extend(std::iter::repeat(15.0).take(68));
v
};
let mut events = Vec::new();
for k in 0..40u64 {
// deterministic small wobble in [-0.03, +0.03] * value
let f = 1.0 + 0.03 * (((k * 2654435761) % 7) as f32 / 3.0 - 1.0);
let w: Vec<f32> = base.iter().map(|x| x * f).collect();
events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, w), &g));
}
assert!(
!events.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected),
"a ±3% wobble around a large baseline must not be an anomaly; got {events:?}"
);
// A 5x jump on the data subcarriers (a person walks in) *is* an anomaly.
let spike: Vec<f32> = {
let mut v = vec![128.0, 110.0];
v.extend(std::iter::repeat(75.0).take(68));
v
};
let ev = d.on_window(&window_amp(99, 100_000, spike), &g);
assert!(
ev.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected),
"a 5x jump on the data band should register; got {ev:?}"
);
}
#[test]
fn baseline_drift_resets_on_subcarrier_change() {
let g = IdGenerator::new();
let mut d = BaselineDriftDetector::new();
assert!(d.on_window(&window_amp(0, 1_000, vec![1.0; 8]), &g).is_empty());
// Different length -> reset, no event.
assert!(d.on_window(&window_amp(1, 2_000, vec![1.0; 16]), &g).is_empty());
assert!(d.on_window(&window_amp(2, 3_000, vec![1.0; 16]), &g).is_empty());
}
}
+37
View File
@@ -0,0 +1,37 @@
//! # rvCSI events — window aggregation + semantic event extraction (ADR-095 FR5)
//!
//! This crate turns a stream of validated [`rvcsi_core::CsiFrame`]s into
//! [`rvcsi_core::CsiWindow`]s and then into [`rvcsi_core::CsiEvent`]s.
//!
//! The pipeline has three layers:
//!
//! 1. [`WindowBuffer`] — buffers exposable frames from one
//! `(session_id, source_id)` and emits a [`rvcsi_core::CsiWindow`] when a
//! frame-count or duration threshold is hit. Per-subcarrier statistics
//! (`mean_amplitude`, `phase_variance`) and the scalar `motion_energy`,
//! `presence_score` and `quality_score` are computed here.
//! 2. [`EventDetector`] implementations — small, deterministic state machines
//! that consume windows and emit events:
//! [`PresenceDetector`], [`MotionDetector`], [`QualityDetector`] and
//! [`BaselineDriftDetector`].
//! 3. [`EventPipeline`] — wires a [`WindowBuffer`] and a set of detectors
//! together and owns an [`rvcsi_core::IdGenerator`].
//!
//! Determinism: feeding the same frame stream through an [`EventPipeline`]
//! always produces the same event list (modulo the ids, which are minted in a
//! deterministic order). All "noise" in the tests comes from a tiny LCG, never
//! from `rand`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod detectors;
mod pipeline;
mod window_buffer;
pub use detectors::{
BaselineDriftConfig, BaselineDriftDetector, EventDetector, MotionConfig, MotionDetector,
PresenceConfig, PresenceDetector, QualityConfig, QualityDetector,
};
pub use pipeline::EventPipeline;
pub use window_buffer::{WindowBuffer, WindowBufferConfig};
+260
View File
@@ -0,0 +1,260 @@
//! [`EventPipeline`] — wires a [`WindowBuffer`] to a set of [`EventDetector`]s.
//!
//! A pipeline owns its own [`IdGenerator`] so window/event ids are minted in a
//! deterministic order. Feed it frames with [`EventPipeline::process_frame`]
//! and drain the tail with [`EventPipeline::flush`].
use rvcsi_core::{CsiEvent, CsiFrame, CsiWindow, IdGenerator, SessionId, SourceId};
use crate::detectors::{
BaselineDriftDetector, EventDetector, MotionDetector, PresenceDetector, QualityDetector,
};
use crate::window_buffer::{WindowBuffer, WindowBufferConfig};
/// How many recently-closed windows the pipeline keeps for inspection.
const RECENT_WINDOW_CAP: usize = 32;
/// Aggregates frames into windows and runs detectors over them.
pub struct EventPipeline {
buffer: WindowBuffer,
detectors: Vec<Box<dyn EventDetector>>,
ids: IdGenerator,
recent: Vec<CsiWindow>,
}
impl core::fmt::Debug for EventPipeline {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("EventPipeline")
.field("detectors", &self.detectors.iter().map(|d| d.name()).collect::<Vec<_>>())
.field("pending_frame_count", &self.buffer.pending_frame_count())
.field("recent_windows", &self.recent.len())
.finish()
}
}
impl EventPipeline {
/// New pipeline with the given window-buffer config and no detectors.
///
/// Add detectors with [`EventPipeline::add_detector`].
pub fn new(session_id: SessionId, source_id: SourceId, buffer_cfg: WindowBufferConfig) -> Self {
EventPipeline {
buffer: WindowBuffer::with_config(session_id, source_id, buffer_cfg),
detectors: Vec::new(),
ids: IdGenerator::new(),
recent: Vec::new(),
}
}
/// New pipeline with the four default detectors and a 16-frame / 1-second
/// window buffer.
pub fn with_defaults(session_id: SessionId, source_id: SourceId) -> Self {
let mut p = Self::new(
session_id,
source_id,
WindowBufferConfig::new(16, 1_000_000_000),
);
p.add_detector(Box::new(PresenceDetector::new()));
p.add_detector(Box::new(MotionDetector::new()));
p.add_detector(Box::new(QualityDetector::new()));
p.add_detector(Box::new(BaselineDriftDetector::new()));
p
}
/// Append a detector. Detectors run in insertion order on every window.
pub fn add_detector(&mut self, detector: Box<dyn EventDetector>) {
self.detectors.push(detector);
}
/// Names of the registered detectors, in order.
pub fn detector_names(&self) -> Vec<&'static str> {
self.detectors.iter().map(|d| d.name()).collect()
}
/// The most-recently-closed windows (newest last), capped at 32.
pub fn recent_windows(&self) -> &[CsiWindow] {
&self.recent
}
/// Frames buffered but not yet emitted as a window.
pub fn pending_frame_count(&self) -> usize {
self.buffer.pending_frame_count()
}
/// Push one frame; if it closes a window, run every detector on that window
/// and return their concatenated events. Otherwise return an empty `Vec`.
pub fn process_frame(&mut self, frame: &CsiFrame) -> Vec<CsiEvent> {
match self.buffer.push(frame, &self.ids) {
Some(window) => self.run_detectors(window),
None => Vec::new(),
}
}
/// Close whatever frames remain in the buffer into a final window and run
/// detectors on it. Returns an empty `Vec` if the buffer was empty.
pub fn flush(&mut self) -> Vec<CsiEvent> {
match self.buffer.flush(&self.ids) {
Some(window) => self.run_detectors(window),
None => Vec::new(),
}
}
fn run_detectors(&mut self, window: CsiWindow) -> Vec<CsiEvent> {
let mut events = Vec::new();
for d in &mut self.detectors {
events.extend(d.on_window(&window, &self.ids));
}
debug_assert!(events.iter().all(|e| e.validate().is_ok()));
self.recent.push(window);
if self.recent.len() > RECENT_WINDOW_CAP {
let overflow = self.recent.len() - RECENT_WINDOW_CAP;
self.recent.drain(0..overflow);
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{AdapterKind, CsiEventKind, FrameId, ValidationStatus};
/// Deterministic LCG (Numerical Recipes constants) -> `[0.0, 1.0)`.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Lcg(seed)
}
fn next_unit(&mut self) -> f32 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
// top 24 bits -> [0,1)
((self.0 >> 40) as f32) / (1u64 << 24) as f32
}
}
fn accepted_frame(frame_id: u64, ts: u64, amp: &[f32], quality: f32) -> CsiFrame {
let i: Vec<f32> = amp.to_vec();
let q: Vec<f32> = vec![0.0; amp.len()];
let mut f = CsiFrame::from_iq(
FrameId(frame_id),
SessionId(1),
SourceId::from("dev"),
AdapterKind::Synthetic,
ts,
6,
20,
i,
q,
);
f.validation = ValidationStatus::Accepted;
f.quality_score = quality;
f
}
/// Build a quiet / active / quiet frame stream with monotonic 50 ms
/// timestamps. Long enough that the default 16-frame window buffer yields
/// enough windows for the detectors' debounce / hysteresis chains.
fn synthetic_stream() -> Vec<CsiFrame> {
let mut rng = Lcg::new(0xC0FFEE);
let mut frames = Vec::new();
let dt = 50_000_000u64; // 50 ms
let quiet_a = 30u64;
let active = 60u64;
let quiet_b = 60u64;
let total = quiet_a + active + quiet_b;
for k in 0..total {
let ts = k * dt;
let is_active = (quiet_a..quiet_a + active).contains(&k);
let amp: Vec<f32> = (0..32)
.map(|_| {
if is_active {
// Large per-frame jitter.
1.0 + (rng.next_unit() - 0.5) * 4.0
} else {
// Tiny deterministic noise around 1.0.
1.0 + (rng.next_unit() - 0.5) * 0.001
}
})
.collect();
frames.push(accepted_frame(k, ts, &amp, 0.9));
}
frames
}
fn run_stream(frames: &[CsiFrame]) -> Vec<CsiEvent> {
let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
let mut events = Vec::new();
for f in frames {
events.extend(p.process_frame(f));
}
events.extend(p.flush());
events
}
#[test]
fn pipeline_detects_motion_and_presence_and_settles() {
let frames = synthetic_stream();
let events = run_stream(&frames);
assert!(!events.is_empty(), "expected some events");
for e in &events {
assert!(e.validate().is_ok(), "invalid event: {e:?}");
}
let kinds: Vec<CsiEventKind> = events.iter().map(|e| e.kind).collect();
assert!(kinds.contains(&CsiEventKind::MotionDetected), "kinds = {kinds:?}");
assert!(kinds.contains(&CsiEventKind::PresenceStarted), "kinds = {kinds:?}");
assert!(kinds.contains(&CsiEventKind::MotionSettled), "kinds = {kinds:?}");
assert!(kinds.contains(&CsiEventKind::PresenceEnded), "kinds = {kinds:?}");
// MotionDetected should come before MotionSettled.
let det = events.iter().position(|e| e.kind == CsiEventKind::MotionDetected).unwrap();
let set = events.iter().position(|e| e.kind == CsiEventKind::MotionSettled).unwrap();
assert!(det < set);
let start = events.iter().position(|e| e.kind == CsiEventKind::PresenceStarted).unwrap();
let end = events.iter().position(|e| e.kind == CsiEventKind::PresenceEnded).unwrap();
assert!(start < end);
}
#[test]
fn pipeline_is_deterministic() {
let frames = synthetic_stream();
let a = run_stream(&frames);
let b = run_stream(&frames);
assert_eq!(a, b, "same stream must yield identical events");
}
#[test]
fn pipeline_recent_windows_and_pending_count() {
let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
let amp = vec![1.0f32; 32];
// Two windows worth of frames (16 each at the 16-frame cap).
for k in 0..16u64 {
p.process_frame(&accepted_frame(k, k * 10_000, &amp, 0.9));
}
assert_eq!(p.recent_windows().len(), 1);
assert_eq!(p.pending_frame_count(), 0);
p.process_frame(&accepted_frame(16, 200_000, &amp, 0.9));
assert_eq!(p.pending_frame_count(), 1);
let leftover = p.flush();
let _ = leftover;
assert_eq!(p.recent_windows().len(), 2);
assert_eq!(p.pending_frame_count(), 0);
}
#[test]
fn pipeline_skips_foreign_frames() {
let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
let amp = vec![1.0f32; 8];
let mut foreign = accepted_frame(0, 0, &amp, 0.9);
foreign.session_id = SessionId(99);
assert!(p.process_frame(&foreign).is_empty());
assert_eq!(p.pending_frame_count(), 0);
}
#[test]
fn detector_names_in_order() {
let p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
assert_eq!(
p.detector_names(),
vec!["presence", "motion", "quality", "baseline_drift"]
);
}
}
+392
View File
@@ -0,0 +1,392 @@
//! [`WindowBuffer`] — aggregates exposable [`CsiFrame`]s into [`CsiWindow`]s.
use rvcsi_core::{CsiFrame, CsiWindow, IdGenerator, SessionId, SourceId};
/// Tunables for a [`WindowBuffer`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WindowBufferConfig {
/// Close the window once this many frames have been buffered. Must be `>= 2`.
pub max_frames: usize,
/// Close the window once `last_ts - first_ts >= max_duration_ns`.
pub max_duration_ns: u64,
/// Centre of the logistic that maps `motion_energy` to `presence_score`.
pub presence_threshold: f32,
}
impl WindowBufferConfig {
/// Build a config with a default `presence_threshold` of `0.05`.
///
/// # Panics
/// Panics if `max_frames < 2`.
pub fn new(max_frames: usize, max_duration_ns: u64) -> Self {
assert!(max_frames >= 2, "WindowBuffer max_frames must be >= 2");
WindowBufferConfig {
max_frames,
max_duration_ns,
presence_threshold: 0.05,
}
}
/// Builder-style setter for [`WindowBufferConfig::presence_threshold`].
pub fn with_presence_threshold(mut self, t: f32) -> Self {
self.presence_threshold = t;
self
}
}
/// Buffers frames from one `(session_id, source_id)` and emits windows.
///
/// Use [`WindowBuffer::push`] for each incoming frame; it returns `Some(window)`
/// on the frame that closes a window (that frame being the last in the window).
/// Call [`WindowBuffer::flush`] at end-of-stream to drain whatever is buffered.
#[derive(Debug, Clone)]
pub struct WindowBuffer {
session_id: SessionId,
source_id: SourceId,
cfg: WindowBufferConfig,
/// Subcarrier count fixed by the first buffered frame of the current window.
subcarrier_count: Option<u16>,
/// Buffered `amplitude` vectors (one per accepted frame).
amplitudes: Vec<Vec<f32>>,
/// Buffered `phase` vectors (one per accepted frame).
phases: Vec<Vec<f32>>,
/// Buffered `quality_score`s.
qualities: Vec<f32>,
/// Buffered timestamps (ns).
timestamps: Vec<u64>,
}
impl WindowBuffer {
/// Create a buffer for `session_id` / `source_id` with the given thresholds.
///
/// # Panics
/// Panics if `max_frames < 2`.
pub fn new(
session_id: SessionId,
source_id: SourceId,
max_frames: usize,
max_duration_ns: u64,
) -> Self {
Self::with_config(
session_id,
source_id,
WindowBufferConfig::new(max_frames, max_duration_ns),
)
}
/// Create a buffer from a [`WindowBufferConfig`].
///
/// # Panics
/// Panics if `cfg.max_frames < 2`.
pub fn with_config(session_id: SessionId, source_id: SourceId, cfg: WindowBufferConfig) -> Self {
assert!(cfg.max_frames >= 2, "WindowBuffer max_frames must be >= 2");
WindowBuffer {
session_id,
source_id,
cfg,
subcarrier_count: None,
amplitudes: Vec::new(),
phases: Vec::new(),
qualities: Vec::new(),
timestamps: Vec::new(),
}
}
/// Number of frames currently buffered (not yet emitted as a window).
pub fn pending_frame_count(&self) -> usize {
self.amplitudes.len()
}
/// Add a frame; returns `Some(window)` if this frame closed a window.
///
/// Frames are skipped (returning `None`, not buffered) when:
/// * `!frame.is_exposable()`,
/// * the frame's `session_id` / `source_id` don't match the buffer's, or
/// * the frame's `subcarrier_count` differs from the first buffered frame's.
pub fn push(&mut self, frame: &CsiFrame, ids: &IdGenerator) -> Option<CsiWindow> {
if !frame.is_exposable() {
return None;
}
if frame.session_id != self.session_id || frame.source_id != self.source_id {
return None;
}
match self.subcarrier_count {
None => self.subcarrier_count = Some(frame.subcarrier_count),
Some(n) if n != frame.subcarrier_count => return None,
Some(_) => {}
}
self.amplitudes.push(frame.amplitude.clone());
self.phases.push(frame.phase.clone());
self.qualities.push(frame.quality_score);
self.timestamps.push(frame.timestamp_ns);
let reached_count = self.amplitudes.len() >= self.cfg.max_frames;
let reached_duration = match (self.timestamps.first(), self.timestamps.last()) {
(Some(&first), Some(&last)) => last.saturating_sub(first) >= self.cfg.max_duration_ns,
_ => false,
};
if reached_count || reached_duration {
Some(self.close(ids))
} else {
None
}
}
/// Drain whatever is buffered (>= 1 frame) into a final window.
///
/// Returns `None` when the buffer is empty.
pub fn flush(&mut self, ids: &IdGenerator) -> Option<CsiWindow> {
if self.amplitudes.is_empty() {
None
} else {
Some(self.close(ids))
}
}
/// Build the [`CsiWindow`] from the buffered frames and reset the buffer.
fn close(&mut self, ids: &IdGenerator) -> CsiWindow {
let frame_count = self.amplitudes.len();
debug_assert!(frame_count >= 1, "close() called on an empty buffer");
let n = self.subcarrier_count.unwrap_or(0) as usize;
// Per-subcarrier mean amplitude.
let mut mean_amplitude = vec![0.0f32; n];
for amp in &self.amplitudes {
for (slot, a) in mean_amplitude.iter_mut().zip(amp.iter()) {
*slot += *a;
}
}
for v in &mut mean_amplitude {
*v /= frame_count as f32;
}
// Per-subcarrier population variance of the phase.
let mut phase_mean = vec![0.0f32; n];
for ph in &self.phases {
for (slot, p) in phase_mean.iter_mut().zip(ph.iter()) {
*slot += *p;
}
}
for v in &mut phase_mean {
*v /= frame_count as f32;
}
let mut phase_variance = vec![0.0f32; n];
for ph in &self.phases {
for k in 0..n {
let d = ph.get(k).copied().unwrap_or(0.0) - phase_mean[k];
phase_variance[k] += d * d;
}
}
for v in &mut phase_variance {
*v /= frame_count as f32;
}
// Motion energy: mean over consecutive pairs of ||amp_b - amp_a||_2 / sqrt(n).
let motion_energy = if frame_count < 2 || n == 0 {
0.0
} else {
let mut acc = 0.0f64;
for w in self.amplitudes.windows(2) {
let (a, b) = (&w[0], &w[1]);
let mut sq = 0.0f64;
for k in 0..n {
let d = (b.get(k).copied().unwrap_or(0.0) - a.get(k).copied().unwrap_or(0.0))
as f64;
sq += d * d;
}
acc += sq.sqrt() / (n as f64).sqrt();
}
(acc / (frame_count - 1) as f64) as f32
};
let motion_energy = if motion_energy.is_finite() && motion_energy >= 0.0 {
motion_energy
} else {
0.0
};
// Presence score: logistic of (motion_energy - threshold).
let z = (motion_energy - self.cfg.presence_threshold) * 8.0;
let presence_score = (1.0 / (1.0 + (-z).exp())).clamp(0.0, 1.0);
// Quality score: mean of frame quality scores.
let quality_sum: f32 = self.qualities.iter().sum();
let quality_score = (quality_sum / frame_count as f32).clamp(0.0, 1.0);
let start_ns = *self.timestamps.first().unwrap();
let raw_end = *self.timestamps.last().unwrap();
// Edge case: a single-frame window would have start_ns == end_ns, which
// CsiWindow::validate() rejects. Bump the end by 1 ns so it stays valid.
let end_ns = if raw_end > start_ns { raw_end } else { start_ns + 1 };
let window = CsiWindow {
window_id: ids.next_window(),
session_id: self.session_id,
source_id: self.source_id.clone(),
start_ns,
end_ns,
frame_count: frame_count as u32,
mean_amplitude,
phase_variance,
motion_energy,
presence_score,
quality_score,
};
debug_assert!(
window.validate().is_ok(),
"WindowBuffer produced an invalid CsiWindow: {:?}",
window.validate()
);
// Reset for the next window.
self.subcarrier_count = None;
self.amplitudes.clear();
self.phases.clear();
self.qualities.clear();
self.timestamps.clear();
window
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{AdapterKind, FrameId, ValidationStatus};
fn frame(
session: u64,
source: &str,
frame_id: u64,
ts: u64,
amp: &[f32],
quality: f32,
) -> CsiFrame {
// Build I/Q so that amplitude == amp and phase == 0.
let i: Vec<f32> = amp.to_vec();
let q: Vec<f32> = vec![0.0; amp.len()];
let mut f = CsiFrame::from_iq(
FrameId(frame_id),
SessionId(session),
SourceId::from(source),
AdapterKind::Synthetic,
ts,
6,
20,
i,
q,
);
f.validation = ValidationStatus::Accepted;
f.quality_score = quality;
f
}
#[test]
fn closes_after_exactly_max_frames() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 4, u64::MAX);
let amp = [1.0f32, 1.0, 1.0];
assert!(buf.push(&frame(0, "s", 0, 0, &amp, 0.9), &g).is_none());
assert!(buf.push(&frame(0, "s", 1, 10, &amp, 0.9), &g).is_none());
assert!(buf.push(&frame(0, "s", 2, 20, &amp, 0.9), &g).is_none());
assert_eq!(buf.pending_frame_count(), 3);
let w = buf.push(&frame(0, "s", 3, 30, &amp, 0.9), &g).expect("window");
assert_eq!(w.frame_count, 4);
assert_eq!(buf.pending_frame_count(), 0);
assert!(w.validate().is_ok());
}
#[test]
fn closes_on_duration_with_fewer_frames() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 100, 1_000);
let amp = [1.0f32, 2.0];
assert!(buf.push(&frame(0, "s", 0, 0, &amp, 0.8), &g).is_none());
assert!(buf.push(&frame(0, "s", 1, 500, &amp, 0.8), &g).is_none());
let w = buf
.push(&frame(0, "s", 2, 1_000, &amp, 0.8), &g)
.expect("window closed on duration");
assert_eq!(w.frame_count, 3);
assert_eq!(w.start_ns, 0);
assert_eq!(w.end_ns, 1_000);
assert!(w.validate().is_ok());
}
#[test]
fn flush_returns_remainder_and_handles_single_frame() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 10, u64::MAX);
let amp = [1.0f32, 1.0];
assert!(buf.push(&frame(0, "s", 0, 100, &amp, 0.7), &g).is_none());
let w = buf.flush(&g).expect("flush returns the single buffered frame");
assert_eq!(w.frame_count, 1);
assert_eq!(w.start_ns, 100);
assert_eq!(w.end_ns, 101); // bumped so validate() passes
assert_eq!(w.motion_energy, 0.0);
assert!(w.validate().is_ok());
assert!(buf.flush(&g).is_none());
}
#[test]
fn skips_mismatched_session_and_source() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(7), SourceId::from("good"), 4, u64::MAX);
let amp = [1.0f32, 1.0];
assert!(buf.push(&frame(7, "good", 0, 0, &amp, 0.9), &g).is_none());
// Wrong session.
assert!(buf.push(&frame(8, "good", 1, 10, &amp, 0.9), &g).is_none());
// Wrong source.
assert!(buf.push(&frame(7, "bad", 2, 20, &amp, 0.9), &g).is_none());
assert_eq!(buf.pending_frame_count(), 1);
}
#[test]
fn skips_non_exposable_and_mismatched_subcarrier_count() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 4, u64::MAX);
// Non-exposable frame is dropped.
let mut bad = frame(0, "s", 0, 0, &[1.0, 1.0], 0.9);
bad.validation = ValidationStatus::Pending;
assert!(buf.push(&bad, &g).is_none());
assert_eq!(buf.pending_frame_count(), 0);
// First good frame fixes subcarrier count = 2.
assert!(buf.push(&frame(0, "s", 1, 10, &[1.0, 1.0], 0.9), &g).is_none());
// Different subcarrier count is dropped.
assert!(buf
.push(&frame(0, "s", 2, 20, &[1.0, 1.0, 1.0], 0.9), &g)
.is_none());
assert_eq!(buf.pending_frame_count(), 1);
}
#[test]
fn identical_frames_have_zero_motion_low_presence() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 8, u64::MAX);
let amp = [1.0f32; 32];
let mut last = None;
for k in 0..8u64 {
last = buf.push(&frame(0, "s", k, k * 10, &amp, 0.9), &g);
}
let w = last.expect("window");
assert_eq!(w.motion_energy, 0.0);
assert!(w.presence_score < 0.5, "presence_score = {}", w.presence_score);
assert!(w.validate().is_ok());
}
#[test]
fn growing_jitter_raises_motion_and_presence() {
let g = IdGenerator::new();
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 16, u64::MAX);
// Large alternating jitter -> high motion energy.
let mut last = None;
for k in 0..16u64 {
let bump = if k % 2 == 0 { 0.0 } else { 1.0 };
let amp: Vec<f32> = (0..32).map(|_| 1.0 + bump).collect();
last = buf.push(&frame(0, "s", k, k * 10, &amp, 0.9), &g);
}
let w = last.expect("window");
assert!(w.motion_energy > 0.1, "motion_energy = {}", w.motion_energy);
assert!(w.presence_score > 0.5, "presence_score = {}", w.presence_score);
assert!(w.validate().is_ok());
}
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "rvcsi-node"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI Node.js bindings (napi-rs) — safe TypeScript-facing surface over the rvCSI Rust runtime (ADR-095 D3/D4, ADR-096)"
repository.workspace = true
keywords = ["wifi", "csi", "napi", "rvcsi"]
categories = ["science"]
build = "build.rs"
[lib]
# cdylib -> the .node addon; rlib -> so `cargo test --workspace` can link/test it.
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = { workspace = true }
napi-derive = { workspace = true }
rvcsi-core = { path = "../rvcsi-core" }
rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" }
rvcsi-runtime = { path = "../rvcsi-runtime" }
serde = { workspace = true }
serde_json = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
[dev-dependencies]
tempfile = "3.10"
+64
View File
@@ -0,0 +1,64 @@
# @ruv/rvcsi
Node.js bindings (napi-rs) for **rvCSI** — the edge RF sensing runtime: ingest
WiFi CSI from files / Nexmon dumps, validate and normalize it, run reusable DSP,
emit typed presence / motion / quality / anomaly events, and export temporal
embeddings to an RF-memory store. See [ADR-095](../../../docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md)
and [ADR-096](../../../docs/adr/ADR-096-rvcsi-ffi-crate-layout.md).
> This package wraps the Rust crates in `v2/crates/rvcsi-*`. The Rust side does
> all the work (parsing, validation, DSP, events, embeddings); this is a thin,
> safe JS surface — nothing crosses the boundary except validated/normalized
> objects (delivered as JSON the SDK parses for you).
## Build
The native addon is produced from the `rvcsi-node` Rust crate:
```bash
# from v2/crates/rvcsi-node
npm install # installs @napi-rs/cli
npm run build # -> rvcsi-node.<triple>.node + binding.js + binding.d.ts
```
(`cargo build -p rvcsi-node` also compiles the addon as a `cdylib`; `napi build`
additionally emits the platform loader and `.d.ts`.)
## Usage
```js
const { RvCsi, inspectCaptureFile, eventsFromCaptureFile, nexmonDecodeRecords } = require('@ruv/rvcsi');
// One-shot: summarize a capture
const summary = inspectCaptureFile('lab.rvcsi');
console.log(summary.frame_count, summary.channels, summary.mean_quality);
// One-shot: replay a capture into events
for (const e of eventsFromCaptureFile('lab.rvcsi')) {
console.log(e.kind, e.timestamp_ns, e.confidence);
}
// Streaming
const rt = RvCsi.openCaptureFile('lab.rvcsi');
let frame;
while ((frame = rt.nextCleanFrame()) !== null) {
// frame.validation is 'Accepted' | 'Degraded' | 'Recovered' — never 'Pending'/'Rejected'
if (frame.quality_score > 0.5) { /* ... */ }
}
const events = rt.drainEvents();
console.log(rt.health());
// Decode raw Nexmon records (the napi-c shim format) straight from a Buffer
const fs = require('fs');
const frames = nexmonDecodeRecords(fs.readFileSync('nexmon.bin'), 'wlan0', 1);
```
TypeScript types ship in `index.d.ts` (`CsiFrame`, `CsiWindow`, `CsiEvent`,
`SourceHealth`, `CaptureSummary`, `ValidationStatus`, `CsiEventKind`, ...).
## What's here vs. not (yet)
Implemented: file/replay + Nexmon sources, the validation pipeline, the DSP
stages, window aggregation + the event state machines, RuVector-style RF-memory
export. Not yet wired into this addon: live radio capture, the WebSocket daemon,
and the MCP tool server — those come with `rvcsi-daemon` / `rvcsi-mcp`.
@@ -0,0 +1,48 @@
'use strict';
// Structural smoke test for the @ruv/rvcsi JS surface.
//
// Importing the package never throws (the native addon loads lazily). This test
// asserts the public API shape; if the .node addon HAS been built (e.g. CI ran
// `npm run build` first), it also checks `rvcsiVersion()` returns a string —
// otherwise it asserts the error message is the helpful "not built" one.
//
// Run with: node --test (Node >= 18)
const test = require('node:test');
const assert = require('node:assert/strict');
const rvcsi = require('../index.js');
test('exports the expected functions and class', () => {
for (const fn of [
'rvcsiVersion',
'nexmonShimAbiVersion',
'nexmonDecodeRecords',
'nexmonDecodePcap',
'inspectNexmonPcap',
'decodeChanspec',
'nexmonChipName',
'nexmonProfile',
'nexmonChips',
'inspectCaptureFile',
'eventsFromCaptureFile',
'exportCaptureToRfMemory',
]) {
assert.equal(typeof rvcsi[fn], 'function', `${fn} should be a function`);
}
assert.equal(typeof rvcsi.RvCsi, 'function', 'RvCsi should be a class');
assert.equal(typeof rvcsi.RvCsi.openCaptureFile, 'function');
assert.equal(typeof rvcsi.RvCsi.openNexmonFile, 'function');
assert.equal(typeof rvcsi.RvCsi.openNexmonPcap, 'function');
});
test('native calls either work (addon built) or fail with a helpful message', () => {
try {
const v = rvcsi.rvcsiVersion();
assert.equal(typeof v, 'string');
assert.match(v, /^\d+\.\d+\.\d+/);
assert.equal(typeof rvcsi.nexmonShimAbiVersion(), 'number');
} catch (e) {
assert.match(e.message, /native addon is not built/i);
}
});
+5
View File
@@ -0,0 +1,5 @@
//! napi-rs build glue (ADR-096): emits the platform link args the `.node`
//! addon needs and (re)generates `index.d.ts` / `index.js` via `napi build`.
fn main() {
napi_build::setup();
}
+287
View File
@@ -0,0 +1,287 @@
// rvCSI Node.js SDK — type declarations for the curated `index.js` surface.
//
// The shapes below mirror the Rust `rvcsi-core` schema (`CsiFrame`, `CsiWindow`,
// `CsiEvent`, `SourceHealth`) and `rvcsi-runtime` (`CaptureSummary`). They are
// what you get back after the SDK `JSON.parse`s the strings the napi-rs addon
// returns (see ADR-095 §10 / ADR-096 §2.3).
/** Outcome of the rvCSI validation pipeline for a frame. */
export type ValidationStatus =
| 'Pending'
| 'Accepted'
| 'Degraded'
| 'Rejected'
| 'Recovered';
/** Which adapter family produced a frame. */
export type AdapterKind =
| 'File'
| 'Replay'
| 'Nexmon'
| 'Esp32'
| 'Intel'
| 'Atheros'
| 'Synthetic';
/** Kinds of event the runtime emits. */
export type CsiEventKind =
| 'PresenceStarted'
| 'PresenceEnded'
| 'MotionDetected'
| 'MotionSettled'
| 'BaselineChanged'
| 'SignalQualityDropped'
| 'DeviceDisconnected'
| 'BreathingCandidate'
| 'AnomalyDetected'
| 'CalibrationRequired';
/** One normalized, validated CSI observation. */
export interface CsiFrame {
frame_id: number;
session_id: number;
source_id: string;
adapter_kind: AdapterKind;
timestamp_ns: number;
channel: number;
bandwidth_mhz: number;
rssi_dbm: number | null;
noise_floor_dbm: number | null;
antenna_index: number | null;
tx_chain: number | null;
rx_chain: number | null;
subcarrier_count: number;
i_values: number[];
q_values: number[];
amplitude: number[];
phase: number[];
validation: ValidationStatus;
quality_score: number;
/** Present (non-empty) only when `validation` is `Degraded`. */
quality_reasons?: string[];
calibration_version: string | null;
}
/** A bounded window of frames, summarized. */
export interface CsiWindow {
window_id: number;
session_id: number;
source_id: string;
start_ns: number;
end_ns: number;
frame_count: number;
mean_amplitude: number[];
phase_variance: number[];
motion_energy: number;
presence_score: number;
quality_score: number;
}
/** A detected event with confidence and the windows that justify it. */
export interface CsiEvent {
event_id: number;
kind: CsiEventKind;
session_id: number;
source_id: string;
timestamp_ns: number;
confidence: number;
evidence_window_ids: number[];
calibration_version: string | null;
/** Free-form JSON string of event metadata. */
metadata_json: string;
}
/** Health snapshot for a source. */
export interface SourceHealth {
connected: boolean;
frames_delivered: number;
frames_rejected: number;
status: string | null;
}
/** Per-`ValidationStatus` frame counts. */
export interface ValidationBreakdown {
pending: number;
accepted: number;
degraded: number;
rejected: number;
recovered: number;
}
/** A source's capability descriptor (channels / bandwidths / expected subcarrier counts). */
export interface AdapterProfile {
adapter_kind: AdapterKind;
/** Chip string, e.g. `"bcm43455c0 (pi5)"`, or `null`. */
chip: string | null;
firmware_version: string | null;
driver_version: string | null;
supported_channels: number[];
supported_bandwidths_mhz: number[];
expected_subcarrier_counts: number[];
supports_live_capture: boolean;
supports_injection: boolean;
supports_monitor_mode: boolean;
}
/** Compact summary of a `.rvcsi` capture file. */
export interface CaptureSummary {
capture_version: number;
session_id: number;
source_id: string;
adapter_kind: string;
/** The header's adapter-profile `chip` string, if any (e.g. `"bcm43455c0 (pi5)"`). */
chip: string | null;
frame_count: number;
first_timestamp_ns: number;
last_timestamp_ns: number;
channels: number[];
subcarrier_counts: number[];
mean_quality: number;
validation_breakdown: ValidationBreakdown;
calibration_version: string | null;
}
/** Compact summary of a nexmon_csi `.pcap` capture. */
export interface NexmonPcapSummary {
/** libpcap link-layer type (1 = Ethernet, 101/228 = raw IPv4, 113 = Linux SLL, ...). */
link_type: number;
csi_frame_count: number;
/** Non-CSI / skipped UDP packets (wrong port, not IPv4/UDP, bad nexmon magic). */
skipped_packets: number;
first_timestamp_ns: number;
last_timestamp_ns: number;
channels: number[];
bandwidths_mhz: number[];
subcarrier_counts: number[];
/** Distinct chip-version words (e.g. 0x4345 = the BCM4345 family). */
chip_versions: number[];
/** Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5). */
chip_names: string[];
/** The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture. */
detected_chip: string;
/** `[min, max]` RSSI in dBm, or `null` for an empty capture. */
rssi_dbm_range: [number, number] | null;
}
/** A decoded Broadcom d11ac chanspec word. */
export interface DecodedChanspec {
/** The raw 16-bit chanspec value. */
chanspec: number;
/** `chanspec & 0xff`. */
channel: number;
/** 20 / 40 / 80 / 160, or 0 if the bandwidth bits are unrecognised. */
bandwidth_mhz: number;
is_5ghz: boolean;
}
/** One Nexmon-supported chip in the {@link nexmonChips} listing. */
export interface NexmonChipInfo {
/** Slug, e.g. `"bcm43455c0"`. */
slug: string;
/** Human description incl. a typical host device. */
description: string;
/** Whether the chip supports the 5 GHz band. */
dualBand: boolean;
/** Whether its firmware exports CSI in the modern int16 I/Q format. */
int16IqExport: boolean;
bandwidthsMhz: number[];
expectedSubcarrierCounts: number[];
}
/** One Raspberry Pi model in the {@link nexmonChips} listing. */
export interface RaspberryPiModelInfo {
/** Slug, e.g. `"pi5"`. */
slug: string;
/** The chip on this board (`"bcm43455c0"` for the Pi 5), or `null` if not CSI-capable. */
chip: string | null;
csiSupported: boolean;
}
/** The {@link nexmonChips} listing. */
export interface NexmonChipsListing {
chips: NexmonChipInfo[];
raspberryPiModels: RaspberryPiModelInfo[];
}
/** rvCSI runtime version string. */
export function rvcsiVersion(): string;
/** ABI version of the linked napi-c Nexmon shim (`major<<16 | minor`). */
export function nexmonShimAbiVersion(): number;
/**
* Decode a Buffer of "rvCSI Nexmon records" (the napi-c shim format) into
* validated frames. Throws on a malformed record.
*/
export function nexmonDecodeRecords(
buf: Buffer | Uint8Array,
sourceId: string,
sessionId: number,
): CsiFrame[];
/** Summarize a `.rvcsi` capture file. */
export function inspectCaptureFile(path: string): CaptureSummary;
/** Replay a `.rvcsi` capture through the DSP + event pipeline. */
export function eventsFromCaptureFile(path: string): CsiEvent[];
/** Window a capture and store each window's embedding into a JSONL RF-memory file; returns the count. */
export function exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number;
/**
* Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` buffer
* into validated frames. `port` defaults to 5500. `chip` (`'pi5'`,
* `'bcm43455c0'`, ...) validates against that device's profile and drops the
* non-conforming frames. Throws on a non-pcap buffer or an unknown `chip`.
*/
export function nexmonDecodePcap(
pcap: Buffer | Uint8Array,
sourceId: string,
sessionId: number,
port?: number,
chip?: string,
): CsiFrame[];
/** Summarize a nexmon_csi `.pcap` file. `port` defaults to 5500. */
export function inspectNexmonPcap(path: string, port?: number): NexmonPcapSummary;
/** Decode a Broadcom d11ac chanspec word. */
export function decodeChanspec(chanspec: number): DecodedChanspec;
/**
* Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug
* (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise).
*/
export function nexmonChipName(chipVer: number): string;
/**
* The {@link AdapterProfile} for a chip / Raspberry-Pi-model spec (`'pi5'`,
* `'bcm43455c0'`, `'raspberry pi 4'`, ...). Throws on an unknown spec.
*/
export function nexmonProfile(spec: string): AdapterProfile;
/** Listing of the Nexmon-supported chips + Raspberry Pi models (incl. the Pi 5 → BCM43455c0). */
export function nexmonChips(): NexmonChipsListing;
/** Streaming capture runtime: a source + the DSP stage + the event pipeline. */
export class RvCsi {
private constructor(rt: unknown);
/** Open a `.rvcsi` capture file. */
static openCaptureFile(path: string): RvCsi;
/** Open a Nexmon capture file (concatenated rvCSI Nexmon records). */
static openNexmonFile(path: string, sourceId: string, sessionId: number): RvCsi;
/** Open a real nexmon_csi `.pcap` capture. `port` defaults to 5500. */
static openNexmonPcap(path: string, sourceId: string, sessionId: number, port?: number): RvCsi;
/** Next exposable, validated frame, or `null` at end-of-stream. */
nextFrame(): CsiFrame | null;
/** Like {@link RvCsi.nextFrame} but with the DSP pipeline applied. */
nextCleanFrame(): CsiFrame | null;
/** Drain the rest of the stream through DSP + the event pipeline. */
drainEvents(): CsiEvent[];
/** Current health snapshot. */
health(): SourceHealth;
/** Frames pulled from the source so far. */
readonly framesSeen: number;
/** Frames dropped by validation so far. */
readonly framesDropped: number;
}
+251
View File
@@ -0,0 +1,251 @@
'use strict';
// rvCSI Node.js SDK — curated public surface over the napi-rs addon.
//
// The compiled addon (and its loader `binding.js`) are produced by
// `napi build --platform --release --js binding.js --dts binding.d.ts`
// in this directory (see package.json `build` script). Until that's run,
// `require('@ruv/rvcsi')` still succeeds — only the calls that touch the
// native code throw, with a message explaining how to build it.
//
// Everything the Rust side returns as JSON is parsed here so callers get
// plain objects (CsiFrame / CsiWindow / CsiEvent / SourceHealth /
// CaptureSummary — see index.d.ts).
let _binding = null;
let _bindingError = null;
function binding() {
if (_binding) return _binding;
if (_bindingError) throw _bindingError;
try {
// The @napi-rs/cli loader (resolves the right prebuilt .node for this platform).
_binding = require('./binding.js');
} catch (e1) {
try {
// Fallback: a sibling .node placed next to this file (e.g. a debug build).
_binding = require('./rvcsi-node.node');
} catch (e2) {
_bindingError = new Error(
'rvcsi: the native addon is not built. Build it with ' +
'`npm run build` here, or `napi build --platform --release ' +
'--js binding.js --dts binding.d.ts` in v2/crates/rvcsi-node ' +
'(needs the Rust toolchain + @napi-rs/cli). ' +
'Loader error: ' + e1.message + ' | fallback error: ' + e2.message,
);
throw _bindingError;
}
}
return _binding;
}
const u32 = (n) => Number(n) >>> 0;
/** rvCSI runtime version string. @returns {string} */
function rvcsiVersion() {
return binding().rvcsiVersion();
}
/** ABI version of the linked napi-c Nexmon shim (`major<<16 | minor`). @returns {number} */
function nexmonShimAbiVersion() {
return binding().nexmonShimAbiVersion();
}
/**
* Decode a Buffer of "rvCSI Nexmon records" (the napi-c shim format) into an
* array of validated CsiFrame objects.
* @param {Buffer|Uint8Array} buf
* @param {string} sourceId
* @param {number} sessionId
* @returns {import('./index').CsiFrame[]}
*/
function nexmonDecodeRecords(buf, sourceId, sessionId) {
return JSON.parse(binding().nexmonDecodeRecords(buf, String(sourceId), u32(sessionId)));
}
/**
* Summarize a `.rvcsi` capture file.
* @param {string} path
* @returns {import('./index').CaptureSummary}
*/
function inspectCaptureFile(path) {
return JSON.parse(binding().inspectCaptureFile(String(path)));
}
/**
* Replay a `.rvcsi` capture through the DSP + event pipeline.
* @param {string} path
* @returns {import('./index').CsiEvent[]}
*/
function eventsFromCaptureFile(path) {
return JSON.parse(binding().eventsFromCaptureFile(String(path)));
}
/**
* Window a capture and store each window's embedding into a JSONL RF-memory file.
* @param {string} capturePath
* @param {string} outJsonlPath
* @returns {number} windows stored
*/
function exportCaptureToRfMemory(capturePath, outJsonlPath) {
return binding().exportCaptureToRfMemory(String(capturePath), String(outJsonlPath));
}
/**
* Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` buffer
* (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into validated CsiFrame objects.
* @param {Buffer|Uint8Array} pcap
* @param {string} sourceId
* @param {number} sessionId
* @param {number} [port] CSI UDP port (default 5500)
* @param {string} [chip] chip / Raspberry-Pi-model spec to validate against
* (e.g. `'pi5'`, `'bcm43455c0'`); non-conforming frames are dropped
* @returns {import('./index').CsiFrame[]}
*/
function nexmonDecodePcap(pcap, sourceId, sessionId, port, chip) {
return JSON.parse(
binding().nexmonDecodePcap(
pcap,
String(sourceId),
u32(sessionId),
port == null ? undefined : Number(port),
chip == null ? undefined : String(chip),
),
);
}
/**
* Summarize a nexmon_csi `.pcap` file (link type, CSI frame count, channels,
* bandwidths, chip versions + resolved chip names, RSSI range, time span).
* @param {string} path
* @param {number} [port] CSI UDP port (default 5500)
* @returns {import('./index').NexmonPcapSummary}
*/
function inspectNexmonPcap(path, port) {
return JSON.parse(binding().inspectNexmonPcap(String(path), port == null ? undefined : Number(port)));
}
/**
* Decode a Broadcom d11ac chanspec word.
* @param {number} chanspec
* @returns {import('./index').DecodedChanspec}
*/
function decodeChanspec(chanspec) {
return JSON.parse(binding().decodeChanspec(u32(chanspec)));
}
/**
* Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug
* (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise).
* @param {number} chipVer
* @returns {string}
*/
function nexmonChipName(chipVer) {
return binding().nexmonChipName(u32(chipVer));
}
/**
* The AdapterProfile (channels / bandwidths / expected subcarrier counts /
* capability flags) for a chip / Raspberry-Pi-model spec (`'pi5'`,
* `'bcm43455c0'`, ...). Throws on an unknown spec.
* @param {string} spec
* @returns {import('./index').AdapterProfile}
*/
function nexmonProfile(spec) {
return JSON.parse(binding().nexmonProfile(String(spec)));
}
/**
* Listing of the Nexmon-supported chips + the Raspberry Pi models that carry
* them (incl. the Pi 5 → BCM43455c0).
* @returns {import('./index').NexmonChipsListing}
*/
function nexmonChips() {
return JSON.parse(binding().nexmonChips());
}
/** Streaming capture runtime: a source + the DSP stage + the event pipeline. */
class RvCsi {
/** @param {*} rt the underlying napi RvcsiRuntime handle */
constructor(rt) {
/** @private */
this._rt = rt;
}
/** Open a `.rvcsi` capture file. @param {string} path @returns {RvCsi} */
static openCaptureFile(path) {
return new RvCsi(binding().RvcsiRuntime.openCaptureFile(String(path)));
}
/**
* Open a Nexmon capture file (concatenated rvCSI Nexmon records).
* @param {string} path @param {string} sourceId @param {number} sessionId @returns {RvCsi}
*/
static openNexmonFile(path, sourceId, sessionId) {
return new RvCsi(binding().RvcsiRuntime.openNexmonFile(String(path), String(sourceId), u32(sessionId)));
}
/**
* Open a real nexmon_csi `.pcap` capture.
* @param {string} path @param {string} sourceId @param {number} sessionId
* @param {number} [port] CSI UDP port (default 5500) @returns {RvCsi}
*/
static openNexmonPcap(path, sourceId, sessionId, port) {
return new RvCsi(
binding().RvcsiRuntime.openNexmonPcap(
String(path),
String(sourceId),
u32(sessionId),
port == null ? undefined : Number(port),
),
);
}
/** Next exposable, validated frame, or `null` at end-of-stream. @returns {import('./index').CsiFrame|null} */
nextFrame() {
const s = this._rt.nextFrameJson();
return s == null ? null : JSON.parse(s);
}
/** Like {@link RvCsi#nextFrame} but with the DSP pipeline applied. @returns {import('./index').CsiFrame|null} */
nextCleanFrame() {
const s = this._rt.nextCleanFrameJson();
return s == null ? null : JSON.parse(s);
}
/** Drain the rest of the stream through DSP + the event pipeline. @returns {import('./index').CsiEvent[]} */
drainEvents() {
return JSON.parse(this._rt.drainEventsJson());
}
/** Current health snapshot. @returns {import('./index').SourceHealth} */
health() {
return JSON.parse(this._rt.healthJson());
}
/** Frames pulled from the source so far. @returns {number} */
get framesSeen() {
return this._rt.framesSeen;
}
/** Frames dropped by validation so far. @returns {number} */
get framesDropped() {
return this._rt.framesDropped;
}
}
module.exports = {
rvcsiVersion,
nexmonShimAbiVersion,
nexmonDecodeRecords,
nexmonDecodePcap,
inspectNexmonPcap,
decodeChanspec,
nexmonChipName,
nexmonProfile,
nexmonChips,
inspectCaptureFile,
eventsFromCaptureFile,
exportCaptureToRfMemory,
RvCsi,
};
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@ruv/rvcsi",
"version": "0.3.0",
"description": "rvCSI — edge RF sensing runtime: Node.js bindings (napi-rs) over the Rust CSI pipeline (ADR-095, ADR-096)",
"keywords": ["wifi", "csi", "rf-sensing", "presence", "napi-rs", "rvcsi"],
"license": "MIT OR Apache-2.0",
"repository": "https://github.com/ruvnet/wifi-densepose",
"main": "index.js",
"types": "index.d.ts",
"engines": {
"node": ">=14"
},
"files": [
"index.js",
"index.d.ts",
"binding.js",
"binding.d.ts",
"README.md",
"*.node"
],
"napi": {
"name": "rvcsi-node",
"triples": {
"defaults": true
}
},
"scripts": {
"build": "napi build --platform --release --js binding.js --dts binding.d.ts",
"build:debug": "napi build --platform --js binding.js --dts binding.d.ts",
"test": "node --test"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
}
}
+270
View File
@@ -0,0 +1,270 @@
//! # rvCSI Node.js bindings — napi-rs (ADR-095 D3/D4, ADR-096)
//!
//! The safe TypeScript-facing surface over the rvCSI Rust runtime. Nothing here
//! exposes raw pointers; every value that crosses the boundary is either a
//! normalized rvCSI struct *serialized to JSON* or a scalar. Frames are run
//! through [`rvcsi_core::validate_frame`] inside [`rvcsi_runtime`] before they
//! reach JS (D6), so a JS caller never sees a `Pending` or `Rejected` frame.
//!
//! All real logic lives in the `rvcsi-runtime` crate (plain Rust, unit-tested
//! without a Node env); the `#[napi]` items below are one-liner wrappers.
//!
//! ## JS surface (also see the generated `index.d.ts` in the npm package)
//!
//! Free functions:
//! * `rvcsiVersion(): string`
//! * `nexmonShimAbiVersion(): number` — ABI of the linked napi-c shim
//! * `nexmonDecodeRecords(buf: Buffer, sourceId: string, sessionId: number): string`
//! — JSON array of validated `CsiFrame`s decoded from the C-shim record format
//! * `inspectCaptureFile(path: string): string` — JSON `CaptureSummary`
//! * `eventsFromCaptureFile(path: string): string` — JSON array of `CsiEvent`s
//! * `exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number`
//! — windows stored
//!
//! Class `RvcsiRuntime` (streaming):
//! * `RvcsiRuntime.openCaptureFile(path): RvcsiRuntime`
//! * `RvcsiRuntime.openNexmonFile(path, sourceId, sessionId): RvcsiRuntime`
//! * `.nextFrameJson(): string | null` / `.nextCleanFrameJson(): string | null`
//! * `.drainEventsJson(): string` — JSON array of `CsiEvent`s
//! * `.healthJson(): string` — JSON `SourceHealth`
//! * `.framesSeen` / `.framesDropped` (getters)
#![deny(clippy::all)]
#[macro_use]
extern crate napi_derive;
use napi::bindgen_prelude::Buffer;
use rvcsi_runtime::{self as runtime, CaptureRuntime};
fn napi_err(e: impl std::fmt::Display) -> napi::Error {
napi::Error::from_reason(e.to_string())
}
fn to_json<T: serde::Serialize>(v: &T) -> napi::Result<String> {
serde_json::to_string(v).map_err(napi_err)
}
// ---------------------------------------------------------------------------
// Free functions
// ---------------------------------------------------------------------------
/// rvCSI runtime version (the workspace crate version).
#[napi]
pub fn rvcsi_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
/// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`).
#[napi]
pub fn nexmon_shim_abi_version() -> u32 {
runtime::nexmon_shim_abi_version()
}
/// Decode a `Buffer` of "rvCSI Nexmon records" (the napi-c shim format) into a
/// JSON array of validated `CsiFrame`s. Throws on a malformed record.
#[napi]
pub fn nexmon_decode_records(buf: Buffer, source_id: String, session_id: u32) -> napi::Result<String> {
let frames = runtime::decode_nexmon_records(buf.as_ref(), &source_id, session_id as u64).map_err(napi_err)?;
to_json(&frames)
}
/// Summarize a `.rvcsi` capture file; returns JSON for a `CaptureSummary`.
#[napi]
pub fn inspect_capture_file(path: String) -> napi::Result<String> {
let summary = runtime::summarize_capture(&path).map_err(napi_err)?;
to_json(&summary)
}
/// Replay a `.rvcsi` capture through the DSP + event pipeline; returns a JSON
/// array of `CsiEvent`s.
#[napi]
pub fn events_from_capture_file(path: String) -> napi::Result<String> {
let events = runtime::events_from_capture(&path).map_err(napi_err)?;
to_json(&events)
}
/// Replay a `.rvcsi` capture, window it, and store each window's embedding into
/// a JSONL RF-memory file; returns the number of windows stored.
#[napi]
pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) -> napi::Result<u32> {
let n = runtime::export_capture_to_rf_memory(&capture_path, &out_jsonl_path).map_err(napi_err)?;
Ok(n as u32)
}
/// Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` `Buffer`
/// into a JSON array of validated `CsiFrame`s. `port` is the CSI UDP port
/// (omit / `null` ⇒ 5500); `chip` is an optional chip / Raspberry-Pi-model spec
/// (`"pi5"`, `"bcm43455c0"`, ...) — when given, frames are validated against
/// that device's profile and the non-conforming ones dropped. Throws if the
/// buffer isn't a parseable classic pcap or `chip` is unrecognised.
#[napi]
pub fn nexmon_decode_pcap(
pcap: Buffer,
source_id: String,
session_id: u32,
port: Option<u16>,
chip: Option<String>,
) -> napi::Result<String> {
let frames = runtime::decode_nexmon_pcap_for(pcap.as_ref(), &source_id, session_id as u64, port, chip.as_deref())
.map_err(napi_err)?;
to_json(&frames)
}
/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels,
/// bandwidths, chip versions + resolved chip names, RSSI range, time span);
/// returns JSON for a `NexmonPcapSummary`. `port` defaults to 5500.
#[napi]
pub fn inspect_nexmon_pcap(path: String, port: Option<u16>) -> napi::Result<String> {
let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?;
to_json(&summary)
}
/// Decode a Broadcom d11ac chanspec word; returns JSON
/// `{ chanspec, channel, bandwidth_mhz, is_5ghz }`.
#[napi]
pub fn decode_chanspec(chanspec: u32) -> napi::Result<String> {
let d = rvcsi_adapter_nexmon::decode_chanspec((chanspec & 0xFFFF) as u16);
to_json(&serde_json::json!({
"chanspec": d.chanspec,
"channel": d.channel,
"bandwidth_mhz": d.bandwidth_mhz,
"is_5ghz": d.is_5ghz,
}))
}
/// Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug
/// (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise).
#[napi]
pub fn nexmon_chip_name(chip_ver: u32) -> String {
rvcsi_adapter_nexmon::NexmonChip::from_chip_ver((chip_ver & 0xFFFF) as u16).slug()
}
/// The `AdapterProfile` (channels / bandwidths / expected subcarrier counts /
/// capability flags) for a chip / Raspberry-Pi-model spec (`"pi5"`,
/// `"bcm43455c0"`, `"raspberry pi 4"`, ...); returns JSON. Throws if unknown.
#[napi]
pub fn nexmon_profile(spec: String) -> napi::Result<String> {
let p = runtime::nexmon_profile_for(&spec)
.ok_or_else(|| napi::Error::from_reason(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?;
to_json(&p)
}
/// JSON listing of the Nexmon-supported chips + the Raspberry Pi models that
/// carry them (incl. the Pi 5 → BCM43455c0): `{ chips: [...], raspberryPiModels: [...] }`.
#[napi]
pub fn nexmon_chips() -> napi::Result<String> {
use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip};
let chips: Vec<_> = known_chips()
.iter()
.map(|c| {
let p = nexmon_adapter_profile(*c);
serde_json::json!({
"slug": c.slug(), "description": c.description(),
"dualBand": c.dual_band(), "int16IqExport": c.uses_int16_iq(),
"bandwidthsMhz": p.supported_bandwidths_mhz,
"expectedSubcarrierCounts": p.expected_subcarrier_counts,
})
})
.collect();
let pis: Vec<_> = known_pi_models()
.iter()
.map(|m| {
let chip = m.nexmon_chip();
serde_json::json!({
"slug": m.slug(),
"chip": if matches!(chip, NexmonChip::Unknown { .. }) { serde_json::Value::Null } else { serde_json::Value::String(chip.slug()) },
"csiSupported": m.csi_supported(),
})
})
.collect();
to_json(&serde_json::json!({ "chips": chips, "raspberryPiModels": pis }))
}
// ---------------------------------------------------------------------------
// Streaming runtime class
// ---------------------------------------------------------------------------
/// A streaming capture runtime: a source + the DSP stage + the event pipeline.
#[napi]
pub struct RvcsiRuntime {
inner: CaptureRuntime,
}
#[napi]
impl RvcsiRuntime {
/// Open a `.rvcsi` capture file as the source.
#[napi(factory)]
pub fn open_capture_file(path: String) -> napi::Result<RvcsiRuntime> {
Ok(RvcsiRuntime {
inner: CaptureRuntime::open_capture_file(&path).map_err(napi_err)?,
})
}
/// Open a Nexmon capture file (concatenated rvCSI Nexmon records) as the source.
#[napi(factory)]
pub fn open_nexmon_file(path: String, source_id: String, session_id: u32) -> napi::Result<RvcsiRuntime> {
Ok(RvcsiRuntime {
inner: CaptureRuntime::open_nexmon_file(&path, &source_id, session_id as u64).map_err(napi_err)?,
})
}
/// Open a real nexmon_csi `.pcap` capture as the source. `port` is the CSI
/// UDP port (omit / `null` ⇒ 5500).
#[napi(factory)]
pub fn open_nexmon_pcap(
path: String,
source_id: String,
session_id: u32,
port: Option<u16>,
) -> napi::Result<RvcsiRuntime> {
Ok(RvcsiRuntime {
inner: CaptureRuntime::open_nexmon_pcap(&path, &source_id, session_id as u64, port)
.map_err(napi_err)?,
})
}
/// Next exposable, validated frame as JSON, or `null` at end-of-stream.
#[napi]
pub fn next_frame_json(&mut self) -> napi::Result<Option<String>> {
match self.inner.next_validated_frame().map_err(napi_err)? {
Some(f) => Ok(Some(to_json(&f)?)),
None => Ok(None),
}
}
/// Like `nextFrameJson` but with the DSP pipeline applied (cleaned amplitude/phase).
#[napi]
pub fn next_clean_frame_json(&mut self) -> napi::Result<Option<String>> {
match self.inner.next_clean_frame().map_err(napi_err)? {
Some(f) => Ok(Some(to_json(&f)?)),
None => Ok(None),
}
}
/// Drain the rest of the stream through DSP + the event pipeline; JSON array of `CsiEvent`s.
#[napi]
pub fn drain_events_json(&mut self) -> napi::Result<String> {
let events = self.inner.drain_events().map_err(napi_err)?;
to_json(&events)
}
/// Health snapshot as JSON (`SourceHealth`).
#[napi]
pub fn health_json(&self) -> napi::Result<String> {
to_json(&self.inner.health())
}
/// Frames pulled from the source so far.
#[napi(getter)]
pub fn frames_seen(&self) -> u32 {
self.inner.frames_seen() as u32
}
/// Frames dropped by validation so far.
#[napi(getter)]
pub fn frames_dropped(&self) -> u32 {
self.inner.frames_dropped() as u32
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "rvcsi-runtime"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI runtime composition — wires a CsiSource + DSP + the event pipeline + RuVector export; the shared layer under rvcsi-node and rvcsi-cli (ADR-096)"
repository.workspace = true
keywords = ["wifi", "csi", "rvcsi", "runtime"]
categories = ["science"]
[dependencies]
rvcsi-core = { path = "../rvcsi-core" }
rvcsi-dsp = { path = "../rvcsi-dsp" }
rvcsi-events = { path = "../rvcsi-events" }
rvcsi-adapter-file = { path = "../rvcsi-adapter-file" }
rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" }
rvcsi-ruvector = { path = "../rvcsi-ruvector" }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = "3.10"
+350
View File
@@ -0,0 +1,350 @@
//! A streaming capture runtime: a [`CsiSource`](rvcsi_core::CsiSource) + the DSP
//! stage + the event pipeline, wired together. The `rvcsi-node` napi-rs
//! `RvcsiRuntime` class is a thin `#[napi]` wrapper around [`CaptureRuntime`].
use rvcsi_adapter_file::FileReplayAdapter;
use rvcsi_adapter_nexmon::NexmonAdapter;
use rvcsi_core::{
validate_frame, AdapterProfile, CsiEvent, CsiFrame, CsiSource, RvcsiError, SessionId,
SourceHealth, SourceId, ValidationPolicy, ValidationStatus,
};
use rvcsi_dsp::SignalPipeline;
use rvcsi_events::EventPipeline;
/// Owns a source and the per-frame processing chain.
///
/// `next_validated_frame` pulls from the source and guarantees the returned
/// frame is *exposable* (Accepted/Degraded/Recovered) — frames that arrive
/// `Pending` are validated against the source's profile, and hard-rejected
/// frames are skipped (never surfaced). `drain_events` runs the remainder of the
/// stream through `SignalPipeline` + `EventPipeline`.
pub struct CaptureRuntime {
source: Box<dyn CsiSource>,
profile: AdapterProfile,
policy: ValidationPolicy,
dsp: SignalPipeline,
events: EventPipeline,
prev_ts: Option<u64>,
frames_seen: u64,
frames_dropped: u64,
}
impl CaptureRuntime {
fn new(source: Box<dyn CsiSource>, policy: ValidationPolicy) -> Self {
let profile = source.profile().clone();
let session_id = source.session_id();
let source_id = source.source_id().clone();
CaptureRuntime {
source,
profile,
policy,
dsp: SignalPipeline::default(),
events: EventPipeline::with_defaults(session_id, source_id),
prev_ts: None,
frames_seen: 0,
frames_dropped: 0,
}
}
/// Open a `.rvcsi` capture file as the source.
pub fn open_capture_file(path: &str) -> Result<Self, RvcsiError> {
let source = FileReplayAdapter::open(path)?;
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
}
/// Open a buffer of "rvCSI Nexmon records" (the napi-c shim format) as the source.
pub fn open_nexmon_bytes(bytes: Vec<u8>, source_id: &str, session_id: u64) -> Self {
let source = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes);
// Permissive policy: the C-shim records may carry non-default subcarrier counts.
Self::new(Box::new(source), ValidationPolicy::default())
}
/// Open a Nexmon capture *file* (concatenated records) as the source.
pub fn open_nexmon_file(path: &str, source_id: &str, session_id: u64) -> Result<Self, RvcsiError> {
let bytes = std::fs::read(path)?;
Ok(Self::open_nexmon_bytes(bytes, source_id, session_id))
}
/// Open a real nexmon_csi `.pcap` capture (`tcpdump -i wlan0 dst port 5500 -w …`)
/// as the source. `port` is the CSI UDP port (`None` ⇒ 5500).
pub fn open_nexmon_pcap(
path: &str,
source_id: &str,
session_id: u64,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::open(
SourceId::from(source_id),
SessionId(session_id),
path,
port,
)?;
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
}
/// Open a real nexmon_csi `.pcap` from an in-memory byte buffer.
pub fn open_nexmon_pcap_bytes(
pcap_bytes: &[u8],
source_id: &str,
session_id: u64,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
SourceId::from(source_id),
SessionId(session_id),
pcap_bytes,
port,
)?;
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
}
/// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected.
fn admit(&mut self, mut frame: CsiFrame) -> Option<CsiFrame> {
self.frames_seen += 1;
if frame.validation == ValidationStatus::Pending {
let ts = frame.timestamp_ns;
match validate_frame(&mut frame, &self.profile, &self.policy, self.prev_ts) {
Ok(()) if frame.is_exposable() => {
self.prev_ts = Some(ts);
Some(frame)
}
_ => {
self.frames_dropped += 1;
None
}
}
} else if frame.is_exposable() {
Some(frame)
} else {
self.frames_dropped += 1;
None
}
}
/// Pull the next exposable frame, validating it if necessary. `Ok(None)` at
/// end-of-stream. The frame's `amplitude`/`phase` are NOT yet DSP-cleaned
/// (call [`CaptureRuntime::next_clean_frame`] for that).
pub fn next_validated_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
loop {
match self.source.next_frame()? {
None => return Ok(None),
Some(frame) => {
if let Some(f) = self.admit(frame) {
return Ok(Some(f));
}
}
}
}
}
/// Like [`CaptureRuntime::next_validated_frame`] but with `SignalPipeline`
/// applied (DC removal, phase unwrap, Hampel filter, smoothing).
pub fn next_clean_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
match self.next_validated_frame()? {
None => Ok(None),
Some(mut f) => {
self.dsp.process_frame(&mut f);
Ok(Some(f))
}
}
}
/// Drain the rest of the stream through DSP + the event pipeline and return
/// every emitted event (in order).
pub fn drain_events(&mut self) -> Result<Vec<CsiEvent>, RvcsiError> {
let mut out = Vec::new();
while let Some(mut f) = self.next_validated_frame()? {
self.dsp.process_frame(&mut f);
out.extend(self.events.process_frame(&f));
}
out.extend(self.events.flush());
Ok(out)
}
/// Health snapshot combining the source's view and the runtime's counters.
pub fn health(&self) -> SourceHealth {
let mut h = self.source.health();
// Augment the status with the runtime's drop count.
let extra = format!("frames_seen={}, frames_dropped={}", self.frames_seen, self.frames_dropped);
h.status = Some(match h.status {
Some(s) => format!("{s}; {extra}"),
None => extra,
});
h
}
/// Frames pulled from the source so far.
pub fn frames_seen(&self) -> u64 {
self.frames_seen
}
/// Frames dropped by validation so far.
pub fn frames_dropped(&self) -> u64 {
self.frames_dropped
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_adapter_file::{CaptureHeader, FileRecorder};
use rvcsi_adapter_nexmon::{encode_record, NexmonRecord};
use rvcsi_core::{AdapterKind, FrameId};
fn write_capture(path: &std::path::Path, n: usize) {
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rt"),
AdapterProfile::offline(AdapterKind::File),
);
let mut rec = FileRecorder::create(path, &header).unwrap();
for k in 0..n {
let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 };
let i: Vec<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
let q: Vec<f32> = (0..32).map(|_| 0.5).collect();
let mut f = CsiFrame::from_iq(
FrameId(k as u64),
SessionId(1),
SourceId::from("rt"),
AdapterKind::File,
1_000 + k as u64 * 50_000_000,
6,
20,
i,
q,
)
.with_rssi(-55);
f.validation = ValidationStatus::Accepted;
f.quality_score = 0.9;
rec.write_frame(&f).unwrap();
}
rec.finish().unwrap();
}
#[test]
fn streams_validated_frames_from_a_capture() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 5);
let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap();
let mut count = 0;
while let Some(f) = rt.next_validated_frame().unwrap() {
assert!(f.is_exposable());
count += 1;
}
assert_eq!(count, 5);
assert_eq!(rt.frames_seen(), 5);
assert_eq!(rt.frames_dropped(), 0);
let h = rt.health();
assert!(h.status.unwrap().contains("frames_seen=5"));
}
#[test]
fn clean_frame_applies_dsp_without_changing_validation() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 3);
let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap();
let f = rt.next_clean_frame().unwrap().unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
assert_eq!(f.quality_score, 0.9);
assert_eq!(f.amplitude.len(), 32);
}
#[test]
fn drains_events_from_an_alternating_stream() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), 64);
let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap();
let events = rt.drain_events().unwrap();
assert!(!events.is_empty());
for e in &events {
e.validate().unwrap();
}
}
#[test]
fn runs_a_nexmon_record_stream() {
let mk = |ts: u64| {
let rec = NexmonRecord {
subcarrier_count: 64,
channel: 36,
bandwidth_mhz: 80,
rssi_dbm: Some(-60),
noise_floor_dbm: Some(-92),
timestamp_ns: ts,
i_values: (0..64).map(|k| (k as f32 % 3.0) - 1.0).collect(),
q_values: (0..64).map(|k| (k as f32 % 5.0) * 0.1).collect(),
};
encode_record(&rec).unwrap()
};
let mut buf = Vec::new();
for k in 0..40 {
buf.extend(mk(1_000 + k * 50_000_000));
}
let mut rt = CaptureRuntime::open_nexmon_bytes(buf, "nexmon-rt", 3);
let mut n = 0;
while let Some(f) = rt.next_validated_frame().unwrap() {
assert_eq!(f.adapter_kind, AdapterKind::Nexmon);
assert!(f.is_exposable());
n += 1;
}
assert_eq!(n, 40);
}
#[test]
fn runs_a_real_nexmon_csi_pcap() {
use rvcsi_adapter_nexmon::NexmonCsiHeader;
let chanspec = 0x1000u16 | 6; // 2.4 GHz ch6 20 MHz
let nsub = 64u16;
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..12u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|_| 1.0f32).collect();
(
1_000_000_000 + k * 50_000_000,
NexmonCsiHeader {
rssi_dbm: -55 - k as i16,
fctl: 8,
src_mac: [0, 1, 2, 3, 4, 5],
seq_cnt: k as u16,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 0x4345,
channel: 0,
bandwidth_mhz: 0,
is_5ghz: false,
subcarrier_count: nsub,
},
i,
q,
)
})
.collect();
let pcap = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap();
let mut rt = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "nexmon-pcap-rt", 1, None).unwrap();
let mut got = 0;
while let Some(f) = rt.next_validated_frame().unwrap() {
assert_eq!(f.adapter_kind, AdapterKind::Nexmon);
assert_eq!(f.channel, 6);
assert_eq!(f.bandwidth_mhz, 20);
assert!(f.is_exposable());
got += 1;
}
assert_eq!(got, 12);
let events = {
let mut rt2 = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "n", 2, None).unwrap();
rt2.drain_events().unwrap()
};
for e in &events {
e.validate().unwrap();
}
}
#[test]
fn missing_file_is_an_error() {
assert!(CaptureRuntime::open_capture_file("/nope/x.rvcsi").is_err());
assert!(CaptureRuntime::open_nexmon_file("/nope/x.bin", "s", 0).is_err());
assert!(CaptureRuntime::open_nexmon_pcap("/nope/x.pcap", "s", 0, None).is_err());
assert!(CaptureRuntime::open_nexmon_pcap_bytes(&[0u8; 8], "s", 0, None).is_err());
}
}

Some files were not shown because too many files have changed in this diff Show More