From 1e684cb208055ad2596db0f14efc5205ae8bbf74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 23:49:58 +0000 Subject: [PATCH] feat(rvcsi): rvcsi-core + napi-c Nexmon shim + crate skeletons (ADR-095/096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 289 +++++++++++- v2/Cargo.toml | 16 + v2/crates/rvcsi-adapter-file/Cargo.toml | 20 + v2/crates/rvcsi-adapter-file/src/lib.rs | 7 + v2/crates/rvcsi-adapter-nexmon/Cargo.toml | 19 + v2/crates/rvcsi-adapter-nexmon/build.rs | 18 + .../native/rvcsi_nexmon_shim.c | 159 +++++++ .../native/rvcsi_nexmon_shim.h | 112 +++++ v2/crates/rvcsi-adapter-nexmon/src/ffi.rs | 256 +++++++++++ v2/crates/rvcsi-adapter-nexmon/src/lib.rs | 278 ++++++++++++ v2/crates/rvcsi-cli/Cargo.toml | 26 ++ v2/crates/rvcsi-cli/src/main.rs | 33 ++ v2/crates/rvcsi-core/Cargo.toml | 18 + v2/crates/rvcsi-core/src/adapter.rs | 293 ++++++++++++ v2/crates/rvcsi-core/src/error.rs | 86 ++++ v2/crates/rvcsi-core/src/event.rs | 189 ++++++++ v2/crates/rvcsi-core/src/frame.rs | 229 ++++++++++ v2/crates/rvcsi-core/src/ids.rs | 170 +++++++ v2/crates/rvcsi-core/src/lib.rs | 35 ++ v2/crates/rvcsi-core/src/validation.rs | 420 ++++++++++++++++++ v2/crates/rvcsi-core/src/window.rs | 174 ++++++++ v2/crates/rvcsi-dsp/Cargo.toml | 18 + v2/crates/rvcsi-dsp/src/lib.rs | 7 + v2/crates/rvcsi-events/Cargo.toml | 20 + v2/crates/rvcsi-events/src/lib.rs | 7 + v2/crates/rvcsi-node/Cargo.toml | 30 ++ v2/crates/rvcsi-node/build.rs | 5 + v2/crates/rvcsi-node/src/lib.rs | 22 + v2/crates/rvcsi-ruvector/Cargo.toml | 20 + v2/crates/rvcsi-ruvector/src/lib.rs | 8 + 30 files changed, 2977 insertions(+), 7 deletions(-) create mode 100644 v2/crates/rvcsi-adapter-file/Cargo.toml create mode 100644 v2/crates/rvcsi-adapter-file/src/lib.rs create mode 100644 v2/crates/rvcsi-adapter-nexmon/Cargo.toml create mode 100644 v2/crates/rvcsi-adapter-nexmon/build.rs create mode 100644 v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c create mode 100644 v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h create mode 100644 v2/crates/rvcsi-adapter-nexmon/src/ffi.rs create mode 100644 v2/crates/rvcsi-adapter-nexmon/src/lib.rs create mode 100644 v2/crates/rvcsi-cli/Cargo.toml create mode 100644 v2/crates/rvcsi-cli/src/main.rs create mode 100644 v2/crates/rvcsi-core/Cargo.toml create mode 100644 v2/crates/rvcsi-core/src/adapter.rs create mode 100644 v2/crates/rvcsi-core/src/error.rs create mode 100644 v2/crates/rvcsi-core/src/event.rs create mode 100644 v2/crates/rvcsi-core/src/frame.rs create mode 100644 v2/crates/rvcsi-core/src/ids.rs create mode 100644 v2/crates/rvcsi-core/src/lib.rs create mode 100644 v2/crates/rvcsi-core/src/validation.rs create mode 100644 v2/crates/rvcsi-core/src/window.rs create mode 100644 v2/crates/rvcsi-dsp/Cargo.toml create mode 100644 v2/crates/rvcsi-dsp/src/lib.rs create mode 100644 v2/crates/rvcsi-events/Cargo.toml create mode 100644 v2/crates/rvcsi-events/src/lib.rs create mode 100644 v2/crates/rvcsi-node/Cargo.toml create mode 100644 v2/crates/rvcsi-node/build.rs create mode 100644 v2/crates/rvcsi-node/src/lib.rs create mode 100644 v2/crates/rvcsi-ruvector/Cargo.toml create mode 100644 v2/crates/rvcsi-ruvector/src/lib.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 2425594e..6bc8bcec 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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", diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 113859cd..787aef08 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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" diff --git a/v2/crates/rvcsi-adapter-file/Cargo.toml b/v2/crates/rvcsi-adapter-file/Cargo.toml new file mode 100644 index 00000000..9c4d50b7 --- /dev/null +++ b/v2/crates/rvcsi-adapter-file/Cargo.toml @@ -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" diff --git a/v2/crates/rvcsi-adapter-file/src/lib.rs b/v2/crates/rvcsi-adapter-file/src/lib.rs new file mode 100644 index 00000000..390cc8fd --- /dev/null +++ b/v2/crates/rvcsi-adapter-file/src/lib.rs @@ -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() {} diff --git a/v2/crates/rvcsi-adapter-nexmon/Cargo.toml b/v2/crates/rvcsi-adapter-nexmon/Cargo.toml new file mode 100644 index 00000000..789a5138 --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-adapter-nexmon/build.rs b/v2/crates/rvcsi-adapter-nexmon/build.rs new file mode 100644 index 00000000..9f05985a --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/build.rs @@ -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"); +} diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c new file mode 100644 index 00000000..4b934b2a --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c @@ -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 + +#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; +} diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h new file mode 100644 index 00000000..5bcadeda --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h @@ -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 +#include + +#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 */ diff --git a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs new file mode 100644 index 00000000..71707dc2 --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs @@ -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, + /// Noise floor in dBm, if present. + pub noise_floor_dbm: Option, + /// Source timestamp, ns. + pub timestamp_ns: u64, + /// In-phase samples. + pub i_values: Vec, + /// Quadrature samples. + pub q_values: Vec, +} + +/// 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 { + // 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, 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()); + } +} diff --git a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs new file mode 100644 index 00000000..f0b31218 --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs @@ -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, + cursor: usize, + next_frame_id: u64, + delivered: u64, + rejected: u64, + status: Option, +} + +impl NexmonAdapter { + /// Build an adapter from a buffer of concatenated records. + pub fn from_bytes( + source_id: impl Into, + session_id: SessionId, + bytes: impl Into>, + ) -> 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, + session_id: SessionId, + path: impl AsRef, + ) -> Result { + 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, + session_id: SessionId, + bytes: &[u8], + ) -> Result, 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, 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) -> Vec { + let i: Vec = (0..n).map(|k| (k as f32) * 0.5).collect(); + let q: Vec = (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); + } +} diff --git a/v2/crates/rvcsi-cli/Cargo.toml b/v2/crates/rvcsi-cli/Cargo.toml new file mode 100644 index 00000000..5400d6ef --- /dev/null +++ b/v2/crates/rvcsi-cli/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-cli/src/main.rs b/v2/crates/rvcsi-cli/src/main.rs new file mode 100644 index 00000000..4ded0e1c --- /dev/null +++ b/v2/crates/rvcsi-cli/src/main.rs @@ -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(()) + } + } +} diff --git a/v2/crates/rvcsi-core/Cargo.toml b/v2/crates/rvcsi-core/Cargo.toml new file mode 100644 index 00000000..698f023f --- /dev/null +++ b/v2/crates/rvcsi-core/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-core/src/adapter.rs b/v2/crates/rvcsi-core/src/adapter.rs new file mode 100644 index 00000000..dd6de4f5 --- /dev/null +++ b/v2/crates/rvcsi-core/src/adapter.rs @@ -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, + /// Firmware version string, if known. + pub firmware_version: Option, + /// Driver version string, if known. + pub driver_version: Option, + /// Channels the source can capture on. + pub supported_channels: Vec, + /// Bandwidths (MHz) the source supports. + pub supported_bandwidths_mhz: Vec, + /// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`). + pub expected_subcarrier_counts: Vec, + /// 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 1–13, 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, +} + +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, + /// WiFi channel (live sources only). + #[serde(default)] + pub channel: Option, + /// Bandwidth in MHz (live sources only). + #[serde(default)] + pub bandwidth_mhz: Option, + /// Replay speed multiplier (`1.0` = real time); replay source only. + #[serde(default)] + pub replay_speed: Option, + /// Free-form adapter-specific options. + #[serde(default)] + pub options_json: Option, +} + +impl SourceConfig { + /// Build a config for the given source slug with no other options set. + pub fn new(source: impl Into) -> 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) -> 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`. 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, 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::(&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); + } +} diff --git a/v2/crates/rvcsi-core/src/error.rs b/v2/crates/rvcsi-core/src/error.rs new file mode 100644 index 00000000..d0191435 --- /dev/null +++ b/v2/crates/rvcsi-core/src/error.rs @@ -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, message: impl Into) -> Self { + RvcsiError::Adapter { + kind: kind.into(), + message: message.into(), + } + } + + /// Convenience constructor for parse errors. + pub fn parse(offset: usize, message: impl Into) -> 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")); + } +} diff --git a/v2/crates/rvcsi-core/src/event.rs b/v2/crates/rvcsi-core/src/event.rs new file mode 100644 index 00000000..cf6e746b --- /dev/null +++ b/v2/crates/rvcsi-core/src/event.rs @@ -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, + /// Calibration version detection ran against, if any. + pub calibration_version: Option, + /// 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, + ) -> 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) -> Self { + self.calibration_version = Some(version.into()); + self + } + + /// Attach metadata (any serializable value). + pub fn with_metadata(mut self, meta: &T) -> Result { + 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::(&json).unwrap(), e); + } +} diff --git a/v2/crates/rvcsi-core/src/frame.rs b/v2/crates/rvcsi-core/src/frame.rs new file mode 100644 index 00000000..1936c338 --- /dev/null +++ b/v2/crates/rvcsi-core/src/frame.rs @@ -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, + /// Noise floor, dBm, if reported. + pub noise_floor_dbm: Option, + /// Receive-antenna index, if reported. + pub antenna_index: Option, + /// Transmit chain index, if reported. + pub tx_chain: Option, + /// Receive chain index, if reported. + pub rx_chain: Option, + /// Number of subcarriers (== length of the four vectors below). + pub subcarrier_count: u16, + /// In-phase components, one per subcarrier. + pub i_values: Vec, + /// Quadrature components, one per subcarrier. + pub q_values: Vec, + /// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier. + pub amplitude: Vec, + /// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later). + pub phase: Vec, + /// 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, + /// Calibration version this frame was processed against, if any. + pub calibration_version: Option, +} + +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, + q_values: Vec, + ) -> 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, tx: Option, rx: Option) -> 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::() / 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); + } +} diff --git a/v2/crates/rvcsi-core/src/ids.rs b/v2/crates/rvcsi-core/src/ids.rs new file mode 100644 index 00000000..ae5c8783 --- /dev/null +++ b/v2/crates/rvcsi-core/src/ids.rs @@ -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 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) -> 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 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::(&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::(&json).unwrap(), f); + } +} diff --git a/v2/crates/rvcsi-core/src/lib.rs b/v2/crates/rvcsi-core/src/lib.rs new file mode 100644 index 00000000..e066d1bf --- /dev/null +++ b/v2/crates/rvcsi-core/src/lib.rs @@ -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 = core::result::Result; diff --git a/v2/crates/rvcsi-core/src/validation.rs b/v2/crates/rvcsi-core/src/validation.rs new file mode 100644 index 00000000..c48171c0 --- /dev/null +++ b/v2/crates/rvcsi-core/src/validation.rs @@ -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, +} + +impl QualityScore { + fn full() -> Self { + QualityScore { + value: 1.0, + reasons: Vec::new(), + } + } + + fn penalize(&mut self, factor: f32, reason: impl Into) { + 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, +) -> 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 = 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 { .. })); + } +} diff --git a/v2/crates/rvcsi-core/src/window.rs b/v2/crates/rvcsi-core/src/window.rs new file mode 100644 index 00000000..1e67a680 --- /dev/null +++ b/v2/crates/rvcsi-core/src/window.rs @@ -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, + /// Phase variance per subcarrier. + pub phase_variance: Vec, + /// 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))); + } +} diff --git a/v2/crates/rvcsi-dsp/Cargo.toml b/v2/crates/rvcsi-dsp/Cargo.toml new file mode 100644 index 00000000..d82ff632 --- /dev/null +++ b/v2/crates/rvcsi-dsp/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-dsp/src/lib.rs b/v2/crates/rvcsi-dsp/src/lib.rs new file mode 100644 index 00000000..62457266 --- /dev/null +++ b/v2/crates/rvcsi-dsp/src/lib.rs @@ -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() {} diff --git a/v2/crates/rvcsi-events/Cargo.toml b/v2/crates/rvcsi-events/Cargo.toml new file mode 100644 index 00000000..10a74caf --- /dev/null +++ b/v2/crates/rvcsi-events/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-events/src/lib.rs b/v2/crates/rvcsi-events/src/lib.rs new file mode 100644 index 00000000..67811f8a --- /dev/null +++ b/v2/crates/rvcsi-events/src/lib.rs @@ -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() {} diff --git a/v2/crates/rvcsi-node/Cargo.toml b/v2/crates/rvcsi-node/Cargo.toml new file mode 100644 index 00000000..23c9f1bf --- /dev/null +++ b/v2/crates/rvcsi-node/Cargo.toml @@ -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 } diff --git a/v2/crates/rvcsi-node/build.rs b/v2/crates/rvcsi-node/build.rs new file mode 100644 index 00000000..c337d8cf --- /dev/null +++ b/v2/crates/rvcsi-node/build.rs @@ -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(); +} diff --git a/v2/crates/rvcsi-node/src/lib.rs b/v2/crates/rvcsi-node/src/lib.rs new file mode 100644 index 00000000..3c26b75a --- /dev/null +++ b/v2/crates/rvcsi-node/src/lib.rs @@ -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() +} diff --git a/v2/crates/rvcsi-ruvector/Cargo.toml b/v2/crates/rvcsi-ruvector/Cargo.toml new file mode 100644 index 00000000..c794b057 --- /dev/null +++ b/v2/crates/rvcsi-ruvector/Cargo.toml @@ -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" diff --git a/v2/crates/rvcsi-ruvector/src/lib.rs b/v2/crates/rvcsi-ruvector/src/lib.rs new file mode 100644 index 00000000..643e0e27 --- /dev/null +++ b/v2/crates/rvcsi-ruvector/src/lib.rs @@ -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() {}