//! # rvCSI Node.js bindings — napi-rs (ADR-095 D3/D4, ADR-096) //! //! The safe TypeScript-facing surface over the rvCSI Rust runtime. Nothing here //! exposes raw pointers; every value that crosses the boundary is either a //! normalized rvCSI struct *serialized to JSON* or a scalar. Frames are run //! through [`rvcsi_core::validate_frame`] inside [`rvcsi_runtime`] before they //! reach JS (D6), so a JS caller never sees a `Pending` or `Rejected` frame. //! //! All real logic lives in the `rvcsi-runtime` crate (plain Rust, unit-tested //! without a Node env); the `#[napi]` items below are one-liner wrappers. //! //! ## JS surface (also see the generated `index.d.ts` in the npm package) //! //! Free functions: //! * `rvcsiVersion(): string` //! * `nexmonShimAbiVersion(): number` — ABI of the linked napi-c shim //! * `nexmonDecodeRecords(buf: Buffer, sourceId: string, sessionId: number): string` //! — JSON array of validated `CsiFrame`s decoded from the C-shim record format //! * `inspectCaptureFile(path: string): string` — JSON `CaptureSummary` //! * `eventsFromCaptureFile(path: string): string` — JSON array of `CsiEvent`s //! * `exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number` //! — windows stored //! //! Class `RvcsiRuntime` (streaming): //! * `RvcsiRuntime.openCaptureFile(path): RvcsiRuntime` //! * `RvcsiRuntime.openNexmonFile(path, sourceId, sessionId): RvcsiRuntime` //! * `.nextFrameJson(): string | null` / `.nextCleanFrameJson(): string | null` //! * `.drainEventsJson(): string` — JSON array of `CsiEvent`s //! * `.healthJson(): string` — JSON `SourceHealth` //! * `.framesSeen` / `.framesDropped` (getters) #![deny(clippy::all)] #[macro_use] extern crate napi_derive; use napi::bindgen_prelude::Buffer; use rvcsi_runtime::{self as runtime, CaptureRuntime}; fn napi_err(e: impl std::fmt::Display) -> napi::Error { napi::Error::from_reason(e.to_string()) } fn to_json(v: &T) -> napi::Result { serde_json::to_string(v).map_err(napi_err) } // --------------------------------------------------------------------------- // Free functions // --------------------------------------------------------------------------- /// rvCSI runtime version (the workspace crate version). #[napi] pub fn rvcsi_version() -> String { env!("CARGO_PKG_VERSION").to_string() } /// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`). #[napi] pub fn nexmon_shim_abi_version() -> u32 { runtime::nexmon_shim_abi_version() } /// Decode a `Buffer` of "rvCSI Nexmon records" (the napi-c shim format) into a /// JSON array of validated `CsiFrame`s. Throws on a malformed record. #[napi] pub fn nexmon_decode_records(buf: Buffer, source_id: String, session_id: u32) -> napi::Result { let frames = runtime::decode_nexmon_records(buf.as_ref(), &source_id, session_id as u64).map_err(napi_err)?; to_json(&frames) } /// Summarize a `.rvcsi` capture file; returns JSON for a `CaptureSummary`. #[napi] pub fn inspect_capture_file(path: String) -> napi::Result { let summary = runtime::summarize_capture(&path).map_err(napi_err)?; to_json(&summary) } /// Replay a `.rvcsi` capture through the DSP + event pipeline; returns a JSON /// array of `CsiEvent`s. #[napi] pub fn events_from_capture_file(path: String) -> napi::Result { let events = runtime::events_from_capture(&path).map_err(napi_err)?; to_json(&events) } /// Replay a `.rvcsi` capture, window it, and store each window's embedding into /// a JSONL RF-memory file; returns the number of windows stored. #[napi] pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) -> napi::Result { let n = runtime::export_capture_to_rf_memory(&capture_path, &out_jsonl_path).map_err(napi_err)?; Ok(n as u32) } /// Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` `Buffer` /// into a JSON array of validated `CsiFrame`s. `port` is the CSI UDP port /// (omit / `null` ⇒ 5500); `chip` is an optional chip / Raspberry-Pi-model spec /// (`"pi5"`, `"bcm43455c0"`, ...) — when given, frames are validated against /// that device's profile and the non-conforming ones dropped. Throws if the /// buffer isn't a parseable classic pcap or `chip` is unrecognised. #[napi] pub fn nexmon_decode_pcap( pcap: Buffer, source_id: String, session_id: u32, port: Option, chip: Option, ) -> napi::Result { let frames = runtime::decode_nexmon_pcap_for(pcap.as_ref(), &source_id, session_id as u64, port, chip.as_deref()) .map_err(napi_err)?; to_json(&frames) } /// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, /// bandwidths, chip versions + resolved chip names, RSSI range, time span); /// returns JSON for a `NexmonPcapSummary`. `port` defaults to 5500. #[napi] pub fn inspect_nexmon_pcap(path: String, port: Option) -> napi::Result { let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?; to_json(&summary) } /// Decode a Broadcom d11ac chanspec word; returns JSON /// `{ chanspec, channel, bandwidth_mhz, is_5ghz }`. #[napi] pub fn decode_chanspec(chanspec: u32) -> napi::Result { let d = rvcsi_adapter_nexmon::decode_chanspec((chanspec & 0xFFFF) as u16); to_json(&serde_json::json!({ "chanspec": d.chanspec, "channel": d.channel, "bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz, })) } /// Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug /// (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise). #[napi] pub fn nexmon_chip_name(chip_ver: u32) -> String { rvcsi_adapter_nexmon::NexmonChip::from_chip_ver((chip_ver & 0xFFFF) as u16).slug() } /// The `AdapterProfile` (channels / bandwidths / expected subcarrier counts / /// capability flags) for a chip / Raspberry-Pi-model spec (`"pi5"`, /// `"bcm43455c0"`, `"raspberry pi 4"`, ...); returns JSON. Throws if unknown. #[napi] pub fn nexmon_profile(spec: String) -> napi::Result { let p = runtime::nexmon_profile_for(&spec) .ok_or_else(|| napi::Error::from_reason(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?; to_json(&p) } /// JSON listing of the Nexmon-supported chips + the Raspberry Pi models that /// carry them (incl. the Pi 5 → BCM43455c0): `{ chips: [...], raspberryPiModels: [...] }`. #[napi] pub fn nexmon_chips() -> napi::Result { use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip}; let chips: Vec<_> = known_chips() .iter() .map(|c| { let p = nexmon_adapter_profile(*c); serde_json::json!({ "slug": c.slug(), "description": c.description(), "dualBand": c.dual_band(), "int16IqExport": c.uses_int16_iq(), "bandwidthsMhz": p.supported_bandwidths_mhz, "expectedSubcarrierCounts": p.expected_subcarrier_counts, }) }) .collect(); let pis: Vec<_> = known_pi_models() .iter() .map(|m| { let chip = m.nexmon_chip(); serde_json::json!({ "slug": m.slug(), "chip": if matches!(chip, NexmonChip::Unknown { .. }) { serde_json::Value::Null } else { serde_json::Value::String(chip.slug()) }, "csiSupported": m.csi_supported(), }) }) .collect(); to_json(&serde_json::json!({ "chips": chips, "raspberryPiModels": pis })) } // --------------------------------------------------------------------------- // Streaming runtime class // --------------------------------------------------------------------------- /// A streaming capture runtime: a source + the DSP stage + the event pipeline. #[napi] pub struct RvcsiRuntime { inner: CaptureRuntime, } #[napi] impl RvcsiRuntime { /// Open a `.rvcsi` capture file as the source. #[napi(factory)] pub fn open_capture_file(path: String) -> napi::Result { Ok(RvcsiRuntime { inner: CaptureRuntime::open_capture_file(&path).map_err(napi_err)?, }) } /// Open a Nexmon capture file (concatenated rvCSI Nexmon records) as the source. #[napi(factory)] pub fn open_nexmon_file(path: String, source_id: String, session_id: u32) -> napi::Result { Ok(RvcsiRuntime { inner: CaptureRuntime::open_nexmon_file(&path, &source_id, session_id as u64).map_err(napi_err)?, }) } /// Open a real nexmon_csi `.pcap` capture as the source. `port` is the CSI /// UDP port (omit / `null` ⇒ 5500). #[napi(factory)] pub fn open_nexmon_pcap( path: String, source_id: String, session_id: u32, port: Option, ) -> napi::Result { Ok(RvcsiRuntime { inner: CaptureRuntime::open_nexmon_pcap(&path, &source_id, session_id as u64, port) .map_err(napi_err)?, }) } /// Next exposable, validated frame as JSON, or `null` at end-of-stream. #[napi] pub fn next_frame_json(&mut self) -> napi::Result> { match self.inner.next_validated_frame().map_err(napi_err)? { Some(f) => Ok(Some(to_json(&f)?)), None => Ok(None), } } /// Like `nextFrameJson` but with the DSP pipeline applied (cleaned amplitude/phase). #[napi] pub fn next_clean_frame_json(&mut self) -> napi::Result> { match self.inner.next_clean_frame().map_err(napi_err)? { Some(f) => Ok(Some(to_json(&f)?)), None => Ok(None), } } /// Drain the rest of the stream through DSP + the event pipeline; JSON array of `CsiEvent`s. #[napi] pub fn drain_events_json(&mut self) -> napi::Result { let events = self.inner.drain_events().map_err(napi_err)?; to_json(&events) } /// Health snapshot as JSON (`SourceHealth`). #[napi] pub fn health_json(&self) -> napi::Result { to_json(&self.inner.health()) } /// Frames pulled from the source so far. #[napi(getter)] pub fn frames_seen(&self) -> u32 { self.inner.frames_seen() as u32 } /// Frames dropped by validation so far. #[napi(getter)] pub fn frames_dropped(&self) -> u32 { self.inner.frames_dropped() as u32 } }