Skip to main content

rvcsi_core/
adapter.rs

1//! Source adapters — the [`CsiSource`] plugin trait (ADR-095 D15) plus the
2//! [`AdapterProfile`] capability descriptor and [`SourceConfig`] open params.
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::RvcsiError;
7use crate::frame::CsiFrame;
8use crate::ids::SessionId;
9
10/// Which family of source produced a frame.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum AdapterKind {
13    /// A recorded `.rvcsi` capture file.
14    File,
15    /// Deterministic replay of a capture session.
16    Replay,
17    /// Nexmon CSI (via the isolated C shim).
18    Nexmon,
19    /// ESP32 CSI over serial/UDP.
20    Esp32,
21    /// Intel `iwlwifi` CSI tool logs.
22    Intel,
23    /// Atheros CSI tool logs.
24    Atheros,
25    /// An in-memory / synthetic source (tests, simulation).
26    Synthetic,
27}
28
29impl AdapterKind {
30    /// Stable lower-case slug (`"file"`, `"nexmon"`, ...).
31    pub fn slug(self) -> &'static str {
32        match self {
33            AdapterKind::File => "file",
34            AdapterKind::Replay => "replay",
35            AdapterKind::Nexmon => "nexmon",
36            AdapterKind::Esp32 => "esp32",
37            AdapterKind::Intel => "intel",
38            AdapterKind::Atheros => "atheros",
39            AdapterKind::Synthetic => "synthetic",
40        }
41    }
42}
43
44impl core::fmt::Display for AdapterKind {
45    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46        f.write_str(self.slug())
47    }
48}
49
50/// Capability descriptor for a source — used by validation to bound frames and
51/// by health checks to flag unsupported firmware/driver state.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AdapterProfile {
54    /// Adapter family.
55    pub adapter_kind: AdapterKind,
56    /// Radio chip, if known (`"BCM43455c0"`, `"ESP32-S3"`, ...).
57    pub chip: Option<String>,
58    /// Firmware version string, if known.
59    pub firmware_version: Option<String>,
60    /// Driver version string, if known.
61    pub driver_version: Option<String>,
62    /// Channels the source can capture on.
63    pub supported_channels: Vec<u16>,
64    /// Bandwidths (MHz) the source supports.
65    pub supported_bandwidths_mhz: Vec<u16>,
66    /// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`).
67    pub expected_subcarrier_counts: Vec<u16>,
68    /// Whether live capture is possible (false for files/replay).
69    pub supports_live_capture: bool,
70    /// Whether frame injection is possible.
71    pub supports_injection: bool,
72    /// Whether monitor mode is available.
73    pub supports_monitor_mode: bool,
74}
75
76impl AdapterProfile {
77    /// A permissive profile for file/replay/synthetic sources: any channel,
78    /// any bandwidth, any subcarrier count, no live capabilities.
79    pub fn offline(adapter_kind: AdapterKind) -> Self {
80        AdapterProfile {
81            adapter_kind,
82            chip: None,
83            firmware_version: None,
84            driver_version: None,
85            supported_channels: Vec::new(),
86            supported_bandwidths_mhz: Vec::new(),
87            expected_subcarrier_counts: Vec::new(),
88            supports_live_capture: false,
89            supports_injection: false,
90            supports_monitor_mode: false,
91        }
92    }
93
94    /// A typical ESP32-S3 HT20 CSI profile (192 raw subcarriers on HT40,
95    /// 64 on HT20 — both listed; channels 1–13, 2.4 GHz).
96    pub fn esp32_default() -> Self {
97        AdapterProfile {
98            adapter_kind: AdapterKind::Esp32,
99            chip: Some("ESP32-S3".to_string()),
100            firmware_version: None,
101            driver_version: None,
102            supported_channels: (1..=13).collect(),
103            supported_bandwidths_mhz: vec![20, 40],
104            expected_subcarrier_counts: vec![64, 128, 192],
105            supports_live_capture: true,
106            supports_injection: false,
107            supports_monitor_mode: false,
108        }
109    }
110
111    /// A typical Nexmon (BCM43455c0) CSI profile: 802.11ac, 20/40/80 MHz.
112    pub fn nexmon_default() -> Self {
113        AdapterProfile {
114            adapter_kind: AdapterKind::Nexmon,
115            chip: Some("BCM43455c0".to_string()),
116            firmware_version: None,
117            driver_version: None,
118            supported_channels: vec![1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161],
119            supported_bandwidths_mhz: vec![20, 40, 80],
120            expected_subcarrier_counts: vec![64, 128, 256],
121            supports_live_capture: true,
122            supports_injection: true,
123            supports_monitor_mode: true,
124        }
125    }
126
127    /// `true` if `count` is acceptable for this profile (always true when the
128    /// expected list is empty, e.g. offline sources).
129    pub fn accepts_subcarrier_count(&self, count: u16) -> bool {
130        self.expected_subcarrier_counts.is_empty()
131            || self.expected_subcarrier_counts.contains(&count)
132    }
133
134    /// `true` if `channel` is acceptable (always true when the list is empty).
135    pub fn accepts_channel(&self, channel: u16) -> bool {
136        self.supported_channels.is_empty() || self.supported_channels.contains(&channel)
137    }
138}
139
140/// Health snapshot for a source (returned by [`CsiSource::health`] and the
141/// `rvcsi health` CLI / `rvcsi_health_report` MCP tool).
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SourceHealth {
144    /// `true` while the source is producing frames.
145    pub connected: bool,
146    /// Frames delivered since the session started.
147    pub frames_delivered: u64,
148    /// Frames rejected by validation since the session started.
149    pub frames_rejected: u64,
150    /// Optional human-readable status / last error.
151    pub status: Option<String>,
152}
153
154impl SourceHealth {
155    /// A "just opened, nothing yet" snapshot.
156    pub fn fresh(connected: bool) -> Self {
157        SourceHealth {
158            connected,
159            frames_delivered: 0,
160            frames_rejected: 0,
161            status: None,
162        }
163    }
164}
165
166/// Parameters for opening a source (mirrors the TS SDK `RvCsi.open(...)` shape).
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct SourceConfig {
169    /// Source slug: `"file"`, `"replay"`, `"nexmon"`, `"esp32"`, `"intel"`, `"atheros"`.
170    pub source: String,
171    /// Network interface (`"wlan0"`), serial port (`"/dev/ttyUSB0"`), or file path.
172    #[serde(default)]
173    pub target: Option<String>,
174    /// WiFi channel (live sources only).
175    #[serde(default)]
176    pub channel: Option<u16>,
177    /// Bandwidth in MHz (live sources only).
178    #[serde(default)]
179    pub bandwidth_mhz: Option<u16>,
180    /// Replay speed multiplier (`1.0` = real time); replay source only.
181    #[serde(default)]
182    pub replay_speed: Option<f32>,
183    /// Free-form adapter-specific options.
184    #[serde(default)]
185    pub options_json: Option<String>,
186}
187
188impl SourceConfig {
189    /// Build a config for the given source slug with no other options set.
190    pub fn new(source: impl Into<String>) -> Self {
191        SourceConfig {
192            source: source.into(),
193            target: None,
194            channel: None,
195            bandwidth_mhz: None,
196            replay_speed: None,
197            options_json: None,
198        }
199    }
200
201    /// Builder: set the target (iface/port/path).
202    pub fn target(mut self, t: impl Into<String>) -> Self {
203        self.target = Some(t.into());
204        self
205    }
206
207    /// Builder: set the channel.
208    pub fn channel(mut self, c: u16) -> Self {
209        self.channel = Some(c);
210        self
211    }
212
213    /// Builder: set the bandwidth.
214    pub fn bandwidth_mhz(mut self, b: u16) -> Self {
215        self.bandwidth_mhz = Some(b);
216        self
217    }
218}
219
220/// The plugin trait every CSI source implements.
221///
222/// Object-safe so the runtime can hold `Box<dyn CsiSource>`. Adapters produce
223/// frames with `validation = Pending`; the runtime runs [`crate::validate_frame`]
224/// before exposing anything.
225pub trait CsiSource: Send {
226    /// The source's capability descriptor.
227    fn profile(&self) -> &AdapterProfile;
228
229    /// The capture session id this source is bound to.
230    fn session_id(&self) -> SessionId;
231
232    /// Stable source id for logs / RuVector records.
233    fn source_id(&self) -> &crate::ids::SourceId;
234
235    /// Pull the next frame. `Ok(None)` signals end-of-stream (file exhausted,
236    /// replay finished). Live sources block until a frame is available or
237    /// return an [`RvcsiError::Adapter`] on disconnect.
238    fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError>;
239
240    /// Current health snapshot.
241    fn health(&self) -> SourceHealth;
242
243    /// Stop the source and release resources. Default: no-op.
244    fn stop(&mut self) -> Result<(), RvcsiError> {
245        Ok(())
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn offline_profile_accepts_anything() {
255        let p = AdapterProfile::offline(AdapterKind::File);
256        assert!(p.accepts_subcarrier_count(57));
257        assert!(p.accepts_channel(999));
258        assert!(!p.supports_live_capture);
259    }
260
261    #[test]
262    fn esp32_profile_bounds() {
263        let p = AdapterProfile::esp32_default();
264        assert!(p.accepts_subcarrier_count(64));
265        assert!(!p.accepts_subcarrier_count(57));
266        assert!(p.accepts_channel(6));
267        assert!(!p.accepts_channel(36));
268        assert!(p.supports_live_capture);
269    }
270
271    #[test]
272    fn source_config_builder() {
273        let c = SourceConfig::new("nexmon").target("wlan0").channel(6).bandwidth_mhz(20);
274        assert_eq!(c.source, "nexmon");
275        assert_eq!(c.target.as_deref(), Some("wlan0"));
276        assert_eq!(c.channel, Some(6));
277        let json = serde_json::to_string(&c).unwrap();
278        assert_eq!(serde_json::from_str::<SourceConfig>(&json).unwrap(), c);
279    }
280
281    #[test]
282    fn adapter_kind_slug_display() {
283        assert_eq!(AdapterKind::Nexmon.slug(), "nexmon");
284        assert_eq!(AdapterKind::Esp32.to_string(), "esp32");
285    }
286
287    #[test]
288    fn health_fresh() {
289        let h = SourceHealth::fresh(true);
290        assert!(h.connected);
291        assert_eq!(h.frames_delivered, 0);
292    }
293}