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
This commit is contained in:
Claude 2026-05-12 23:49:58 +00:00
parent d98b7e3f65
commit 1e684cb208
No known key found for this signature in database
30 changed files with 2977 additions and 7 deletions

289
v2/Cargo.lock generated
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,100 @@ 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-dsp",
"rvcsi-events",
"rvcsi-ruvector",
"serde",
"serde_json",
]
[[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",
"rvcsi-dsp",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "rvcsi-node"
version = "0.3.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"rvcsi-adapter-file",
"rvcsi-adapter-nexmon",
"rvcsi-core",
"rvcsi-dsp",
"rvcsi-events",
"rvcsi-ruvector",
"serde",
"serde_json",
]
[[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 +7630,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 +7673,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 +7707,7 @@ dependencies = [
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
@ -8385,6 +8659,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tower-http 0.5.2",
]
[[package]]
@ -8454,7 +8729,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-train"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"approx 0.5.1",

View File

@ -21,6 +21,15 @@ 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-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 +117,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"

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"

View File

@ -0,0 +1,7 @@
//! # rvCSI file/replay adapter (skeleton — implemented by the adapters swarm agent)
//!
//! Records and replays `.rvcsi` capture sessions deterministically (ADR-095 D9).
#![forbid(unsafe_code)]
/// Placeholder so the crate compiles before the agent fills it in.
pub fn __rvcsi_adapter_file_placeholder() {}

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 }

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");
}

View File

@ -0,0 +1,159 @@
/*
* rvCSI Nexmon CSI compatibility shim implementation (napi-c layer).
* See rvcsi_nexmon_shim.h for the record layout and contract.
*
* Deliberately tiny, allocation-free, and dependency-free (libc only). Every
* read is bounds-checked; nothing here can scribble outside caller buffers.
*/
#include "rvcsi_nexmon_shim.h"
#include <string.h>
#define RVCSI_NX_ABI 0x00010000u /* major.minor = 1.0 */
/* ---- 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. */
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;
/* round to nearest, ties away from zero */
if (scaled >= 0.0f)
return (int16_t)(scaled + 0.5f);
return (int16_t)(scaled - 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";
default: return "unknown error";
}
}
/* Validate the header at buf[0..24); on success return N (subcarrier count) and
* the total record size via *out_total. On failure return a negative
* RvcsiNxError. */
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;
}

View File

@ -0,0 +1,112 @@
/*
* rvCSI Nexmon CSI compatibility shim (napi-c layer, ADR-095 D2, ADR-096).
*
* This is the ONLY C in the rvCSI runtime. It parses (and, for tests, writes)
* a compact, byte-defined "rvCSI Nexmon record" a normalized superset of the
* nexmon_csi UDP payload (magic, RSSI, chanspec, then interleaved int16 I/Q).
* The Rust side (`rvcsi-adapter-nexmon`) wraps these functions and never sees
* raw vendor structs; everything above this file is safe Rust.
*
* Record 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 record size = 24 + 4*N bytes
*
* Fixed-point: stored int16 value v maps to float v / 256.0.
*/
#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
/* Sentinel for "metadata field absent". */
#define RVCSI_NX_ABSENT_I16 ((int16_t)0x7FFF)
/* Error codes returned (negated) by rvcsi_nx_parse_record / rvcsi_nx_write_record. */
typedef enum {
RVCSI_NX_OK = 0,
RVCSI_NX_ERR_TOO_SHORT = 1, /* buffer shorter than the header */
RVCSI_NX_ERR_BAD_MAGIC = 2, /* magic mismatch */
RVCSI_NX_ERR_BAD_VERSION = 3, /* unsupported version */
RVCSI_NX_ERR_CAPACITY = 4, /* caller i/q buffer too small for N */
RVCSI_NX_ERR_TRUNCATED = 5, /* buffer shorter than 24 + 4*N */
RVCSI_NX_ERR_ZERO_SUBCARRIERS = 6,
RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS = 7,
RVCSI_NX_ERR_NULL_ARG = 8
} 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;
/*
* Length, in bytes, of the record that starts at `buf`, given `len` bytes are
* available. Returns 0 if `len` is too small to even read the header, the magic
* is wrong, the version is unsupported, the subcarrier count is out of range,
* or `len` < the full record. On success returns 24 + 4*N (>= 28).
*/
size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len);
/*
* Parse one record at `buf` (with `len` bytes available). Fills `*meta` and
* writes `subcarrier_count` floats into each of `i_out` and `q_out` (which must
* each have capacity `cap`). Returns RVCSI_NX_OK (0) on success, or one of the
* RvcsiNxError codes (positive) on failure. 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 record into `buf` (capacity `cap`). `i_in`/`q_in` hold
* `meta->subcarrier_count` floats each (clamped to the Q8.8 range). Returns the
* number of bytes written (24 + 4*N) on success, or 0 on error (null arg, zero
* or too-many subcarriers, capacity too small). Used by Rust tests and the
* `rvcsi capture` recorder; production capture comes straight off the wire.
*/
size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta,
const float *i_in, const float *q_in);
/* Static, human-readable string for an RvcsiNxError code. Never NULL. */
const char *rvcsi_nx_strerror(int code);
/* ABI version of this shim — bumped if the record layout or function
* signatures change. The Rust side asserts it matches at startup. */
uint32_t rvcsi_nx_abi_version(void);
#ifdef __cplusplus
}
#endif
#endif /* RVCSI_NEXMON_SHIM_H */

View File

@ -0,0 +1,256 @@
//! 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_strerror(code: i32) -> *const c_char;
fn rvcsi_nx_abi_version() -> u32;
}
/// 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)
}
#[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());
}
}

View File

@ -0,0 +1,278 @@
//! # 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.
//!
//! Typical use: a capture dump (a file of concatenated records, or the bytes of
//! a UDP stream demux) is fed to [`NexmonAdapter::from_bytes`], which yields
//! `Pending` [`CsiFrame`]s. The runtime then 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 ffi;
pub use ffi::{decode_record, encode_record, shim_abi_version, NexmonRecord, RECORD_HEADER_BYTES};
/// 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(),
}
}
}
#[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_oh() {
assert_eq!(shim_abi_version(), 0x0001_0000);
}
#[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);
}
}

View File

@ -0,0 +1,26 @@
[package]
name = "rvcsi-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI command-line tool — inspect, replay, stream, calibrate, health, 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-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" }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }

View File

@ -0,0 +1,33 @@
//! `rvcsi` — the rvCSI command-line tool (skeleton; completed during integration).
//!
//! Subcommands (ADR-095 FR7): `inspect`, `replay`, `stream`, `calibrate`,
//! `health`, `export`. The skeleton wires `inspect` so the binary is usable;
//! the rest are filled in by the CLI swarm agent / integration step.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Print a summary of a capture file (frame count, channels, quality).
Inspect {
/// Path to a `.rvcsi` capture file.
path: String,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Inspect { path } => {
println!("rvcsi inspect: {path} (not yet implemented in the skeleton)");
Ok(())
}
}
}

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 }

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

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"));
}
}

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

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

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

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

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 { .. }));
}
}

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

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 }

View File

@ -0,0 +1,7 @@
//! # rvCSI DSP (skeleton — implemented by the rvcsi-dsp swarm agent)
//!
//! Reusable signal-processing stages for the rvCSI runtime (ADR-095 FR4).
#![forbid(unsafe_code)]
/// Placeholder so the crate compiles before the agent fills it in.
pub fn __rvcsi_dsp_placeholder() {}

View File

@ -0,0 +1,20 @@
[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" }
rvcsi-dsp = { path = "../rvcsi-dsp" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@ -0,0 +1,7 @@
//! # rvCSI events (skeleton — implemented by the rvcsi-events swarm agent)
//!
//! Window aggregation and semantic event extraction (ADR-095 FR5).
#![forbid(unsafe_code)]
/// Placeholder so the crate compiles before the agent fills it in.
pub fn __rvcsi_events_placeholder() {}

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-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 }
[build-dependencies]
napi-build = { workspace = true }

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();
}

View File

@ -0,0 +1,22 @@
//! # rvCSI Node.js bindings — napi-rs (skeleton; completed during integration)
//!
//! The safe TypeScript-facing surface over the rvCSI Rust runtime
//! (ADR-095 D3/D4, ADR-096). Nothing here exposes raw pointers; every value
//! that crosses the boundary is a validated/normalized struct or a JSON string.
#![deny(clippy::all)]
#[macro_use]
extern crate napi_derive;
/// 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 {
rvcsi_adapter_nexmon::shim_abi_version()
}

View File

@ -0,0 +1,20 @@
[package]
name = "rvcsi-ruvector"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "rvCSI RuVector bridge — exports temporal RF embeddings + event metadata as a queryable RF-memory store (ADR-095 FR8, D8)"
repository.workspace = true
keywords = ["wifi", "csi", "ruvector", "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"

View File

@ -0,0 +1,8 @@
//! # rvCSI RuVector bridge (skeleton — implemented by the rvcsi-ruvector swarm agent)
//!
//! Exports temporal RF embeddings + event metadata as a queryable RF-memory
//! store (ADR-095 FR8, D8).
#![forbid(unsafe_code)]
/// Placeholder so the crate compiles before the agent fills it in.
pub fn __rvcsi_ruvector_placeholder() {}