Skip to main content

csi_webserver/
models.rs

1//! Data models used by HTTP handlers and runtime control flow.
2//!
3//! This module contains:
4//! - request-body structs for config/control endpoints,
5//! - runtime enums used by watch channels,
6//! - common API response payloads.
7
8use serde::{Deserialize, Serialize};
9use std::sync::atomic::{AtomicBool, Ordering};
10
11// ─── Device config (cached state) ─────────────────────────────────────────
12
13/// Server-side cached view of device-side `UserConfig`, structured to mirror
14/// the firmware's `show-config` output (sections `[WiFi]`, `[Collection]`,
15/// `[CSI Config]`). Fields are best-effort: each is populated when the
16/// matching `POST /api/config/*` endpoint succeeds, and reset to firmware
17/// defaults by `POST /api/config/reset`. Values can drift if the device is
18/// re-flashed or commands are sent out-of-band.
19///
20/// `sta_password` is intentionally *not* cached even though `show-config`
21/// echoes it — round-tripping plaintext passwords through a GET endpoint
22/// would defeat the point of having one.
23///
24/// The trailing fields (`log_mode`, `csi_delivery_mode`,
25/// `csi_logging_enabled`) live alongside the show-config sections because
26/// they're set via separate CLI commands (`set-log-mode`, `set-csi-delivery`)
27/// and are useful to surface here even though they aren't part of the
28/// `show-config` block.
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct DeviceConfig {
31    pub wifi: WifiSection,
32    pub collection: CollectionSection,
33    pub csi_config: CsiConfigSection,
34    pub log_mode: Option<String>,
35    pub csi_delivery_mode: Option<String>,
36    pub csi_logging_enabled: Option<bool>,
37}
38
39/// `[WiFi]` section in `show-config`.
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41pub struct WifiSection {
42    /// `node_mode` — `sniffer` | `station` | `esp-now-central` | `esp-now-peripheral`.
43    pub mode: Option<String>,
44    /// `channel` — `u8`. Valid Wi-Fi 2.4 GHz: 1..=14.
45    pub channel: Option<u8>,
46    /// `sta_ssid` — UTF-8, ≤ 32 B.
47    pub sta_ssid: Option<String>,
48}
49
50/// `[Collection]` section in `show-config`.
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct CollectionSection {
53    /// `collection_mode` — `collector` | `listener`.
54    pub mode: Option<String>,
55    /// `trigger_freq` Hz. `0` disables traffic generation.
56    pub traffic_hz: Option<u64>,
57    /// `phy_rate` enum — e.g. `mcs0-lgi`. Only honored by ESP-NOW modes.
58    pub phy_rate: Option<String>,
59    /// `io_tasks.tx_enabled`.
60    pub io_tx_enabled: Option<bool>,
61    /// `io_tasks.rx_enabled`.
62    pub io_rx_enabled: Option<bool>,
63}
64
65/// `[CSI Config]` section in `show-config`. Both classic (ESP32 / C3 / S3)
66/// and HE (ESP32-C5 / C6) fields are merged into a single struct so the
67/// JSON shape is stable across chip variants. The fields applicable to
68/// the active firmware are populated; the others remain `None`.
69///
70/// The classic block also includes four read-only fields
71/// (`channel_filter_enabled`, `manual_scale`, `shift`, `dump_ack_enabled`)
72/// that have no `set-csi` flag; they are populated by
73/// `POST /api/config/reset` from firmware defaults but otherwise stay
74/// fixed.
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76pub struct CsiConfigSection {
77    // ── Classic (ESP32 / C3 / S3) ─────────────────────────────────────
78    /// `lltf_en`.
79    pub lltf_enabled: Option<bool>,
80    /// `htltf_en`.
81    pub htltf_enabled: Option<bool>,
82    /// `stbc_htltf2_en`.
83    pub stbc_htltf_enabled: Option<bool>,
84    /// `ltf_merge_en`.
85    pub ltf_merge_enabled: Option<bool>,
86    /// `channel_filter_en` — **read-only**; only restored by `reset-config`.
87    pub channel_filter_enabled: Option<bool>,
88    /// `manu_scale` — **read-only**; only restored by `reset-config`.
89    pub manual_scale: Option<bool>,
90    /// `shift` — **read-only**; only restored by `reset-config`.
91    pub shift: Option<u8>,
92    /// `dump_ack_en` — **read-only**; only restored by `reset-config`.
93    pub dump_ack_enabled: Option<bool>,
94
95    // ── HE (ESP32-C5 / C6) ────────────────────────────────────────────
96    /// `enable` (acquire CSI overall).
97    pub acquire_csi: Option<u32>,
98    /// `acquire_csi_legacy` — L-LTF / 11g.
99    pub acquire_csi_legacy: Option<u32>,
100    /// `acquire_csi_ht20`.
101    pub acquire_csi_ht20: Option<u32>,
102    /// `acquire_csi_ht40`.
103    pub acquire_csi_ht40: Option<u32>,
104    /// `acquire_csi_su` — HE20 single-user.
105    pub acquire_csi_su: Option<u32>,
106    /// `acquire_csi_mu` — HE20 multi-user.
107    pub acquire_csi_mu: Option<u32>,
108    /// `acquire_csi_dcm` — HE20 dual carrier modulation.
109    pub acquire_csi_dcm: Option<u32>,
110    /// `acquire_csi_beamformed`.
111    pub acquire_csi_beamformed: Option<u32>,
112    /// `acquire_csi_he_stbc` — `0` HE-LTF1, `1` HE-LTF2, `2` even sample.
113    pub csi_he_stbc: Option<u32>,
114    /// `val_scale_cfg`.
115    pub val_scale_cfg: Option<u32>,
116}
117
118impl DeviceConfig {
119    /// Snapshot of `UserConfig::new()` / `CsiConfig::default()` on the
120    /// device, as documented in the `show-config` spec. Populated into
121    /// the cache by `POST /api/config/reset` so the response after a
122    /// reset reflects what the firmware actually holds, even before the
123    /// user re-sends any `set-*` commands.
124    ///
125    /// Both the classic and the HE CSI defaults are populated — the
126    /// caller can ignore the fields irrelevant to the connected chip
127    /// (consult `GET /api/info` for the `chip` field).
128    pub fn firmware_defaults() -> Self {
129        Self {
130            wifi: WifiSection {
131                mode: Some("sniffer".to_string()),
132                channel: Some(1),
133                sta_ssid: Some(String::new()),
134            },
135            collection: CollectionSection {
136                mode: Some("collector".to_string()),
137                traffic_hz: Some(100),
138                phy_rate: Some("mcs0-lgi".to_string()),
139                io_tx_enabled: Some(true),
140                io_rx_enabled: Some(true),
141            },
142            csi_config: CsiConfigSection {
143                // Classic
144                lltf_enabled: Some(true),
145                htltf_enabled: Some(true),
146                stbc_htltf_enabled: Some(true),
147                ltf_merge_enabled: Some(true),
148                channel_filter_enabled: Some(false),
149                manual_scale: Some(false),
150                shift: Some(0),
151                dump_ack_enabled: Some(false),
152                // HE
153                acquire_csi: Some(1),
154                acquire_csi_legacy: Some(1),
155                acquire_csi_ht20: Some(1),
156                acquire_csi_ht40: Some(1),
157                acquire_csi_su: Some(1),
158                acquire_csi_mu: Some(1),
159                acquire_csi_dcm: Some(1),
160                acquire_csi_beamformed: Some(1),
161                csi_he_stbc: Some(2),
162                val_scale_cfg: Some(2),
163            },
164            log_mode: None,
165            csi_delivery_mode: None,
166            csi_logging_enabled: None,
167        }
168    }
169}
170
171// ─── Quoting helpers ──────────────────────────────────────────────────────
172
173/// Quote a free-form string argument for `esp-csi-cli-rs`.
174///
175/// The CLI accepts both `'…'` and `"…"`; the opening quote style is
176/// matched by the same style and the other quote is treated literally.
177/// Spaces inside quotes are forwarded as `0x1F` and decoded back to `' '`
178/// in the device-side handler. Underscores are passed through literally
179/// (no shorthand substitution).
180fn quote_cli_arg(s: &str) -> Result<String, String> {
181    if s.contains('\n') || s.contains('\r') {
182        return Err("value cannot contain newline characters".to_string());
183    }
184    if !s.contains('\'') {
185        Ok(format!("'{s}'"))
186    } else if !s.contains('"') {
187        Ok(format!("\"{s}\""))
188    } else {
189        Err("value cannot contain both single and double quote characters".to_string())
190    }
191}
192
193// ─── HTTP request bodies ───────────────────────────────────────────────────
194
195#[derive(Debug, Deserialize)]
196pub struct WifiConfig {
197    /// `station` | `sniffer` | `esp-now-central` | `esp-now-peripheral`.
198    pub mode: String,
199    pub sta_ssid: Option<String>,
200    pub sta_password: Option<String>,
201    pub channel: Option<u8>,
202}
203
204impl WifiConfig {
205    /// Validate values and emit the matching `set-wifi …` line.
206    pub fn to_cli_command(&self) -> Result<String, String> {
207        match self.mode.as_str() {
208            "station" | "sniffer" | "esp-now-central" | "esp-now-peripheral" => {}
209            other => {
210                return Err(format!(
211                    "Unknown wifi mode '{other}'; expected station, sniffer, esp-now-central, or esp-now-peripheral"
212                ));
213            }
214        }
215
216        let mut cmd = format!("set-wifi --mode={}", self.mode);
217
218        if let Some(ssid) = &self.sta_ssid {
219            if ssid.len() > 32 {
220                return Err(format!(
221                    "sta_ssid is {} bytes; firmware limit is 32 bytes",
222                    ssid.len()
223                ));
224            }
225            cmd.push_str(&format!(" --sta-ssid={}", quote_cli_arg(ssid)?));
226        }
227
228        if let Some(pass) = &self.sta_password {
229            if pass.len() > 32 {
230                return Err(format!(
231                    "sta_password is {} bytes; firmware limit is 32 bytes",
232                    pass.len()
233                ));
234            }
235            cmd.push_str(&format!(" --sta-password={}", quote_cli_arg(pass)?));
236        }
237
238        if let Some(ch) = self.channel {
239            cmd.push_str(&format!(" --set-channel={ch}"));
240        }
241
242        Ok(cmd)
243    }
244}
245
246#[derive(Debug, Deserialize)]
247pub struct TrafficConfig {
248    /// Traffic generation frequency in Hz; `0` disables generation.
249    pub frequency_hz: u64,
250}
251
252impl TrafficConfig {
253    pub fn to_cli_command(&self) -> String {
254        format!("set-traffic --frequency-hz={}", self.frequency_hz)
255    }
256}
257
258/// CSI feature flags. Classic (ESP32 / ESP32-C3 / ESP32-S3) and HE
259/// (ESP32-C5 / ESP32-C6) parameters are merged here; the firmware will
260/// silently ignore flags that are not part of its compiled-in variant.
261/// Only flags set to `true` are forwarded.
262#[derive(Debug, Deserialize)]
263pub struct CsiConfig {
264    // ── Classic (non-C5/C6) ────────────────────────────────────────────
265    pub disable_lltf: Option<bool>,
266    pub disable_htltf: Option<bool>,
267    pub disable_stbc_htltf: Option<bool>,
268    pub disable_ltf_merge: Option<bool>,
269    // ── HE (C5/C6) ─────────────────────────────────────────────────────
270    pub disable_csi: Option<bool>,
271    pub disable_csi_legacy: Option<bool>,
272    pub disable_csi_ht20: Option<bool>,
273    pub disable_csi_ht40: Option<bool>,
274    pub disable_csi_su: Option<bool>,
275    pub disable_csi_mu: Option<bool>,
276    pub disable_csi_dcm: Option<bool>,
277    pub disable_csi_beamformed: Option<bool>,
278    /// `0` HE-LTF1, `1` HE-LTF2, `2` even sample (default).
279    pub csi_he_stbc: Option<u32>,
280    /// `0..=3`; default `2`.
281    pub val_scale_cfg: Option<u32>,
282}
283
284impl CsiConfig {
285    pub fn to_cli_command(&self) -> String {
286        let mut cmd = "set-csi".to_string();
287        if self.disable_lltf.unwrap_or(false) {
288            cmd.push_str(" --disable-lltf");
289        }
290        if self.disable_htltf.unwrap_or(false) {
291            cmd.push_str(" --disable-htltf");
292        }
293        if self.disable_stbc_htltf.unwrap_or(false) {
294            cmd.push_str(" --disable-stbc-htltf");
295        }
296        if self.disable_ltf_merge.unwrap_or(false) {
297            cmd.push_str(" --disable-ltf-merge");
298        }
299        if self.disable_csi.unwrap_or(false) {
300            cmd.push_str(" --disable-csi");
301        }
302        if self.disable_csi_legacy.unwrap_or(false) {
303            cmd.push_str(" --disable-csi-legacy");
304        }
305        if self.disable_csi_ht20.unwrap_or(false) {
306            cmd.push_str(" --disable-csi-ht20");
307        }
308        if self.disable_csi_ht40.unwrap_or(false) {
309            cmd.push_str(" --disable-csi-ht40");
310        }
311        if self.disable_csi_su.unwrap_or(false) {
312            cmd.push_str(" --disable-csi-su");
313        }
314        if self.disable_csi_mu.unwrap_or(false) {
315            cmd.push_str(" --disable-csi-mu");
316        }
317        if self.disable_csi_dcm.unwrap_or(false) {
318            cmd.push_str(" --disable-csi-dcm");
319        }
320        if self.disable_csi_beamformed.unwrap_or(false) {
321            cmd.push_str(" --disable-csi-beamformed");
322        }
323        if let Some(stbc) = self.csi_he_stbc {
324            cmd.push_str(&format!(" --csi-he-stbc={stbc}"));
325        }
326        if let Some(scale) = self.val_scale_cfg {
327            cmd.push_str(&format!(" --val-scale-cfg={scale}"));
328        }
329        cmd
330    }
331}
332
333#[derive(Debug, Deserialize)]
334pub struct CollectionModeConfig {
335    /// `collector` or `listener`.
336    pub mode: String,
337}
338
339impl CollectionModeConfig {
340    pub fn to_cli_command(&self) -> Result<String, String> {
341        match self.mode.as_str() {
342            "collector" | "listener" => {
343                Ok(format!("set-collection-mode --mode={}", self.mode))
344            }
345            other => Err(format!(
346                "Unknown collection mode '{other}'; expected collector or listener"
347            )),
348        }
349    }
350}
351
352#[derive(Debug, Deserialize)]
353pub struct LogModeConfig {
354    pub mode: LogMode,
355}
356
357impl LogModeConfig {
358    pub fn to_cli_command(&self) -> String {
359        format!("set-log-mode --mode={}", self.mode.as_cli_value())
360    }
361}
362
363/// Supported CSI log formats exposed by `esp-csi-cli-rs set-log-mode`.
364#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
365#[serde(rename_all = "kebab-case")]
366pub enum LogMode {
367    /// Verbose human-readable output with metadata.
368    Text,
369    /// Compact one-line text output per packet.
370    #[default]
371    ArrayList,
372    /// Binary COBS-framed postcard output.
373    Serialized,
374    /// Hernandez-style 26-column CSV (compatible with the ESP32-CSI-Tool collector).
375    EspCsiTool,
376}
377
378impl LogMode {
379    pub fn as_cli_value(&self) -> &'static str {
380        match self {
381            Self::Text => "text",
382            Self::ArrayList => "array-list",
383            Self::Serialized => "serialized",
384            Self::EspCsiTool => "esp-csi-tool",
385        }
386    }
387}
388
389#[derive(Debug, Deserialize)]
390pub struct StartConfig {
391    /// Collection duration in seconds; omit for indefinite collection.
392    pub duration: Option<u64>,
393}
394
395impl StartConfig {
396    pub fn to_cli_command(&self) -> String {
397        match self.duration {
398            Some(d) => format!("start --duration={d}"),
399            None => "start".to_string(),
400        }
401    }
402}
403
404/// `POST /api/config/rate` — pin the Wi-Fi PHY rate (only honored in ESP-NOW
405/// modes).
406#[derive(Debug, Deserialize)]
407pub struct RateConfig {
408    /// e.g. `1m`, `2m`, `5m5`, `11m`, `6m`..`54m`, `mcs0-lgi`..`mcs7-lgi`,
409    /// `mcs0-sgi`.
410    pub rate: String,
411}
412
413impl RateConfig {
414    pub fn to_cli_command(&self) -> String {
415        format!("set-rate --rate={}", self.rate)
416    }
417}
418
419/// `POST /api/config/io-tasks` — toggle the per-direction TX/RX Embassy tasks.
420/// Both fields are independently optional; omitted fields keep their current
421/// device-side value.
422#[derive(Debug, Deserialize)]
423pub struct IoTasksConfig {
424    pub tx: Option<bool>,
425    pub rx: Option<bool>,
426}
427
428impl IoTasksConfig {
429    pub fn to_cli_command(&self) -> Result<String, String> {
430        if self.tx.is_none() && self.rx.is_none() {
431            return Err("at least one of tx or rx must be provided".to_string());
432        }
433        let mut cmd = "set-io-tasks".to_string();
434        if let Some(tx) = self.tx {
435            cmd.push_str(&format!(" --tx={}", if tx { "on" } else { "off" }));
436        }
437        if let Some(rx) = self.rx {
438            cmd.push_str(&format!(" --rx={}", if rx { "on" } else { "off" }));
439        }
440        Ok(cmd)
441    }
442}
443
444/// `POST /api/config/csi-delivery` — switch the CSI delivery path and
445/// inline log gate. Both fields are independent; either or both may be set.
446#[derive(Debug, Deserialize)]
447pub struct CsiDeliveryConfig {
448    /// `off` | `callback` | `async`.
449    pub mode: Option<String>,
450    /// Toggle for the per-packet UART/JTAG inline log path.
451    pub logging: Option<bool>,
452}
453
454impl CsiDeliveryConfig {
455    pub fn to_cli_command(&self) -> Result<String, String> {
456        if self.mode.is_none() && self.logging.is_none() {
457            return Err("at least one of mode or logging must be provided".to_string());
458        }
459        let mut cmd = "set-csi-delivery".to_string();
460        if let Some(mode) = &self.mode {
461            match mode.as_str() {
462                "off" | "callback" | "async" => {}
463                other => {
464                    return Err(format!(
465                        "Unknown csi-delivery mode '{other}'; expected off, callback, or async"
466                    ));
467                }
468            }
469            cmd.push_str(&format!(" --mode={mode}"));
470        }
471        if let Some(logging) = self.logging {
472            cmd.push_str(&format!(
473                " --logging={}",
474                if logging { "on" } else { "off" }
475            ));
476        }
477        Ok(cmd)
478    }
479}
480
481// ─── Output mode ──────────────────────────────────────────────────────────
482
483/// Controls where CSI frames are sent after being read from the serial port.
484#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
485#[serde(rename_all = "lowercase")]
486pub enum OutputMode {
487    /// Stream frames to WebSocket clients only (default).
488    #[default]
489    Stream,
490    /// Write frames to a session dump file only; /api/ws returns 403.
491    Dump,
492    /// Both stream to WebSocket clients and write to the dump file.
493    Both,
494}
495
496#[derive(Debug, Deserialize)]
497pub struct OutputModeConfig {
498    pub mode: String,
499}
500
501// ─── API response ──────────────────────────────────────────────────────────
502
503#[derive(Debug, Serialize)]
504pub struct ApiResponse {
505    pub success: bool,
506    pub message: String,
507}
508
509// ─── Device identification ─────────────────────────────────────────────────
510
511/// Parsed result of the `info` command on `esp-csi-cli-rs`.
512///
513/// The magic prefix `ESP-CSI-CLI/<version>` is what proves the firmware is
514/// `esp-csi-cli-rs`; if the prefix line never arrives, the device is either
515/// running unrelated firmware, an older `esp-csi-cli-rs` build that predates
516/// the `info` command, or no firmware at all.
517#[derive(Debug, Clone, Serialize)]
518pub struct DeviceInfo {
519    /// The version string from the `ESP-CSI-CLI/<version>` magic line.
520    pub banner_version: String,
521    /// `name=` line, expected to be `esp-csi-cli-rs`.
522    pub name: Option<String>,
523    /// `version=` line; should match `banner_version`.
524    pub version: Option<String>,
525    /// `chip=` line: `esp32` | `esp32c3` | `esp32c5` | `esp32c6` | `esp32s3` | `unknown`.
526    pub chip: Option<String>,
527    /// `protocol=` line — a wire-format version number bumped on
528    /// incompatible grammar changes. Host tooling should refuse unknown
529    /// protocol values.
530    pub protocol: Option<u32>,
531    /// `features=` list (compile-time enabled Cargo features).
532    pub features: Vec<String>,
533}
534
535// ─── Runtime status ───────────────────────────────────────────────────────
536
537#[derive(Debug, Serialize)]
538pub struct CollectionStatusResponse {
539    pub serial_connected: bool,
540    pub collection_running: bool,
541    pub port_path: String,
542}
543
544impl CollectionStatusResponse {
545    pub fn from_state(
546        serial_connected: &AtomicBool,
547        collection_running: &AtomicBool,
548        port_path: String,
549    ) -> Self {
550        Self {
551            serial_connected: serial_connected.load(Ordering::SeqCst),
552            collection_running: collection_running.load(Ordering::SeqCst),
553            port_path,
554        }
555    }
556}