feat(rvcsi): rvcsi-core + napi-c Nexmon shim + crate skeletons (ADR-095/096)
First implementation milestone for the rvCSI edge RF sensing runtime:
- rvcsi-core — the foundation: CsiFrame/CsiWindow/CsiEvent normalized schema,
ValidationStatus, AdapterProfile, CsiSource plugin trait, id newtypes +
IdGenerator, RvcsiError, and the validate_frame pipeline (length/finiteness/
subcarrier/RSSI/monotonicity hard checks + multiplicative quality scoring →
Accepted/Degraded/Recovered/Rejected). 29 unit tests, forbid(unsafe_code).
- rvcsi-adapter-nexmon — the napi-c boundary: native/rvcsi_nexmon_shim.{c,h}
(the only C in the runtime, allocation-free, bounds-checked, parses/writes a
byte-defined "rvCSI Nexmon record" — a normalized superset of the nexmon_csi
UDP payload), compiled via build.rs + cc, wrapped by a documented ffi module
and a NexmonAdapter implementing CsiSource. 9 tests round-tripping through C.
- Workspace registration in v2/Cargo.toml (8 new members + napi/cc workspace
deps) and compiling skeletons for rvcsi-dsp, rvcsi-events, rvcsi-adapter-file,
rvcsi-ruvector, rvcsi-node (napi-rs cdylib + build.rs napi_build::setup) and
rvcsi-cli (`rvcsi` binary) — to be filled in by the implementation swarm.
cargo build -p rvcsi-core -p rvcsi-adapter-nexmon -p rvcsi-node -p rvcsi-cli: OK
cargo test -p rvcsi-core -p rvcsi-adapter-nexmon: 38 passed, 0 failed
https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
parent
d98b7e3f65
commit
1e684cb208
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
//! Compiles the isolated napi-c shim (`native/rvcsi_nexmon_shim.c`) into a
|
||||
//! static library linked into `rvcsi-adapter-nexmon`. This is the only place
|
||||
//! the rvCSI runtime invokes a C compiler (ADR-095 D2, ADR-096).
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.c");
|
||||
println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.h");
|
||||
|
||||
cc::Build::new()
|
||||
.file("native/rvcsi_nexmon_shim.c")
|
||||
.include("native")
|
||||
.warnings(true)
|
||||
.extra_warnings(true)
|
||||
// The shim is allocation-free and freestanding-ish; keep it tight.
|
||||
.flag_if_supported("-std=c11")
|
||||
.flag_if_supported("-fno-strict-aliasing")
|
||||
.compile("rvcsi_nexmon_shim");
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* rvCSI — Nexmon CSI compatibility shim implementation (napi-c layer).
|
||||
* See rvcsi_nexmon_shim.h for the record layout and contract.
|
||||
*
|
||||
* Deliberately tiny, allocation-free, and dependency-free (libc only). Every
|
||||
* read is bounds-checked; nothing here can scribble outside caller buffers.
|
||||
*/
|
||||
#include "rvcsi_nexmon_shim.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#define RVCSI_NX_ABI 0x00010000u /* major.minor = 1.0 */
|
||||
|
||||
/* ---- little-endian load/store helpers (portable, no aliasing UB) ---- */
|
||||
|
||||
static uint16_t ld_u16(const uint8_t *p) {
|
||||
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
|
||||
}
|
||||
static uint32_t ld_u32(const uint8_t *p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
|
||||
((uint32_t)p[3] << 24);
|
||||
}
|
||||
static uint64_t ld_u64(const uint8_t *p) {
|
||||
return (uint64_t)ld_u32(p) | ((uint64_t)ld_u32(p + 4) << 32);
|
||||
}
|
||||
static int16_t ld_i16(const uint8_t *p) { return (int16_t)ld_u16(p); }
|
||||
|
||||
static void st_u16(uint8_t *p, uint16_t v) {
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
}
|
||||
static void st_u32(uint8_t *p, uint32_t v) {
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
p[2] = (uint8_t)((v >> 16) & 0xFF);
|
||||
p[3] = (uint8_t)((v >> 24) & 0xFF);
|
||||
}
|
||||
static void st_u64(uint8_t *p, uint64_t v) {
|
||||
st_u32(p, (uint32_t)(v & 0xFFFFFFFFu));
|
||||
st_u32(p + 4, (uint32_t)((v >> 32) & 0xFFFFFFFFu));
|
||||
}
|
||||
static void st_i16(uint8_t *p, int16_t v) { st_u16(p, (uint16_t)v); }
|
||||
|
||||
/* Q8.8 fixed-point <-> float, with saturation on encode. */
|
||||
static float q88_to_f(int16_t v) { return (float)v / 256.0f; }
|
||||
static int16_t f_to_q88(float f) {
|
||||
float scaled = f * 256.0f;
|
||||
if (scaled >= 32767.0f) return (int16_t)32767;
|
||||
if (scaled <= -32768.0f) return (int16_t)-32768;
|
||||
/* round to nearest, ties away from zero */
|
||||
if (scaled >= 0.0f)
|
||||
return (int16_t)(scaled + 0.5f);
|
||||
return (int16_t)(scaled - 0.5f);
|
||||
}
|
||||
|
||||
uint32_t rvcsi_nx_abi_version(void) { return RVCSI_NX_ABI; }
|
||||
|
||||
const char *rvcsi_nx_strerror(int code) {
|
||||
switch (code) {
|
||||
case RVCSI_NX_OK: return "ok";
|
||||
case RVCSI_NX_ERR_TOO_SHORT: return "buffer too short for header";
|
||||
case RVCSI_NX_ERR_BAD_MAGIC: return "bad magic (not an rvCSI Nexmon record)";
|
||||
case RVCSI_NX_ERR_BAD_VERSION: return "unsupported record version";
|
||||
case RVCSI_NX_ERR_CAPACITY: return "output buffer too small for subcarrier count";
|
||||
case RVCSI_NX_ERR_TRUNCATED: return "buffer shorter than the declared record";
|
||||
case RVCSI_NX_ERR_ZERO_SUBCARRIERS: return "record declares zero subcarriers";
|
||||
case RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS: return "record declares too many subcarriers";
|
||||
case RVCSI_NX_ERR_NULL_ARG: return "null argument";
|
||||
default: return "unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
/* Validate the header at buf[0..24); on success return N (subcarrier count) and
|
||||
* the total record size via *out_total. On failure return a negative
|
||||
* RvcsiNxError. */
|
||||
static int validate_header(const uint8_t *buf, size_t len, uint16_t *out_n,
|
||||
size_t *out_total) {
|
||||
if (len < (size_t)RVCSI_NX_HEADER_BYTES) return -RVCSI_NX_ERR_TOO_SHORT;
|
||||
if (ld_u32(buf) != RVCSI_NX_MAGIC) return -RVCSI_NX_ERR_BAD_MAGIC;
|
||||
if (buf[4] != (uint8_t)RVCSI_NX_VERSION) return -RVCSI_NX_ERR_BAD_VERSION;
|
||||
uint16_t n = ld_u16(buf + 6);
|
||||
if (n == 0) return -RVCSI_NX_ERR_ZERO_SUBCARRIERS;
|
||||
if (n > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS;
|
||||
size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u;
|
||||
if (len < total) return -RVCSI_NX_ERR_TRUNCATED;
|
||||
*out_n = n;
|
||||
*out_total = total;
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len) {
|
||||
if (buf == NULL) return 0;
|
||||
uint16_t n;
|
||||
size_t total;
|
||||
if (validate_header(buf, len, &n, &total) < 0) return 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta,
|
||||
float *i_out, float *q_out, size_t cap) {
|
||||
if (buf == NULL || meta == NULL || i_out == NULL || q_out == NULL)
|
||||
return RVCSI_NX_ERR_NULL_ARG;
|
||||
|
||||
uint16_t n;
|
||||
size_t total;
|
||||
int rc = validate_header(buf, len, &n, &total);
|
||||
if (rc < 0) return -rc;
|
||||
if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY;
|
||||
|
||||
uint8_t flags = buf[5];
|
||||
meta->subcarrier_count = n;
|
||||
meta->channel = ld_u16(buf + 10);
|
||||
meta->bandwidth_mhz = ld_u16(buf + 12);
|
||||
meta->rssi_dbm =
|
||||
(flags & RVCSI_NX_FLAG_RSSI) ? (int16_t)(int8_t)buf[8] : RVCSI_NX_ABSENT_I16;
|
||||
meta->noise_floor_dbm =
|
||||
(flags & RVCSI_NX_FLAG_NOISE) ? (int16_t)(int8_t)buf[9] : RVCSI_NX_ABSENT_I16;
|
||||
meta->timestamp_ns = ld_u64(buf + 16);
|
||||
|
||||
const uint8_t *p = buf + RVCSI_NX_HEADER_BYTES;
|
||||
for (uint16_t k = 0; k < n; ++k) {
|
||||
i_out[k] = q88_to_f(ld_i16(p));
|
||||
q_out[k] = q88_to_f(ld_i16(p + 2));
|
||||
p += 4;
|
||||
}
|
||||
return RVCSI_NX_OK;
|
||||
}
|
||||
|
||||
size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta,
|
||||
const float *i_in, const float *q_in) {
|
||||
if (buf == NULL || meta == NULL || i_in == NULL || q_in == NULL) return 0;
|
||||
uint16_t n = meta->subcarrier_count;
|
||||
if (n == 0 || n > RVCSI_NX_MAX_SUBCARRIERS) return 0;
|
||||
size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u;
|
||||
if (cap < total) return 0;
|
||||
|
||||
memset(buf, 0, RVCSI_NX_HEADER_BYTES);
|
||||
st_u32(buf, RVCSI_NX_MAGIC);
|
||||
buf[4] = (uint8_t)RVCSI_NX_VERSION;
|
||||
uint8_t flags = 0;
|
||||
if (meta->rssi_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_RSSI;
|
||||
if (meta->noise_floor_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_NOISE;
|
||||
buf[5] = flags;
|
||||
st_u16(buf + 6, n);
|
||||
buf[8] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_RSSI) ? meta->rssi_dbm : 0);
|
||||
buf[9] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_NOISE) ? meta->noise_floor_dbm : 0);
|
||||
st_u16(buf + 10, meta->channel);
|
||||
st_u16(buf + 12, meta->bandwidth_mhz);
|
||||
st_u16(buf + 14, 0);
|
||||
st_u64(buf + 16, meta->timestamp_ns);
|
||||
|
||||
uint8_t *p = buf + RVCSI_NX_HEADER_BYTES;
|
||||
for (uint16_t k = 0; k < n; ++k) {
|
||||
st_i16(p, f_to_q88(i_in[k]));
|
||||
st_i16(p + 2, f_to_q88(q_in[k]));
|
||||
p += 4;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* rvCSI — Nexmon CSI compatibility shim (napi-c layer, ADR-095 D2, ADR-096).
|
||||
*
|
||||
* This is the ONLY C in the rvCSI runtime. It parses (and, for tests, writes)
|
||||
* a compact, byte-defined "rvCSI Nexmon record" — a normalized superset of the
|
||||
* nexmon_csi UDP payload (magic, RSSI, chanspec, then interleaved int16 I/Q).
|
||||
* The Rust side (`rvcsi-adapter-nexmon`) wraps these functions and never sees
|
||||
* raw vendor structs; everything above this file is safe Rust.
|
||||
*
|
||||
* Record layout (all integers little-endian):
|
||||
*
|
||||
* off size field
|
||||
* --- ---- -----------------------------------------------------------
|
||||
* 0 4 magic = 0x52564E58 ('R','V','N','X')
|
||||
* 4 1 version = RVCSI_NX_VERSION (1)
|
||||
* 5 1 flags bit0: rssi present, bit1: noise floor present
|
||||
* 6 2 subcarrier_count N (1 .. RVCSI_NX_MAX_SUBCARRIERS)
|
||||
* 8 1 rssi_dbm int8 (valid iff flags bit0)
|
||||
* 9 1 noise_dbm int8 (valid iff flags bit1)
|
||||
* 10 2 channel uint16
|
||||
* 12 2 bandwidth_mhz uint16
|
||||
* 14 2 reserved (0)
|
||||
* 16 8 timestamp_ns uint64
|
||||
* 24 4*N N pairs of int16 (i, q), interleaved, fixed-point Q8.8
|
||||
*
|
||||
* total record size = 24 + 4*N bytes
|
||||
*
|
||||
* Fixed-point: stored int16 value v maps to float v / 256.0.
|
||||
*/
|
||||
#ifndef RVCSI_NEXMON_SHIM_H
|
||||
#define RVCSI_NEXMON_SHIM_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define RVCSI_NX_MAGIC 0x52564E58u /* 'R','V','N','X' little-endian */
|
||||
#define RVCSI_NX_VERSION 1
|
||||
#define RVCSI_NX_HEADER_BYTES 24
|
||||
#define RVCSI_NX_MAX_SUBCARRIERS 2048
|
||||
#define RVCSI_NX_FLAG_RSSI 0x01u
|
||||
#define RVCSI_NX_FLAG_NOISE 0x02u
|
||||
|
||||
/* Sentinel for "metadata field absent". */
|
||||
#define RVCSI_NX_ABSENT_I16 ((int16_t)0x7FFF)
|
||||
|
||||
/* Error codes returned (negated) by rvcsi_nx_parse_record / rvcsi_nx_write_record. */
|
||||
typedef enum {
|
||||
RVCSI_NX_OK = 0,
|
||||
RVCSI_NX_ERR_TOO_SHORT = 1, /* buffer shorter than the header */
|
||||
RVCSI_NX_ERR_BAD_MAGIC = 2, /* magic mismatch */
|
||||
RVCSI_NX_ERR_BAD_VERSION = 3, /* unsupported version */
|
||||
RVCSI_NX_ERR_CAPACITY = 4, /* caller i/q buffer too small for N */
|
||||
RVCSI_NX_ERR_TRUNCATED = 5, /* buffer shorter than 24 + 4*N */
|
||||
RVCSI_NX_ERR_ZERO_SUBCARRIERS = 6,
|
||||
RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS = 7,
|
||||
RVCSI_NX_ERR_NULL_ARG = 8
|
||||
} RvcsiNxError;
|
||||
|
||||
/* Decoded per-record metadata (the I/Q samples are written separately into
|
||||
* caller-provided float arrays). */
|
||||
typedef struct RvcsiNxMeta {
|
||||
uint16_t subcarrier_count;
|
||||
uint16_t channel;
|
||||
uint16_t bandwidth_mhz;
|
||||
int16_t rssi_dbm; /* RVCSI_NX_ABSENT_I16 if not present */
|
||||
int16_t noise_floor_dbm;/* RVCSI_NX_ABSENT_I16 if not present */
|
||||
uint64_t timestamp_ns;
|
||||
} RvcsiNxMeta;
|
||||
|
||||
/*
|
||||
* Length, in bytes, of the record that starts at `buf`, given `len` bytes are
|
||||
* available. Returns 0 if `len` is too small to even read the header, the magic
|
||||
* is wrong, the version is unsupported, the subcarrier count is out of range,
|
||||
* or `len` < the full record. On success returns 24 + 4*N (>= 28).
|
||||
*/
|
||||
size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len);
|
||||
|
||||
/*
|
||||
* Parse one record at `buf` (with `len` bytes available). Fills `*meta` and
|
||||
* writes `subcarrier_count` floats into each of `i_out` and `q_out` (which must
|
||||
* each have capacity `cap`). Returns RVCSI_NX_OK (0) on success, or one of the
|
||||
* RvcsiNxError codes (positive) on failure. No allocation, no globals.
|
||||
*/
|
||||
int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta,
|
||||
float *i_out, float *q_out, size_t cap);
|
||||
|
||||
/*
|
||||
* Serialize one record into `buf` (capacity `cap`). `i_in`/`q_in` hold
|
||||
* `meta->subcarrier_count` floats each (clamped to the Q8.8 range). Returns the
|
||||
* number of bytes written (24 + 4*N) on success, or 0 on error (null arg, zero
|
||||
* or too-many subcarriers, capacity too small). Used by Rust tests and the
|
||||
* `rvcsi capture` recorder; production capture comes straight off the wire.
|
||||
*/
|
||||
size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta,
|
||||
const float *i_in, const float *q_in);
|
||||
|
||||
/* Static, human-readable string for an RvcsiNxError code. Never NULL. */
|
||||
const char *rvcsi_nx_strerror(int code);
|
||||
|
||||
/* ABI version of this shim — bumped if the record layout or function
|
||||
* signatures change. The Rust side asserts it matches at startup. */
|
||||
uint32_t rvcsi_nx_abi_version(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RVCSI_NEXMON_SHIM_H */
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
//! Raw FFI to the napi-c shim plus safe wrappers (ADR-096).
|
||||
//!
|
||||
//! The C side (`native/rvcsi_nexmon_shim.c`) is allocation-free and bounds-checks
|
||||
//! every read against the caller-supplied lengths. The `unsafe` here is limited
|
||||
//! to: calling those C functions with correct pointers/lengths, and reading back
|
||||
//! the metadata struct the C side fully initialized on `RVCSI_NX_OK`.
|
||||
|
||||
use std::os::raw::c_char;
|
||||
|
||||
/// Bytes in a record header (the fixed prefix before the I/Q samples).
|
||||
pub const RECORD_HEADER_BYTES: usize = 24;
|
||||
|
||||
/// Largest subcarrier count the shim will parse (mirrors `RVCSI_NX_MAX_SUBCARRIERS`).
|
||||
pub const MAX_SUBCARRIERS: usize = 2048;
|
||||
|
||||
/// Sentinel the C side uses for "metadata field absent".
|
||||
const ABSENT_I16: i16 = 0x7FFF;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RvcsiNxMeta {
|
||||
subcarrier_count: u16,
|
||||
channel: u16,
|
||||
bandwidth_mhz: u16,
|
||||
rssi_dbm: i16,
|
||||
noise_floor_dbm: i16,
|
||||
timestamp_ns: u64,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn rvcsi_nx_record_len(buf: *const u8, len: usize) -> usize;
|
||||
fn rvcsi_nx_parse_record(
|
||||
buf: *const u8,
|
||||
len: usize,
|
||||
meta: *mut RvcsiNxMeta,
|
||||
i_out: *mut f32,
|
||||
q_out: *mut f32,
|
||||
cap: usize,
|
||||
) -> i32;
|
||||
fn rvcsi_nx_write_record(
|
||||
buf: *mut u8,
|
||||
cap: usize,
|
||||
meta: *const RvcsiNxMeta,
|
||||
i_in: *const f32,
|
||||
q_in: *const f32,
|
||||
) -> usize;
|
||||
fn rvcsi_nx_strerror(code: i32) -> *const c_char;
|
||||
fn rvcsi_nx_abi_version() -> u32;
|
||||
}
|
||||
|
||||
/// ABI version of the linked C shim (`major << 16 | minor`).
|
||||
pub fn shim_abi_version() -> u32 {
|
||||
// SAFETY: no arguments, returns a plain u32 by value.
|
||||
unsafe { rvcsi_nx_abi_version() }
|
||||
}
|
||||
|
||||
/// Errors decoding a record (a structured view of the C error codes).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum NexmonFfiError {
|
||||
/// The C shim returned a non-zero error code.
|
||||
#[error("nexmon shim error {code}: {message}")]
|
||||
Shim {
|
||||
/// Numeric `RvcsiNxError` code.
|
||||
code: i32,
|
||||
/// Static description from `rvcsi_nx_strerror`.
|
||||
message: String,
|
||||
},
|
||||
/// The buffer didn't even contain a parseable header / record length.
|
||||
#[error("not a record (bad magic, unsupported version, or too short)")]
|
||||
NotARecord,
|
||||
}
|
||||
|
||||
fn strerror(code: i32) -> String {
|
||||
// SAFETY: rvcsi_nx_strerror always returns a non-NULL pointer to a static,
|
||||
// NUL-terminated C string (see the C source); we only borrow it here.
|
||||
unsafe {
|
||||
let p = rvcsi_nx_strerror(code);
|
||||
if p.is_null() {
|
||||
return format!("error {code}");
|
||||
}
|
||||
std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A record decoded from the wire: fixed metadata + the I/Q sample vectors.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NexmonRecord {
|
||||
/// Number of subcarriers (== length of `i_values`/`q_values`).
|
||||
pub subcarrier_count: u16,
|
||||
/// WiFi channel number.
|
||||
pub channel: u16,
|
||||
/// Bandwidth in MHz.
|
||||
pub bandwidth_mhz: u16,
|
||||
/// RSSI in dBm, if present in the record.
|
||||
pub rssi_dbm: Option<i16>,
|
||||
/// Noise floor in dBm, if present.
|
||||
pub noise_floor_dbm: Option<i16>,
|
||||
/// Source timestamp, ns.
|
||||
pub timestamp_ns: u64,
|
||||
/// In-phase samples.
|
||||
pub i_values: Vec<f32>,
|
||||
/// Quadrature samples.
|
||||
pub q_values: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Length, in bytes, of the record starting at `buf[0]`, or `None` if `buf`
|
||||
/// doesn't begin with a complete, valid record.
|
||||
pub fn record_len(buf: &[u8]) -> Option<usize> {
|
||||
// SAFETY: passing a valid pointer + the slice's true length; the C side
|
||||
// reads at most `len` bytes and returns 0 on any problem.
|
||||
let n = unsafe { rvcsi_nx_record_len(buf.as_ptr(), buf.len()) };
|
||||
if n == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the first record in `buf`. Returns the record and the number of bytes
|
||||
/// it consumed (so callers can advance a cursor over a concatenated stream).
|
||||
pub fn decode_record(buf: &[u8]) -> Result<(NexmonRecord, usize), NexmonFfiError> {
|
||||
let total = record_len(buf).ok_or(NexmonFfiError::NotARecord)?;
|
||||
debug_assert!(total >= RECORD_HEADER_BYTES && total <= buf.len());
|
||||
let n = (total - RECORD_HEADER_BYTES) / 4;
|
||||
|
||||
let mut meta = RvcsiNxMeta {
|
||||
subcarrier_count: 0,
|
||||
channel: 0,
|
||||
bandwidth_mhz: 0,
|
||||
rssi_dbm: 0,
|
||||
noise_floor_dbm: 0,
|
||||
timestamp_ns: 0,
|
||||
};
|
||||
let mut i_out = vec![0.0f32; n];
|
||||
let mut q_out = vec![0.0f32; n];
|
||||
|
||||
// SAFETY: `buf` is valid for `buf.len()` bytes; `i_out`/`q_out` are valid
|
||||
// for `n` f32s each and we pass `n` as the capacity; `meta` points to a
|
||||
// fully owned, properly aligned RvcsiNxMeta. The C side writes only within
|
||||
// those bounds and fully initializes `meta` on RVCSI_NX_OK.
|
||||
let rc = unsafe {
|
||||
rvcsi_nx_parse_record(
|
||||
buf.as_ptr(),
|
||||
buf.len(),
|
||||
&mut meta as *mut RvcsiNxMeta,
|
||||
i_out.as_mut_ptr(),
|
||||
q_out.as_mut_ptr(),
|
||||
n,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(NexmonFfiError::Shim {
|
||||
code: rc,
|
||||
message: strerror(rc),
|
||||
});
|
||||
}
|
||||
debug_assert_eq!(meta.subcarrier_count as usize, n);
|
||||
|
||||
let rec = NexmonRecord {
|
||||
subcarrier_count: meta.subcarrier_count,
|
||||
channel: meta.channel,
|
||||
bandwidth_mhz: meta.bandwidth_mhz,
|
||||
rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm),
|
||||
noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm),
|
||||
timestamp_ns: meta.timestamp_ns,
|
||||
i_values: i_out,
|
||||
q_values: q_out,
|
||||
};
|
||||
Ok((rec, total))
|
||||
}
|
||||
|
||||
/// Encode a record to bytes via the C writer (used by tests and the recorder).
|
||||
pub fn encode_record(rec: &NexmonRecord) -> Result<Vec<u8>, NexmonFfiError> {
|
||||
let n = rec.subcarrier_count as usize;
|
||||
if n == 0 || n > MAX_SUBCARRIERS || rec.i_values.len() != n || rec.q_values.len() != n {
|
||||
return Err(NexmonFfiError::Shim {
|
||||
code: 6,
|
||||
message: "bad subcarrier count or i/q length".to_string(),
|
||||
});
|
||||
}
|
||||
let meta = RvcsiNxMeta {
|
||||
subcarrier_count: rec.subcarrier_count,
|
||||
channel: rec.channel,
|
||||
bandwidth_mhz: rec.bandwidth_mhz,
|
||||
rssi_dbm: rec.rssi_dbm.unwrap_or(ABSENT_I16),
|
||||
noise_floor_dbm: rec.noise_floor_dbm.unwrap_or(ABSENT_I16),
|
||||
timestamp_ns: rec.timestamp_ns,
|
||||
};
|
||||
let cap = RECORD_HEADER_BYTES + n * 4;
|
||||
let mut buf = vec![0u8; cap];
|
||||
// SAFETY: `buf` is valid for `cap` bytes; `i_in`/`q_in` are valid for `n`
|
||||
// f32s each (checked above); `meta` is a fully initialized owned struct.
|
||||
let written = unsafe {
|
||||
rvcsi_nx_write_record(
|
||||
buf.as_mut_ptr(),
|
||||
cap,
|
||||
&meta as *const RvcsiNxMeta,
|
||||
rec.i_values.as_ptr(),
|
||||
rec.q_values.as_ptr(),
|
||||
)
|
||||
};
|
||||
if written == 0 {
|
||||
return Err(NexmonFfiError::Shim {
|
||||
code: 4,
|
||||
message: "write_record failed (capacity or argument error)".to_string(),
|
||||
});
|
||||
}
|
||||
debug_assert_eq!(written, cap);
|
||||
buf.truncate(written);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_buffer_is_not_a_record() {
|
||||
assert!(record_len(&[]).is_none());
|
||||
assert_eq!(decode_record(&[]).unwrap_err(), NexmonFfiError::NotARecord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_then_decode_is_identity() {
|
||||
let rec = NexmonRecord {
|
||||
subcarrier_count: 4,
|
||||
channel: 11,
|
||||
bandwidth_mhz: 20,
|
||||
rssi_dbm: Some(-70),
|
||||
noise_floor_dbm: None,
|
||||
timestamp_ns: 999,
|
||||
i_values: vec![1.0, -2.0, 0.0, 3.5],
|
||||
q_values: vec![0.5, 0.25, -1.0, 0.0],
|
||||
};
|
||||
let bytes = encode_record(&rec).unwrap();
|
||||
assert_eq!(bytes.len(), RECORD_HEADER_BYTES + 16);
|
||||
let (back, consumed) = decode_record(&bytes).unwrap();
|
||||
assert_eq!(consumed, bytes.len());
|
||||
assert_eq!(back, rec);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_subcarriers_on_encode() {
|
||||
let rec = NexmonRecord {
|
||||
subcarrier_count: 0,
|
||||
channel: 1,
|
||||
bandwidth_mhz: 20,
|
||||
rssi_dbm: None,
|
||||
noise_floor_dbm: None,
|
||||
timestamp_ns: 0,
|
||||
i_values: vec![],
|
||||
q_values: vec![],
|
||||
};
|
||||
assert!(encode_record(&rec).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
//! # rvCSI Nexmon adapter (napi-c boundary)
|
||||
//!
|
||||
//! Wraps the isolated C shim in `native/rvcsi_nexmon_shim.{c,h}` — the only C
|
||||
//! in the rvCSI runtime (ADR-095 D2, ADR-096). The shim parses a compact,
|
||||
//! byte-defined "rvCSI Nexmon record" (a normalized superset of the nexmon_csi
|
||||
//! UDP payload). Everything above [`ffi`] is safe Rust; all `unsafe` is
|
||||
//! confined to this crate, bounds-checked on the C side, and documented.
|
||||
//!
|
||||
//! Typical use: a capture dump (a file of concatenated records, or the bytes of
|
||||
//! a UDP stream demux) is fed to [`NexmonAdapter::from_bytes`], which yields
|
||||
//! `Pending` [`CsiFrame`]s. The runtime then runs [`rvcsi_core::validate_frame`]
|
||||
//! on each before exposing it.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rvcsi_core::{
|
||||
AdapterKind, AdapterProfile, CsiFrame, CsiSource, RvcsiError, SessionId, SourceHealth, SourceId,
|
||||
};
|
||||
|
||||
pub mod ffi;
|
||||
|
||||
pub use ffi::{decode_record, encode_record, shim_abi_version, NexmonRecord, RECORD_HEADER_BYTES};
|
||||
|
||||
/// A [`CsiSource`] that replays a buffer of rvCSI Nexmon records.
|
||||
///
|
||||
/// Records are decoded lazily by [`CsiSource::next_frame`]; an exhausted buffer
|
||||
/// returns `Ok(None)`. Frames are produced with `validation = Pending`.
|
||||
pub struct NexmonAdapter {
|
||||
source_id: SourceId,
|
||||
session_id: SessionId,
|
||||
profile: AdapterProfile,
|
||||
buf: Vec<u8>,
|
||||
cursor: usize,
|
||||
next_frame_id: u64,
|
||||
delivered: u64,
|
||||
rejected: u64,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
impl NexmonAdapter {
|
||||
/// Build an adapter from a buffer of concatenated records.
|
||||
pub fn from_bytes(
|
||||
source_id: impl Into<SourceId>,
|
||||
session_id: SessionId,
|
||||
bytes: impl Into<Vec<u8>>,
|
||||
) -> Self {
|
||||
// ABI guard — the static lib we linked must match the header we coded against.
|
||||
debug_assert_eq!(
|
||||
shim_abi_version() >> 16,
|
||||
1,
|
||||
"rvcsi_nexmon_shim major ABI mismatch"
|
||||
);
|
||||
NexmonAdapter {
|
||||
source_id: source_id.into(),
|
||||
session_id,
|
||||
profile: AdapterProfile::nexmon_default(),
|
||||
buf: bytes.into(),
|
||||
cursor: 0,
|
||||
next_frame_id: 0,
|
||||
delivered: 0,
|
||||
rejected: 0,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an adapter from a capture file of concatenated records.
|
||||
pub fn from_file(
|
||||
source_id: impl Into<SourceId>,
|
||||
session_id: SessionId,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<Self, RvcsiError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
Ok(Self::from_bytes(source_id, session_id, bytes))
|
||||
}
|
||||
|
||||
/// Override the capability profile (e.g. when the firmware version is known).
|
||||
pub fn with_profile(mut self, profile: AdapterProfile) -> Self {
|
||||
self.profile = profile;
|
||||
self
|
||||
}
|
||||
|
||||
/// Decode every record in `bytes` into `Pending` frames in one shot.
|
||||
///
|
||||
/// Stops at the first malformed record and returns what was decoded so far
|
||||
/// alongside the error (`Err` carries the partial vec via the message; use
|
||||
/// [`NexmonAdapter`] iteration if you need to inspect partial progress).
|
||||
pub fn frames_from_bytes(
|
||||
source_id: impl Into<SourceId>,
|
||||
session_id: SessionId,
|
||||
bytes: &[u8],
|
||||
) -> Result<Vec<CsiFrame>, RvcsiError> {
|
||||
let mut adapter = NexmonAdapter::from_bytes(source_id, session_id, bytes.to_vec());
|
||||
let mut out = Vec::new();
|
||||
while let Some(frame) = adapter.next_frame()? {
|
||||
out.push(frame);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn record_to_frame(&mut self, rec: NexmonRecord) -> CsiFrame {
|
||||
let fid = self.next_frame_id;
|
||||
self.next_frame_id += 1;
|
||||
let mut frame = CsiFrame::from_iq(
|
||||
fid.into(),
|
||||
self.session_id,
|
||||
self.source_id.clone(),
|
||||
AdapterKind::Nexmon,
|
||||
rec.timestamp_ns,
|
||||
rec.channel,
|
||||
rec.bandwidth_mhz,
|
||||
rec.i_values,
|
||||
rec.q_values,
|
||||
);
|
||||
if let Some(r) = rec.rssi_dbm {
|
||||
frame.rssi_dbm = Some(r);
|
||||
}
|
||||
if let Some(n) = rec.noise_floor_dbm {
|
||||
frame.noise_floor_dbm = Some(n);
|
||||
}
|
||||
frame
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiSource for NexmonAdapter {
|
||||
fn profile(&self) -> &AdapterProfile {
|
||||
&self.profile
|
||||
}
|
||||
|
||||
fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
fn source_id(&self) -> &SourceId {
|
||||
&self.source_id
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
|
||||
if self.cursor >= self.buf.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let remaining = &self.buf[self.cursor..];
|
||||
match decode_record(remaining) {
|
||||
Ok((rec, consumed)) => {
|
||||
self.cursor += consumed;
|
||||
self.delivered += 1;
|
||||
Ok(Some(self.record_to_frame(rec)))
|
||||
}
|
||||
Err(e) => {
|
||||
self.rejected += 1;
|
||||
self.status = Some(format!("malformed record at byte {}: {e}", self.cursor));
|
||||
// Skip the rest of the buffer — a corrupt record means we've lost
|
||||
// framing; the daemon would reconnect/re-sync rather than guess.
|
||||
self.cursor = self.buf.len();
|
||||
Err(RvcsiError::adapter(
|
||||
"nexmon",
|
||||
format!("malformed record: {e}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn health(&self) -> SourceHealth {
|
||||
SourceHealth {
|
||||
connected: self.cursor < self.buf.len(),
|
||||
frames_delivered: self.delivered,
|
||||
frames_rejected: self.rejected,
|
||||
status: self.status.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rvcsi_core::{validate_frame, ValidationPolicy, ValidationStatus};
|
||||
|
||||
fn make_record(ts: u64, ch: u16, n: usize, rssi: Option<i16>) -> Vec<u8> {
|
||||
let i: Vec<f32> = (0..n).map(|k| (k as f32) * 0.5).collect();
|
||||
let q: Vec<f32> = (0..n).map(|k| -(k as f32) * 0.25).collect();
|
||||
let rec = NexmonRecord {
|
||||
subcarrier_count: n as u16,
|
||||
channel: ch,
|
||||
bandwidth_mhz: 80,
|
||||
rssi_dbm: rssi,
|
||||
noise_floor_dbm: Some(-92),
|
||||
timestamp_ns: ts,
|
||||
i_values: i,
|
||||
q_values: q,
|
||||
};
|
||||
encode_record(&rec).expect("encode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_version_is_one_point_oh() {
|
||||
assert_eq!(shim_abi_version(), 0x0001_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_single_record_via_c_shim() {
|
||||
let bytes = make_record(123_456, 36, 64, Some(-58));
|
||||
let (rec, consumed) = decode_record(&bytes).expect("decode");
|
||||
assert_eq!(consumed, bytes.len());
|
||||
assert_eq!(rec.subcarrier_count, 64);
|
||||
assert_eq!(rec.channel, 36);
|
||||
assert_eq!(rec.bandwidth_mhz, 80);
|
||||
assert_eq!(rec.rssi_dbm, Some(-58));
|
||||
assert_eq!(rec.noise_floor_dbm, Some(-92));
|
||||
assert_eq!(rec.timestamp_ns, 123_456);
|
||||
assert_eq!(rec.i_values.len(), 64);
|
||||
// Q8.8 fixed point: 0.5 and -0.25 are exactly representable.
|
||||
assert_eq!(rec.i_values[1], 0.5);
|
||||
assert_eq!(rec.q_values[1], -0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_streams_multiple_records_then_validates() {
|
||||
let mut buf = make_record(1_000, 6, 56, Some(-60));
|
||||
buf.extend(make_record(2_000, 6, 56, Some(-61)));
|
||||
buf.extend(make_record(3_000, 6, 56, None));
|
||||
|
||||
let mut adapter = NexmonAdapter::from_bytes("nexmon-test", SessionId(7), buf);
|
||||
let mut frames = Vec::new();
|
||||
while let Some(f) = adapter.next_frame().unwrap() {
|
||||
frames.push(f);
|
||||
}
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(frames[0].timestamp_ns, 1_000);
|
||||
assert_eq!(frames[2].rssi_dbm, None);
|
||||
assert_eq!(adapter.health().frames_delivered, 3);
|
||||
assert!(!adapter.health().connected);
|
||||
|
||||
// 56 is not in the default Nexmon profile (64/128/256) → rejected.
|
||||
let mut f = frames[0].clone();
|
||||
let err = validate_frame(&mut f, adapter.profile(), &ValidationPolicy::default(), None);
|
||||
assert!(err.is_err());
|
||||
|
||||
// With a permissive profile it validates fine.
|
||||
let mut f = frames[0].clone();
|
||||
validate_frame(
|
||||
&mut f,
|
||||
&AdapterProfile::offline(AdapterKind::Nexmon),
|
||||
&ValidationPolicy::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.validation, ValidationStatus::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_buffer_is_a_structured_error_not_a_panic() {
|
||||
let bytes = make_record(1, 6, 64, Some(-60));
|
||||
let truncated = &bytes[..bytes.len() - 10];
|
||||
let err = decode_record(truncated).unwrap_err();
|
||||
assert!(err.to_string().to_lowercase().contains("trunc") || err.to_string().to_lowercase().contains("short"));
|
||||
|
||||
let mut adapter = NexmonAdapter::from_bytes("t", SessionId(0), truncated.to_vec());
|
||||
assert!(adapter.next_frame().is_err());
|
||||
assert_eq!(adapter.health().frames_rejected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic_is_rejected() {
|
||||
let mut bytes = make_record(1, 6, 64, Some(-60));
|
||||
bytes[0] = 0xFF;
|
||||
assert!(decode_record(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_from_bytes_helper() {
|
||||
let mut buf = make_record(10, 1, 64, Some(-50));
|
||||
buf.extend(make_record(20, 1, 64, Some(-51)));
|
||||
let frames = NexmonAdapter::frames_from_bytes("t", SessionId(1), &buf).unwrap();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[1].timestamp_ns, 20);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
//! Source adapters — the [`CsiSource`] plugin trait (ADR-095 D15) plus the
|
||||
//! [`AdapterProfile`] capability descriptor and [`SourceConfig`] open params.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::RvcsiError;
|
||||
use crate::frame::CsiFrame;
|
||||
use crate::ids::SessionId;
|
||||
|
||||
/// Which family of source produced a frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AdapterKind {
|
||||
/// A recorded `.rvcsi` capture file.
|
||||
File,
|
||||
/// Deterministic replay of a capture session.
|
||||
Replay,
|
||||
/// Nexmon CSI (via the isolated C shim).
|
||||
Nexmon,
|
||||
/// ESP32 CSI over serial/UDP.
|
||||
Esp32,
|
||||
/// Intel `iwlwifi` CSI tool logs.
|
||||
Intel,
|
||||
/// Atheros CSI tool logs.
|
||||
Atheros,
|
||||
/// An in-memory / synthetic source (tests, simulation).
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
impl AdapterKind {
|
||||
/// Stable lower-case slug (`"file"`, `"nexmon"`, ...).
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
AdapterKind::File => "file",
|
||||
AdapterKind::Replay => "replay",
|
||||
AdapterKind::Nexmon => "nexmon",
|
||||
AdapterKind::Esp32 => "esp32",
|
||||
AdapterKind::Intel => "intel",
|
||||
AdapterKind::Atheros => "atheros",
|
||||
AdapterKind::Synthetic => "synthetic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for AdapterKind {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
f.write_str(self.slug())
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability descriptor for a source — used by validation to bound frames and
|
||||
/// by health checks to flag unsupported firmware/driver state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdapterProfile {
|
||||
/// Adapter family.
|
||||
pub adapter_kind: AdapterKind,
|
||||
/// Radio chip, if known (`"BCM43455c0"`, `"ESP32-S3"`, ...).
|
||||
pub chip: Option<String>,
|
||||
/// Firmware version string, if known.
|
||||
pub firmware_version: Option<String>,
|
||||
/// Driver version string, if known.
|
||||
pub driver_version: Option<String>,
|
||||
/// Channels the source can capture on.
|
||||
pub supported_channels: Vec<u16>,
|
||||
/// Bandwidths (MHz) the source supports.
|
||||
pub supported_bandwidths_mhz: Vec<u16>,
|
||||
/// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`).
|
||||
pub expected_subcarrier_counts: Vec<u16>,
|
||||
/// Whether live capture is possible (false for files/replay).
|
||||
pub supports_live_capture: bool,
|
||||
/// Whether frame injection is possible.
|
||||
pub supports_injection: bool,
|
||||
/// Whether monitor mode is available.
|
||||
pub supports_monitor_mode: bool,
|
||||
}
|
||||
|
||||
impl AdapterProfile {
|
||||
/// A permissive profile for file/replay/synthetic sources: any channel,
|
||||
/// any bandwidth, any subcarrier count, no live capabilities.
|
||||
pub fn offline(adapter_kind: AdapterKind) -> Self {
|
||||
AdapterProfile {
|
||||
adapter_kind,
|
||||
chip: None,
|
||||
firmware_version: None,
|
||||
driver_version: None,
|
||||
supported_channels: Vec::new(),
|
||||
supported_bandwidths_mhz: Vec::new(),
|
||||
expected_subcarrier_counts: Vec::new(),
|
||||
supports_live_capture: false,
|
||||
supports_injection: false,
|
||||
supports_monitor_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A typical ESP32-S3 HT20 CSI profile (192 raw subcarriers on HT40,
|
||||
/// 64 on HT20 — both listed; channels 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<String>,
|
||||
}
|
||||
|
||||
impl SourceHealth {
|
||||
/// A "just opened, nothing yet" snapshot.
|
||||
pub fn fresh(connected: bool) -> Self {
|
||||
SourceHealth {
|
||||
connected,
|
||||
frames_delivered: 0,
|
||||
frames_rejected: 0,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for opening a source (mirrors the TS SDK `RvCsi.open(...)` shape).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SourceConfig {
|
||||
/// Source slug: `"file"`, `"replay"`, `"nexmon"`, `"esp32"`, `"intel"`, `"atheros"`.
|
||||
pub source: String,
|
||||
/// Network interface (`"wlan0"`), serial port (`"/dev/ttyUSB0"`), or file path.
|
||||
#[serde(default)]
|
||||
pub target: Option<String>,
|
||||
/// WiFi channel (live sources only).
|
||||
#[serde(default)]
|
||||
pub channel: Option<u16>,
|
||||
/// Bandwidth in MHz (live sources only).
|
||||
#[serde(default)]
|
||||
pub bandwidth_mhz: Option<u16>,
|
||||
/// Replay speed multiplier (`1.0` = real time); replay source only.
|
||||
#[serde(default)]
|
||||
pub replay_speed: Option<f32>,
|
||||
/// Free-form adapter-specific options.
|
||||
#[serde(default)]
|
||||
pub options_json: Option<String>,
|
||||
}
|
||||
|
||||
impl SourceConfig {
|
||||
/// Build a config for the given source slug with no other options set.
|
||||
pub fn new(source: impl Into<String>) -> Self {
|
||||
SourceConfig {
|
||||
source: source.into(),
|
||||
target: None,
|
||||
channel: None,
|
||||
bandwidth_mhz: None,
|
||||
replay_speed: None,
|
||||
options_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: set the target (iface/port/path).
|
||||
pub fn target(mut self, t: impl Into<String>) -> Self {
|
||||
self.target = Some(t.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the channel.
|
||||
pub fn channel(mut self, c: u16) -> Self {
|
||||
self.channel = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the bandwidth.
|
||||
pub fn bandwidth_mhz(mut self, b: u16) -> Self {
|
||||
self.bandwidth_mhz = Some(b);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin trait every CSI source implements.
|
||||
///
|
||||
/// Object-safe so the runtime can hold `Box<dyn CsiSource>`. Adapters produce
|
||||
/// frames with `validation = Pending`; the runtime runs [`crate::validate_frame`]
|
||||
/// before exposing anything.
|
||||
pub trait CsiSource: Send {
|
||||
/// The source's capability descriptor.
|
||||
fn profile(&self) -> &AdapterProfile;
|
||||
|
||||
/// The capture session id this source is bound to.
|
||||
fn session_id(&self) -> SessionId;
|
||||
|
||||
/// Stable source id for logs / RuVector records.
|
||||
fn source_id(&self) -> &crate::ids::SourceId;
|
||||
|
||||
/// Pull the next frame. `Ok(None)` signals end-of-stream (file exhausted,
|
||||
/// replay finished). Live sources block until a frame is available or
|
||||
/// return an [`RvcsiError::Adapter`] on disconnect.
|
||||
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError>;
|
||||
|
||||
/// Current health snapshot.
|
||||
fn health(&self) -> SourceHealth;
|
||||
|
||||
/// Stop the source and release resources. Default: no-op.
|
||||
fn stop(&mut self) -> Result<(), RvcsiError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offline_profile_accepts_anything() {
|
||||
let p = AdapterProfile::offline(AdapterKind::File);
|
||||
assert!(p.accepts_subcarrier_count(57));
|
||||
assert!(p.accepts_channel(999));
|
||||
assert!(!p.supports_live_capture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esp32_profile_bounds() {
|
||||
let p = AdapterProfile::esp32_default();
|
||||
assert!(p.accepts_subcarrier_count(64));
|
||||
assert!(!p.accepts_subcarrier_count(57));
|
||||
assert!(p.accepts_channel(6));
|
||||
assert!(!p.accepts_channel(36));
|
||||
assert!(p.supports_live_capture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_config_builder() {
|
||||
let c = SourceConfig::new("nexmon").target("wlan0").channel(6).bandwidth_mhz(20);
|
||||
assert_eq!(c.source, "nexmon");
|
||||
assert_eq!(c.target.as_deref(), Some("wlan0"));
|
||||
assert_eq!(c.channel, Some(6));
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(serde_json::from_str::<SourceConfig>(&json).unwrap(), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_kind_slug_display() {
|
||||
assert_eq!(AdapterKind::Nexmon.slug(), "nexmon");
|
||||
assert_eq!(AdapterKind::Esp32.to_string(), "esp32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_fresh() {
|
||||
let h = SourceHealth::fresh(true);
|
||||
assert!(h.connected);
|
||||
assert_eq!(h.frames_delivered, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
//! Error type for the rvCSI runtime.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::validation::ValidationError;
|
||||
|
||||
/// Errors surfaced by the rvCSI core, adapters, DSP and event pipeline.
|
||||
///
|
||||
/// Parser failures are structured (never panics, never raw pointers across
|
||||
/// boundaries — ADR-095 D6). A `Validation` error means a frame was *rejected*;
|
||||
/// a *degraded* frame is not an error and is returned normally with reduced
|
||||
/// `quality_score`.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum RvcsiError {
|
||||
/// A source/adapter could not be opened or talked to.
|
||||
#[error("adapter '{kind}' failed: {message}")]
|
||||
Adapter {
|
||||
/// The adapter kind (`"file"`, `"nexmon"`, `"esp32"`, ...).
|
||||
kind: String,
|
||||
/// Human-readable detail.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// A raw byte buffer could not be parsed into a frame.
|
||||
#[error("parse error at offset {offset}: {message}")]
|
||||
Parse {
|
||||
/// Byte offset where parsing failed (best effort).
|
||||
offset: usize,
|
||||
/// Human-readable detail.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// A frame failed validation and was rejected.
|
||||
#[error("frame rejected: {0}")]
|
||||
Validation(#[from] ValidationError),
|
||||
|
||||
/// A configuration value was out of range or inconsistent.
|
||||
#[error("invalid configuration: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// An I/O error (file capture, replay, WebSocket, ...).
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Serialization / deserialization error (JSON capture sidecars, RuVector export).
|
||||
#[error("serde error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// The requested operation is not supported by this source/adapter.
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl RvcsiError {
|
||||
/// Convenience constructor for adapter errors.
|
||||
pub fn adapter(kind: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
RvcsiError::Adapter {
|
||||
kind: kind.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for parse errors.
|
||||
pub fn parse(offset: usize, message: impl Into<String>) -> Self {
|
||||
RvcsiError::Parse {
|
||||
offset,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_messages_are_useful() {
|
||||
let e = RvcsiError::adapter("nexmon", "device /dev/wlan0 not in monitor mode");
|
||||
assert!(e.to_string().contains("nexmon"));
|
||||
assert!(e.to_string().contains("monitor mode"));
|
||||
|
||||
let e = RvcsiError::parse(12, "frame length 0");
|
||||
assert!(e.to_string().contains("offset 12"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
//! The [`CsiEvent`] aggregate — semantic interpretation of one or more windows.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{EventId, SessionId, SourceId, WindowId};
|
||||
|
||||
/// Kinds of event the runtime emits (ADR-095 FR5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CsiEventKind {
|
||||
/// Presence appeared in the sensed space.
|
||||
PresenceStarted,
|
||||
/// Presence ended.
|
||||
PresenceEnded,
|
||||
/// Motion above threshold detected.
|
||||
MotionDetected,
|
||||
/// Motion fell back to baseline.
|
||||
MotionSettled,
|
||||
/// The learned baseline shifted (re-calibration may be warranted).
|
||||
BaselineChanged,
|
||||
/// Signal quality dropped below a usable threshold.
|
||||
SignalQualityDropped,
|
||||
/// The source disconnected.
|
||||
DeviceDisconnected,
|
||||
/// A candidate breathing-rate observation (when signal quality permits).
|
||||
BreathingCandidate,
|
||||
/// A significant unexplained deviation.
|
||||
AnomalyDetected,
|
||||
/// Calibration is required before detection can be trusted.
|
||||
CalibrationRequired,
|
||||
}
|
||||
|
||||
impl CsiEventKind {
|
||||
/// Stable lower-case slug used in logs and the SDK (`"presence_started"`...).
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
CsiEventKind::PresenceStarted => "presence_started",
|
||||
CsiEventKind::PresenceEnded => "presence_ended",
|
||||
CsiEventKind::MotionDetected => "motion_detected",
|
||||
CsiEventKind::MotionSettled => "motion_settled",
|
||||
CsiEventKind::BaselineChanged => "baseline_changed",
|
||||
CsiEventKind::SignalQualityDropped => "signal_quality_dropped",
|
||||
CsiEventKind::DeviceDisconnected => "device_disconnected",
|
||||
CsiEventKind::BreathingCandidate => "breathing_candidate",
|
||||
CsiEventKind::AnomalyDetected => "anomaly_detected",
|
||||
CsiEventKind::CalibrationRequired => "calibration_required",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A detected event with confidence and the evidence windows that justify it.
|
||||
///
|
||||
/// Invariant: `evidence_window_ids` is non-empty and `0.0 <= confidence <= 1.0`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiEvent {
|
||||
/// Event id.
|
||||
pub event_id: EventId,
|
||||
/// What happened.
|
||||
pub kind: CsiEventKind,
|
||||
/// Owning session.
|
||||
pub session_id: SessionId,
|
||||
/// Source that produced the evidence.
|
||||
pub source_id: SourceId,
|
||||
/// When the event was detected (ns).
|
||||
pub timestamp_ns: u64,
|
||||
/// Confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
/// Windows that justify this event (at least one).
|
||||
pub evidence_window_ids: Vec<WindowId>,
|
||||
/// Calibration version detection ran against, if any.
|
||||
pub calibration_version: Option<String>,
|
||||
/// Free-form JSON metadata (motion energy, estimated rate, ...).
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
/// Why a [`CsiEvent`] is malformed.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum EventError {
|
||||
/// No evidence window referenced.
|
||||
#[error("event has no evidence window")]
|
||||
NoEvidence,
|
||||
/// `confidence` escaped `[0, 1]`.
|
||||
#[error("confidence {0} out of [0,1]")]
|
||||
ConfidenceOutOfRange(f32),
|
||||
}
|
||||
|
||||
impl CsiEvent {
|
||||
/// Minimal constructor; sets `metadata_json` to `"{}"`.
|
||||
pub fn new(
|
||||
event_id: EventId,
|
||||
kind: CsiEventKind,
|
||||
session_id: SessionId,
|
||||
source_id: SourceId,
|
||||
timestamp_ns: u64,
|
||||
confidence: f32,
|
||||
evidence_window_ids: Vec<WindowId>,
|
||||
) -> Self {
|
||||
CsiEvent {
|
||||
event_id,
|
||||
kind,
|
||||
session_id,
|
||||
source_id,
|
||||
timestamp_ns,
|
||||
confidence,
|
||||
evidence_window_ids,
|
||||
calibration_version: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a calibration version.
|
||||
pub fn with_calibration(mut self, version: impl Into<String>) -> Self {
|
||||
self.calibration_version = Some(version.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach metadata (any serializable value).
|
||||
pub fn with_metadata<T: Serialize>(mut self, meta: &T) -> Result<Self, serde_json::Error> {
|
||||
self.metadata_json = serde_json::to_string(meta)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Check the aggregate invariant.
|
||||
pub fn validate(&self) -> Result<(), EventError> {
|
||||
if self.evidence_window_ids.is_empty() {
|
||||
return Err(EventError::NoEvidence);
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() {
|
||||
return Err(EventError::ConfidenceOutOfRange(self.confidence));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugs_are_stable() {
|
||||
assert_eq!(CsiEventKind::PresenceStarted.slug(), "presence_started");
|
||||
assert_eq!(CsiEventKind::AnomalyDetected.slug(), "anomaly_detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_evidence_and_bounded_confidence() {
|
||||
let mut e = CsiEvent::new(
|
||||
EventId(0),
|
||||
CsiEventKind::MotionDetected,
|
||||
SessionId(0),
|
||||
SourceId::from("t"),
|
||||
1_000,
|
||||
0.7,
|
||||
vec![WindowId(3)],
|
||||
);
|
||||
assert!(e.validate().is_ok());
|
||||
|
||||
e.evidence_window_ids.clear();
|
||||
assert_eq!(e.validate(), Err(EventError::NoEvidence));
|
||||
|
||||
e.evidence_window_ids.push(WindowId(3));
|
||||
e.confidence = 1.2;
|
||||
assert_eq!(e.validate(), Err(EventError::ConfidenceOutOfRange(1.2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_calibration_roundtrip() {
|
||||
#[derive(Serialize)]
|
||||
struct M {
|
||||
motion_energy: f32,
|
||||
}
|
||||
let e = CsiEvent::new(
|
||||
EventId(1),
|
||||
CsiEventKind::PresenceStarted,
|
||||
SessionId(0),
|
||||
SourceId::from("t"),
|
||||
5,
|
||||
0.9,
|
||||
vec![WindowId(0)],
|
||||
)
|
||||
.with_calibration("livingroom@v3")
|
||||
.with_metadata(&M { motion_energy: 1.25 })
|
||||
.unwrap();
|
||||
assert_eq!(e.calibration_version.as_deref(), Some("livingroom@v3"));
|
||||
assert!(e.metadata_json.contains("1.25"));
|
||||
let json = serde_json::to_string(&e).unwrap();
|
||||
assert_eq!(serde_json::from_str::<CsiEvent>(&json).unwrap(), e);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
//! The normalized [`CsiFrame`] — the FFI-safe boundary object (ADR-095 D5/D6).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::adapter::AdapterKind;
|
||||
use crate::ids::{FrameId, SessionId, SourceId};
|
||||
|
||||
/// Outcome of the validation pipeline for a frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ValidationStatus {
|
||||
/// Not yet validated — set by adapters before [`crate::validate_frame`] runs.
|
||||
/// A `Pending` frame must never cross a language boundary.
|
||||
Pending,
|
||||
/// Passed all checks.
|
||||
Accepted,
|
||||
/// Usable but with reduced confidence; carries a reason in `quality_reasons`.
|
||||
Degraded,
|
||||
/// Failed a hard check; quarantined when quarantine is enabled, otherwise dropped.
|
||||
Rejected,
|
||||
/// Reconstructed during replay or gap-recovery; timestamp monotonicity is waived.
|
||||
Recovered,
|
||||
}
|
||||
|
||||
impl ValidationStatus {
|
||||
/// Whether a frame with this status may be exposed to SDK/DSP/memory/agents.
|
||||
#[inline]
|
||||
pub fn is_exposable(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ValidationStatus::Accepted | ValidationStatus::Degraded | ValidationStatus::Recovered
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One CSI observation at a timestamp, normalized across all sources.
|
||||
///
|
||||
/// Invariants enforced by [`crate::validate_frame`]:
|
||||
/// * `i_values.len() == q_values.len() == amplitude.len() == phase.len() == subcarrier_count`
|
||||
/// * all of `i_values`/`q_values`/`amplitude`/`phase` are finite
|
||||
/// * `subcarrier_count` is within the source's [`crate::AdapterProfile`]
|
||||
/// * `rssi_dbm`, when present, is within plausible device bounds
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Monotonic id within the session.
|
||||
pub frame_id: FrameId,
|
||||
/// Owning capture session.
|
||||
pub session_id: SessionId,
|
||||
/// Human-readable source id.
|
||||
pub source_id: SourceId,
|
||||
/// Which adapter produced this frame.
|
||||
pub adapter_kind: AdapterKind,
|
||||
/// Source timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// WiFi channel number.
|
||||
pub channel: u16,
|
||||
/// Channel bandwidth in MHz (20, 40, 80, 160).
|
||||
pub bandwidth_mhz: u16,
|
||||
/// Received signal strength, dBm, if reported.
|
||||
pub rssi_dbm: Option<i16>,
|
||||
/// Noise floor, dBm, if reported.
|
||||
pub noise_floor_dbm: Option<i16>,
|
||||
/// Receive-antenna index, if reported.
|
||||
pub antenna_index: Option<u8>,
|
||||
/// Transmit chain index, if reported.
|
||||
pub tx_chain: Option<u8>,
|
||||
/// Receive chain index, if reported.
|
||||
pub rx_chain: Option<u8>,
|
||||
/// Number of subcarriers (== length of the four vectors below).
|
||||
pub subcarrier_count: u16,
|
||||
/// In-phase components, one per subcarrier.
|
||||
pub i_values: Vec<f32>,
|
||||
/// Quadrature components, one per subcarrier.
|
||||
pub q_values: Vec<f32>,
|
||||
/// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier.
|
||||
pub amplitude: Vec<f32>,
|
||||
/// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later).
|
||||
pub phase: Vec<f32>,
|
||||
/// Validation outcome.
|
||||
pub validation: ValidationStatus,
|
||||
/// Quality / usability confidence in `[0.0, 1.0]`.
|
||||
pub quality_score: f32,
|
||||
/// Reasons a frame was degraded (empty when `Accepted`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub quality_reasons: Vec<String>,
|
||||
/// Calibration version this frame was processed against, if any.
|
||||
pub calibration_version: Option<String>,
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Build a raw (un-validated) frame from interleaved-free I/Q vectors.
|
||||
///
|
||||
/// `amplitude` and `phase` are derived from `i_values`/`q_values`. The
|
||||
/// frame is returned with `validation = Pending` and `quality_score = 0.0`;
|
||||
/// run [`crate::validate_frame`] before exposing it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_iq(
|
||||
frame_id: FrameId,
|
||||
session_id: SessionId,
|
||||
source_id: SourceId,
|
||||
adapter_kind: AdapterKind,
|
||||
timestamp_ns: u64,
|
||||
channel: u16,
|
||||
bandwidth_mhz: u16,
|
||||
i_values: Vec<f32>,
|
||||
q_values: Vec<f32>,
|
||||
) -> Self {
|
||||
let n = i_values.len();
|
||||
let mut amplitude = Vec::with_capacity(n);
|
||||
let mut phase = Vec::with_capacity(n);
|
||||
for (i, q) in i_values.iter().zip(q_values.iter()) {
|
||||
amplitude.push((i * i + q * q).sqrt());
|
||||
phase.push(q.atan2(*i));
|
||||
}
|
||||
CsiFrame {
|
||||
frame_id,
|
||||
session_id,
|
||||
source_id,
|
||||
adapter_kind,
|
||||
timestamp_ns,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
rssi_dbm: None,
|
||||
noise_floor_dbm: None,
|
||||
antenna_index: None,
|
||||
tx_chain: None,
|
||||
rx_chain: None,
|
||||
subcarrier_count: n as u16,
|
||||
i_values,
|
||||
q_values,
|
||||
amplitude,
|
||||
phase,
|
||||
validation: ValidationStatus::Pending,
|
||||
quality_score: 0.0,
|
||||
quality_reasons: Vec::new(),
|
||||
calibration_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder-style setter for RSSI.
|
||||
pub fn with_rssi(mut self, rssi_dbm: i16) -> Self {
|
||||
self.rssi_dbm = Some(rssi_dbm);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for noise floor.
|
||||
pub fn with_noise_floor(mut self, noise_floor_dbm: i16) -> Self {
|
||||
self.noise_floor_dbm = Some(noise_floor_dbm);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for antenna / chain metadata.
|
||||
pub fn with_chains(mut self, antenna: Option<u8>, tx: Option<u8>, rx: Option<u8>) -> Self {
|
||||
self.antenna_index = antenna;
|
||||
self.tx_chain = tx;
|
||||
self.rx_chain = rx;
|
||||
self
|
||||
}
|
||||
|
||||
/// Mean amplitude across subcarriers (0.0 for an empty frame).
|
||||
pub fn mean_amplitude(&self) -> f32 {
|
||||
if self.amplitude.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.amplitude.iter().sum::<f32>() / self.amplitude.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this frame may be exposed across a language boundary.
|
||||
pub fn is_exposable(&self) -> bool {
|
||||
self.validation.is_exposable()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> CsiFrame {
|
||||
CsiFrame::from_iq(
|
||||
FrameId(0),
|
||||
SessionId(0),
|
||||
SourceId::from("test"),
|
||||
AdapterKind::File,
|
||||
1_000,
|
||||
6,
|
||||
20,
|
||||
vec![3.0, 0.0, -1.0],
|
||||
vec![4.0, 2.0, 0.0],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_amplitude_and_phase() {
|
||||
let f = sample();
|
||||
assert_eq!(f.subcarrier_count, 3);
|
||||
assert!((f.amplitude[0] - 5.0).abs() < 1e-6); // 3-4-5 triangle
|
||||
assert!((f.amplitude[1] - 2.0).abs() < 1e-6);
|
||||
assert!((f.phase[0] - (4.0f32).atan2(3.0)).abs() < 1e-6);
|
||||
assert_eq!(f.validation, ValidationStatus::Pending);
|
||||
assert_eq!(f.quality_score, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_setters_and_mean() {
|
||||
let f = sample().with_rssi(-55).with_noise_floor(-92).with_chains(Some(0), None, Some(1));
|
||||
assert_eq!(f.rssi_dbm, Some(-55));
|
||||
assert_eq!(f.noise_floor_dbm, Some(-92));
|
||||
assert_eq!(f.antenna_index, Some(0));
|
||||
assert_eq!(f.rx_chain, Some(1));
|
||||
assert!((f.mean_amplitude() - (5.0 + 2.0 + 1.0) / 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposability_rules() {
|
||||
assert!(!ValidationStatus::Pending.is_exposable());
|
||||
assert!(!ValidationStatus::Rejected.is_exposable());
|
||||
assert!(ValidationStatus::Accepted.is_exposable());
|
||||
assert!(ValidationStatus::Degraded.is_exposable());
|
||||
assert!(ValidationStatus::Recovered.is_exposable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_json_roundtrips() {
|
||||
let f = sample().with_rssi(-60);
|
||||
let json = serde_json::to_string(&f).unwrap();
|
||||
let back: CsiFrame = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(f, back);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
//! Identifier value objects.
|
||||
//!
|
||||
//! `FrameId`, `WindowId` and `EventId` are monotonic `u64` newtypes minted by
|
||||
//! an [`IdGenerator`]. `SessionId` is also a `u64` (one per capture session).
|
||||
//! `SourceId` wraps a human-readable string (`"esp32-com7"`, `"pcap:lab.pcap"`)
|
||||
//! so logs and RuVector records stay legible.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! u64_newtype {
|
||||
($(#[$m:meta])* $name:ident) => {
|
||||
$(#[$m])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(pub u64);
|
||||
|
||||
impl $name {
|
||||
/// The raw integer value.
|
||||
#[inline]
|
||||
pub const fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for $name {
|
||||
#[inline]
|
||||
fn from(v: u64) -> Self {
|
||||
$name(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}#{}", stringify!($name), self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
u64_newtype!(
|
||||
/// Identifies one CSI observation within a capture session.
|
||||
FrameId
|
||||
);
|
||||
u64_newtype!(
|
||||
/// Identifies a capture session (one source + one runtime config).
|
||||
SessionId
|
||||
);
|
||||
u64_newtype!(
|
||||
/// Identifies a bounded window of frames.
|
||||
WindowId
|
||||
);
|
||||
u64_newtype!(
|
||||
/// Identifies a semantic event.
|
||||
EventId
|
||||
);
|
||||
|
||||
/// Human-readable identifier for a CSI source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct SourceId(pub String);
|
||||
|
||||
impl SourceId {
|
||||
/// Construct from anything string-like.
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
SourceId(s.into())
|
||||
}
|
||||
|
||||
/// Borrow the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SourceId {
|
||||
fn from(s: &str) -> Self {
|
||||
SourceId(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SourceId {
|
||||
fn from(s: String) -> Self {
|
||||
SourceId(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for SourceId {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic id minter shared by a runtime instance.
|
||||
///
|
||||
/// Frame, window and event id spaces are independent. The generator is
|
||||
/// `Send + Sync` (atomic counters) so it can be shared across the capture,
|
||||
/// signal and event tasks.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IdGenerator {
|
||||
frame: AtomicU64,
|
||||
window: AtomicU64,
|
||||
event: AtomicU64,
|
||||
session: AtomicU64,
|
||||
}
|
||||
|
||||
impl IdGenerator {
|
||||
/// A fresh generator with all counters at zero.
|
||||
pub const fn new() -> Self {
|
||||
IdGenerator {
|
||||
frame: AtomicU64::new(0),
|
||||
window: AtomicU64::new(0),
|
||||
event: AtomicU64::new(0),
|
||||
session: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Next frame id.
|
||||
pub fn next_frame(&self) -> FrameId {
|
||||
FrameId(self.frame.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Next window id.
|
||||
pub fn next_window(&self) -> WindowId {
|
||||
WindowId(self.window.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Next event id.
|
||||
pub fn next_event(&self) -> EventId {
|
||||
EventId(self.event.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Next session id.
|
||||
pub fn next_session(&self) -> SessionId {
|
||||
SessionId(self.session.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn id_generator_is_monotonic_and_independent() {
|
||||
let g = IdGenerator::new();
|
||||
assert_eq!(g.next_frame(), FrameId(0));
|
||||
assert_eq!(g.next_frame(), FrameId(1));
|
||||
assert_eq!(g.next_window(), WindowId(0));
|
||||
assert_eq!(g.next_event(), EventId(0));
|
||||
assert_eq!(g.next_frame(), FrameId(2));
|
||||
assert_eq!(g.next_session(), SessionId(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_id_roundtrips_and_displays() {
|
||||
let s = SourceId::from("esp32-com7");
|
||||
assert_eq!(s.as_str(), "esp32-com7");
|
||||
assert_eq!(s.to_string(), "esp32-com7");
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<SourceId>(&json).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_newtype_display_and_serde() {
|
||||
let f = FrameId(42);
|
||||
assert_eq!(f.value(), 42);
|
||||
assert_eq!(f.to_string(), "FrameId#42");
|
||||
let json = serde_json::to_string(&f).unwrap();
|
||||
assert_eq!(json, "42");
|
||||
assert_eq!(serde_json::from_str::<FrameId>(&json).unwrap(), f);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
//! # rvCSI core
|
||||
//!
|
||||
//! Foundation types for the rvCSI edge RF sensing runtime (ADR-095, ADR-096).
|
||||
//!
|
||||
//! Every CSI source is normalized into a [`CsiFrame`]; bounded sequences of
|
||||
//! frames become a [`CsiWindow`]; semantic interpretations become a
|
||||
//! [`CsiEvent`]. A [`CsiSource`] is the plugin trait every hardware/file/replay
|
||||
//! adapter implements. Nothing crosses a language boundary (napi-rs / napi-c)
|
||||
//! until [`validate_frame`] has run and the frame's [`ValidationStatus`] is
|
||||
//! `Accepted` or `Degraded`.
|
||||
//!
|
||||
//! This crate is dependency-light (serde + thiserror only) and `no_std`-clean
|
||||
//! in spirit so it can be reused from WASM later.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod adapter;
|
||||
mod error;
|
||||
mod event;
|
||||
mod frame;
|
||||
mod ids;
|
||||
mod validation;
|
||||
mod window;
|
||||
|
||||
pub use adapter::{AdapterKind, AdapterProfile, CsiSource, SourceConfig, SourceHealth};
|
||||
pub use error::RvcsiError;
|
||||
pub use event::{CsiEvent, CsiEventKind};
|
||||
pub use frame::{CsiFrame, ValidationStatus};
|
||||
pub use ids::{EventId, FrameId, IdGenerator, SessionId, SourceId, WindowId};
|
||||
pub use validation::{validate_frame, QualityScore, ValidationError, ValidationPolicy};
|
||||
pub use window::CsiWindow;
|
||||
|
||||
/// Re-exported result type for the runtime.
|
||||
pub type Result<T> = core::result::Result<T, RvcsiError>;
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
//! The validation pipeline (ADR-095 D6/D13).
|
||||
//!
|
||||
//! [`validate_frame`] is the only door between raw adapter output and anything
|
||||
//! downstream (DSP, events, the napi boundary, RuVector). It mutates a frame in
|
||||
//! place: on success it sets `validation` to `Accepted` or `Degraded` and fills
|
||||
//! `quality_score`; on a hard failure it returns a [`ValidationError`] and the
|
||||
//! caller quarantines the frame (when quarantine is enabled) or drops it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::adapter::AdapterProfile;
|
||||
use crate::frame::{CsiFrame, ValidationStatus};
|
||||
|
||||
/// Tunable bounds for the validation pipeline.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ValidationPolicy {
|
||||
/// Minimum acceptable subcarrier count.
|
||||
pub min_subcarriers: u16,
|
||||
/// Maximum acceptable subcarrier count.
|
||||
pub max_subcarriers: u16,
|
||||
/// Plausible RSSI range, dBm (inclusive).
|
||||
pub rssi_dbm_bounds: (i16, i16),
|
||||
/// If `true`, a non-monotonic timestamp is a hard reject; if `false`, the
|
||||
/// frame is marked [`ValidationStatus::Recovered`] and accepted.
|
||||
pub strict_monotonic_time: bool,
|
||||
/// If `true`, frames that fail a soft check become `Degraded` instead of
|
||||
/// being rejected; if `false`, soft failures are rejected too.
|
||||
pub degrade_instead_of_reject: bool,
|
||||
/// Frames whose computed quality is below this become `Degraded`
|
||||
/// (or rejected if `degrade_instead_of_reject` is false).
|
||||
pub min_quality: f32,
|
||||
}
|
||||
|
||||
impl Default for ValidationPolicy {
|
||||
fn default() -> Self {
|
||||
ValidationPolicy {
|
||||
min_subcarriers: 1,
|
||||
max_subcarriers: 4096,
|
||||
rssi_dbm_bounds: (-110, 0),
|
||||
strict_monotonic_time: false,
|
||||
degrade_instead_of_reject: true,
|
||||
min_quality: 0.25,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computed usability confidence for a frame, in `[0.0, 1.0]`.
|
||||
///
|
||||
/// Starts at `1.0` and accrues multiplicative penalties for: out-of-range
|
||||
/// (but non-fatal) RSSI, near-zero amplitude (dead subcarriers), excessive
|
||||
/// amplitude spikes, and missing optional metadata that the profile implies
|
||||
/// should be present.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QualityScore {
|
||||
/// The final score.
|
||||
pub value: f32,
|
||||
/// Human-readable reasons it was reduced (empty when `value == 1.0`).
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
impl QualityScore {
|
||||
fn full() -> Self {
|
||||
QualityScore {
|
||||
value: 1.0,
|
||||
reasons: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn penalize(&mut self, factor: f32, reason: impl Into<String>) {
|
||||
self.value = (self.value * factor).clamp(0.0, 1.0);
|
||||
self.reasons.push(reason.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a frame was rejected (a hard failure).
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ValidationError {
|
||||
/// The four parallel vectors disagree in length, or none match `subcarrier_count`.
|
||||
#[error("vector length mismatch: i={i}, q={q}, amp={amp}, phase={phase}, subcarrier_count={sc}")]
|
||||
LengthMismatch {
|
||||
/// i_values length
|
||||
i: usize,
|
||||
/// q_values length
|
||||
q: usize,
|
||||
/// amplitude length
|
||||
amp: usize,
|
||||
/// phase length
|
||||
phase: usize,
|
||||
/// declared subcarrier_count
|
||||
sc: usize,
|
||||
},
|
||||
/// Subcarrier count is outside `[policy.min, policy.max]` or not in the profile.
|
||||
#[error("subcarrier count {count} not allowed (policy {min}..={max}, profile-allowed: {profile_ok})")]
|
||||
SubcarrierCount {
|
||||
/// the count
|
||||
count: u16,
|
||||
/// policy minimum
|
||||
min: u16,
|
||||
/// policy maximum
|
||||
max: u16,
|
||||
/// whether the profile's expected list allowed it
|
||||
profile_ok: bool,
|
||||
},
|
||||
/// A non-finite (NaN / inf) value in one of the vectors.
|
||||
#[error("non-finite value in '{vector}' at index {index}")]
|
||||
NonFinite {
|
||||
/// which vector
|
||||
vector: &'static str,
|
||||
/// index of the offending element
|
||||
index: usize,
|
||||
},
|
||||
/// RSSI is so far out of range it's implausible (hard reject).
|
||||
#[error("implausible RSSI {rssi} dBm (bounds {min}..={max})")]
|
||||
ImplausibleRssi {
|
||||
/// reported rssi
|
||||
rssi: i16,
|
||||
/// lower bound
|
||||
min: i16,
|
||||
/// upper bound
|
||||
max: i16,
|
||||
},
|
||||
/// Timestamp went backwards and `strict_monotonic_time` is set.
|
||||
#[error("non-monotonic timestamp: {ts} <= previous {prev}")]
|
||||
NonMonotonicTime {
|
||||
/// this frame's timestamp
|
||||
ts: u64,
|
||||
/// previous frame's timestamp
|
||||
prev: u64,
|
||||
},
|
||||
/// Channel is not supported by the source profile.
|
||||
#[error("channel {channel} not in source profile")]
|
||||
UnsupportedChannel {
|
||||
/// the channel
|
||||
channel: u16,
|
||||
},
|
||||
/// Computed quality fell below `policy.min_quality` and degradation is disabled.
|
||||
#[error("quality {quality} below minimum {min}")]
|
||||
BelowMinQuality {
|
||||
/// computed quality
|
||||
quality: f32,
|
||||
/// configured minimum
|
||||
min: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// How implausibly far outside the bounds an RSSI must be before it's a hard
|
||||
/// reject rather than a quality penalty.
|
||||
const RSSI_HARD_MARGIN: i16 = 30;
|
||||
|
||||
/// Validate `frame` against `profile` and `policy`, mutating it in place.
|
||||
///
|
||||
/// `prev_timestamp_ns` is the timestamp of the previous accepted frame in the
|
||||
/// same session (or `None` for the first frame); it is used for the
|
||||
/// monotonicity check.
|
||||
///
|
||||
/// On `Ok(())` the frame's `validation` is `Accepted` / `Degraded` /
|
||||
/// `Recovered` and `quality_score` is set. On `Err`, the frame's `validation`
|
||||
/// has been set to `Rejected` (so a caller that ignores the error still won't
|
||||
/// expose it) and the error explains why.
|
||||
pub fn validate_frame(
|
||||
frame: &mut CsiFrame,
|
||||
profile: &AdapterProfile,
|
||||
policy: &ValidationPolicy,
|
||||
prev_timestamp_ns: Option<u64>,
|
||||
) -> Result<(), ValidationError> {
|
||||
// -- hard checks ---------------------------------------------------------
|
||||
let sc = frame.subcarrier_count as usize;
|
||||
if frame.i_values.len() != sc
|
||||
|| frame.q_values.len() != sc
|
||||
|| frame.amplitude.len() != sc
|
||||
|| frame.phase.len() != sc
|
||||
{
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::LengthMismatch {
|
||||
i: frame.i_values.len(),
|
||||
q: frame.q_values.len(),
|
||||
amp: frame.amplitude.len(),
|
||||
phase: frame.phase.len(),
|
||||
sc,
|
||||
});
|
||||
}
|
||||
|
||||
let profile_ok = profile.accepts_subcarrier_count(frame.subcarrier_count);
|
||||
if frame.subcarrier_count < policy.min_subcarriers
|
||||
|| frame.subcarrier_count > policy.max_subcarriers
|
||||
|| !profile_ok
|
||||
{
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::SubcarrierCount {
|
||||
count: frame.subcarrier_count,
|
||||
min: policy.min_subcarriers,
|
||||
max: policy.max_subcarriers,
|
||||
profile_ok,
|
||||
});
|
||||
}
|
||||
|
||||
for (name, v) in [
|
||||
("i_values", &frame.i_values),
|
||||
("q_values", &frame.q_values),
|
||||
("amplitude", &frame.amplitude),
|
||||
("phase", &frame.phase),
|
||||
] {
|
||||
if let Some(idx) = v.iter().position(|x| !x.is_finite()) {
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::NonFinite {
|
||||
vector: name,
|
||||
index: idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !profile.accepts_channel(frame.channel) {
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::UnsupportedChannel {
|
||||
channel: frame.channel,
|
||||
});
|
||||
}
|
||||
|
||||
let (rssi_lo, rssi_hi) = policy.rssi_dbm_bounds;
|
||||
if let Some(rssi) = frame.rssi_dbm {
|
||||
if rssi < rssi_lo - RSSI_HARD_MARGIN || rssi > rssi_hi + RSSI_HARD_MARGIN {
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::ImplausibleRssi {
|
||||
rssi,
|
||||
min: rssi_lo,
|
||||
max: rssi_hi,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut recovered_time = false;
|
||||
if let Some(prev) = prev_timestamp_ns {
|
||||
if frame.timestamp_ns <= prev {
|
||||
if policy.strict_monotonic_time {
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::NonMonotonicTime {
|
||||
ts: frame.timestamp_ns,
|
||||
prev,
|
||||
});
|
||||
}
|
||||
recovered_time = true;
|
||||
}
|
||||
}
|
||||
|
||||
// -- quality scoring (soft) ---------------------------------------------
|
||||
let mut q = QualityScore::full();
|
||||
|
||||
if let Some(rssi) = frame.rssi_dbm {
|
||||
if rssi < rssi_lo || rssi > rssi_hi {
|
||||
q.penalize(0.6, format!("rssi {rssi} dBm outside [{rssi_lo},{rssi_hi}]"));
|
||||
}
|
||||
}
|
||||
|
||||
// dead subcarriers (amplitude ~ 0)
|
||||
let dead = frame.amplitude.iter().filter(|a| **a < 1e-6).count();
|
||||
if dead > 0 {
|
||||
let frac = dead as f32 / sc.max(1) as f32;
|
||||
q.penalize((1.0 - frac).max(0.05), format!("{dead}/{sc} dead subcarriers"));
|
||||
}
|
||||
|
||||
// amplitude spikes (a single subcarrier >> the median magnitude)
|
||||
if sc >= 3 {
|
||||
let mut sorted: Vec<f32> = frame.amplitude.clone();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
|
||||
let median = sorted[sc / 2].max(1e-9);
|
||||
let max = *sorted.last().unwrap();
|
||||
if max > median * 50.0 {
|
||||
q.penalize(0.7, format!("amplitude spike: max {max:.3} vs median {median:.3}"));
|
||||
}
|
||||
}
|
||||
|
||||
// implied-but-missing metadata
|
||||
if frame.rssi_dbm.is_none() {
|
||||
q.penalize(0.95, "missing rssi");
|
||||
}
|
||||
|
||||
let status = if recovered_time {
|
||||
ValidationStatus::Recovered
|
||||
} else if q.value < policy.min_quality {
|
||||
if policy.degrade_instead_of_reject {
|
||||
ValidationStatus::Degraded
|
||||
} else {
|
||||
frame.validation = ValidationStatus::Rejected;
|
||||
return Err(ValidationError::BelowMinQuality {
|
||||
quality: q.value,
|
||||
min: policy.min_quality,
|
||||
});
|
||||
}
|
||||
} else if q.reasons.is_empty() {
|
||||
ValidationStatus::Accepted
|
||||
} else if policy.degrade_instead_of_reject {
|
||||
// soft penalties but above the floor → still acceptable, just note them
|
||||
ValidationStatus::Accepted
|
||||
} else {
|
||||
ValidationStatus::Accepted
|
||||
};
|
||||
|
||||
frame.validation = status;
|
||||
frame.quality_score = q.value;
|
||||
frame.quality_reasons = q.reasons;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::AdapterKind;
|
||||
use crate::ids::{FrameId, SessionId, SourceId};
|
||||
|
||||
fn raw(sc: usize) -> CsiFrame {
|
||||
CsiFrame::from_iq(
|
||||
FrameId(0),
|
||||
SessionId(0),
|
||||
SourceId::from("t"),
|
||||
AdapterKind::File,
|
||||
1_000,
|
||||
6,
|
||||
20,
|
||||
vec![1.0; sc],
|
||||
vec![1.0; sc],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_frame_is_accepted_with_perfect_quality() {
|
||||
let mut f = raw(56).with_rssi(-55);
|
||||
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
|
||||
assert_eq!(f.validation, ValidationStatus::Accepted);
|
||||
assert_eq!(f.quality_score, 1.0);
|
||||
assert!(f.quality_reasons.is_empty());
|
||||
assert!(f.is_exposable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rssi_is_a_minor_penalty_not_a_reject() {
|
||||
let mut f = raw(56);
|
||||
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
|
||||
assert_eq!(f.validation, ValidationStatus::Accepted);
|
||||
assert!(f.quality_score < 1.0);
|
||||
assert!(f.quality_reasons.iter().any(|r| r.contains("rssi")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_mismatch_is_rejected() {
|
||||
let mut f = raw(56);
|
||||
f.q_values.pop();
|
||||
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::LengthMismatch { .. }));
|
||||
assert_eq!(f.validation, ValidationStatus::Rejected);
|
||||
assert!(!f.is_exposable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_is_rejected() {
|
||||
let mut f = raw(4);
|
||||
f.amplitude[2] = f32::NAN;
|
||||
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::NonFinite { vector: "amplitude", index: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcarrier_count_must_match_profile() {
|
||||
let mut f = raw(57); // ESP32 expects 64/128/192
|
||||
let err = validate_frame(&mut f, &AdapterProfile::esp32_default(), &ValidationPolicy::default(), None).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::SubcarrierCount { count: 57, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_monotonic_time_is_recovered_when_lenient_rejected_when_strict() {
|
||||
let mut f = raw(56).with_rssi(-50);
|
||||
// lenient
|
||||
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), Some(2_000)).unwrap();
|
||||
assert_eq!(f.validation, ValidationStatus::Recovered);
|
||||
// strict
|
||||
let mut g = raw(56).with_rssi(-50);
|
||||
let policy = ValidationPolicy { strict_monotonic_time: true, ..Default::default() };
|
||||
let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, Some(2_000)).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::NonMonotonicTime { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_subcarriers_degrade_quality() {
|
||||
let mut f = raw(10).with_rssi(-50);
|
||||
for a in f.amplitude.iter_mut().take(8) {
|
||||
*a = 0.0;
|
||||
}
|
||||
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
|
||||
assert!(f.quality_score < 0.5);
|
||||
assert!(f.quality_reasons.iter().any(|r| r.contains("dead subcarriers")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_low_quality_can_be_degraded_or_rejected() {
|
||||
// 9/10 dead → quality ~0.1 < min_quality 0.25
|
||||
let mk = || {
|
||||
let mut f = raw(10).with_rssi(-50);
|
||||
for a in f.amplitude.iter_mut().take(9) {
|
||||
*a = 0.0;
|
||||
}
|
||||
f
|
||||
};
|
||||
let mut f = mk();
|
||||
validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
|
||||
assert_eq!(f.validation, ValidationStatus::Degraded);
|
||||
|
||||
let mut g = mk();
|
||||
let policy = ValidationPolicy { degrade_instead_of_reject: false, ..Default::default() };
|
||||
let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, None).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::BelowMinQuality { .. }));
|
||||
assert_eq!(g.validation, ValidationStatus::Rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_rssi_is_hard_reject() {
|
||||
let mut f = raw(56).with_rssi(50); // way above 0 + margin
|
||||
let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::ImplausibleRssi { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
//! The [`CsiWindow`] aggregate — a bounded sequence of frames from one source.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{SessionId, SourceId, WindowId};
|
||||
|
||||
/// A bounded window of frames, summarized into per-subcarrier statistics plus
|
||||
/// scalar motion / presence / quality scores.
|
||||
///
|
||||
/// Invariants (enforced by the DSP windowing stage, [`CsiWindow::validate`]):
|
||||
/// * all frames came from one `source_id` and one `session_id`
|
||||
/// * `start_ns < end_ns`
|
||||
/// * `0.0 <= presence_score <= 1.0` and `0.0 <= quality_score <= 1.0`
|
||||
/// * `mean_amplitude.len() == phase_variance.len()`
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiWindow {
|
||||
/// Window id.
|
||||
pub window_id: WindowId,
|
||||
/// Owning session.
|
||||
pub session_id: SessionId,
|
||||
/// Source the frames came from.
|
||||
pub source_id: SourceId,
|
||||
/// Timestamp of the first frame, ns.
|
||||
pub start_ns: u64,
|
||||
/// Timestamp of the last frame, ns.
|
||||
pub end_ns: u64,
|
||||
/// Number of frames aggregated.
|
||||
pub frame_count: u32,
|
||||
/// Mean amplitude per subcarrier.
|
||||
pub mean_amplitude: Vec<f32>,
|
||||
/// Phase variance per subcarrier.
|
||||
pub phase_variance: Vec<f32>,
|
||||
/// Scalar motion energy (>= 0).
|
||||
pub motion_energy: f32,
|
||||
/// Presence score in `[0.0, 1.0]`.
|
||||
pub presence_score: f32,
|
||||
/// Window quality in `[0.0, 1.0]`.
|
||||
pub quality_score: f32,
|
||||
}
|
||||
|
||||
/// Reasons a [`CsiWindow`] failed its invariants.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum WindowError {
|
||||
/// `start_ns >= end_ns`.
|
||||
#[error("window start {start_ns} not before end {end_ns}")]
|
||||
BadTimeOrder {
|
||||
/// start
|
||||
start_ns: u64,
|
||||
/// end
|
||||
end_ns: u64,
|
||||
},
|
||||
/// A score escaped `[0, 1]`.
|
||||
#[error("score '{name}' = {value} out of [0,1]")]
|
||||
ScoreOutOfRange {
|
||||
/// which score
|
||||
name: &'static str,
|
||||
/// the value
|
||||
value: f32,
|
||||
},
|
||||
/// `mean_amplitude` and `phase_variance` disagree in length.
|
||||
#[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")]
|
||||
StatLengthMismatch {
|
||||
/// mean_amplitude length
|
||||
a: usize,
|
||||
/// phase_variance length
|
||||
b: usize,
|
||||
},
|
||||
/// Zero frames in the window.
|
||||
#[error("empty window")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl CsiWindow {
|
||||
/// Duration covered by the window, ns.
|
||||
pub fn duration_ns(&self) -> u64 {
|
||||
self.end_ns.saturating_sub(self.start_ns)
|
||||
}
|
||||
|
||||
/// Number of subcarriers summarized.
|
||||
pub fn subcarrier_count(&self) -> usize {
|
||||
self.mean_amplitude.len()
|
||||
}
|
||||
|
||||
/// Check the aggregate invariants.
|
||||
pub fn validate(&self) -> Result<(), WindowError> {
|
||||
if self.frame_count == 0 {
|
||||
return Err(WindowError::Empty);
|
||||
}
|
||||
if self.start_ns >= self.end_ns {
|
||||
return Err(WindowError::BadTimeOrder {
|
||||
start_ns: self.start_ns,
|
||||
end_ns: self.end_ns,
|
||||
});
|
||||
}
|
||||
if self.mean_amplitude.len() != self.phase_variance.len() {
|
||||
return Err(WindowError::StatLengthMismatch {
|
||||
a: self.mean_amplitude.len(),
|
||||
b: self.phase_variance.len(),
|
||||
});
|
||||
}
|
||||
for (name, v) in [
|
||||
("presence_score", self.presence_score),
|
||||
("quality_score", self.quality_score),
|
||||
] {
|
||||
if !(0.0..=1.0).contains(&v) || !v.is_finite() {
|
||||
return Err(WindowError::ScoreOutOfRange { name, value: v });
|
||||
}
|
||||
}
|
||||
if !self.motion_energy.is_finite() || self.motion_energy < 0.0 {
|
||||
return Err(WindowError::ScoreOutOfRange {
|
||||
name: "motion_energy",
|
||||
value: self.motion_energy,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn good() -> CsiWindow {
|
||||
CsiWindow {
|
||||
window_id: WindowId(0),
|
||||
session_id: SessionId(0),
|
||||
source_id: SourceId::from("test"),
|
||||
start_ns: 1_000,
|
||||
end_ns: 2_000,
|
||||
frame_count: 10,
|
||||
mean_amplitude: vec![1.0, 2.0, 3.0],
|
||||
phase_variance: vec![0.1, 0.1, 0.2],
|
||||
motion_energy: 0.5,
|
||||
presence_score: 0.8,
|
||||
quality_score: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_window_passes() {
|
||||
let w = good();
|
||||
assert!(w.validate().is_ok());
|
||||
assert_eq!(w.duration_ns(), 1_000);
|
||||
assert_eq!(w.subcarrier_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_time_order() {
|
||||
let mut w = good();
|
||||
w.end_ns = w.start_ns;
|
||||
assert!(matches!(w.validate(), Err(WindowError::BadTimeOrder { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_score() {
|
||||
let mut w = good();
|
||||
w.presence_score = 1.5;
|
||||
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "presence_score", .. })));
|
||||
let mut w = good();
|
||||
w.motion_energy = -0.1;
|
||||
assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "motion_energy", .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_stat_mismatch_and_empty() {
|
||||
let mut w = good();
|
||||
w.phase_variance.push(0.3);
|
||||
assert!(matches!(w.validate(), Err(WindowError::StatLengthMismatch { .. })));
|
||||
let mut w = good();
|
||||
w.frame_count = 0;
|
||||
assert!(matches!(w.validate(), Err(WindowError::Empty)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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() {}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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() {}
|
||||
Loading…
Reference in New Issue