Skip to main content

csi_webserver_core/
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::collections::BTreeMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use crate::profile::CsiProfile;
13
14// ─── Device config (cached state) ─────────────────────────────────────────
15
16/// Server-side cached view of device-side `UserConfig`, structured to mirror
17/// the firmware's `show-config` output (sections `[WiFi]`, `[Collection]`,
18/// `[CSI Config]`). Fields are best-effort: each is populated when the
19/// matching `POST /api/config/*` endpoint succeeds, and reset to firmware
20/// defaults by `POST /api/config/reset`. Values can drift if the device is
21/// re-flashed or commands are sent out-of-band.
22///
23/// `sta_password` is intentionally *not* cached even though `show-config`
24/// echoes it — round-tripping plaintext passwords through a GET endpoint
25/// would defeat the point of having one.
26///
27/// The trailing fields (`csi_delivery_mode`, `csi_logging_enabled`) live
28/// alongside the show-config sections because they're set via separate CLI
29/// commands (`set-csi-delivery`) and are useful to surface here even though
30/// they aren't part of the `show-config` block.
31///
32/// The log mode is fixed to `serialized` (the only format this server
33/// consumes), so it is no longer a configurable field.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct DeviceConfig {
36    pub wifi: WifiSection,
37    pub collection: CollectionSection,
38    pub csi_config: CsiConfigSection,
39    pub csi_delivery_mode: Option<String>,
40    pub csi_logging_enabled: Option<bool>,
41}
42
43/// `[WiFi]` section in `show-config`.
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct WifiSection {
46    /// `node_mode` — `sniffer` | `station` | `wifi-ap` | `esp-now-central` |
47    /// `esp-now-peripheral` | `esp-now-fast-collector` | `esp-now-fast-source`.
48    pub mode: Option<String>,
49    /// `channel` — `u8`. Valid Wi-Fi 2.4 GHz: 1..=14.
50    pub channel: Option<u8>,
51    /// `sta_ssid` — UTF-8, ≤ 32 B.
52    pub sta_ssid: Option<String>,
53    /// `ap_ssid` — softAP SSID for `wifi-ap` mode. Default `esp-csi-ap`.
54    pub ap_ssid: Option<String>,
55    /// `ap_password` — intentionally not cached (same policy as `sta_password`).
56    pub ap_password: Option<String>,
57    /// `serve_dhcp` — built-in DHCP server in `wifi-ap` mode.
58    pub ap_dhcp: Option<bool>,
59    /// `ap_lease_count` — DHCP lease pool size in `wifi-ap` mode (1–8).
60    /// Reported as `AP Leases` in `show-config`.
61    pub ap_leases: Option<u8>,
62    /// `ap_sync_burst` — synchronized burst flood in `wifi-ap` mode.
63    /// Reported as `AP Burst` in `show-config`.
64    pub ap_burst: Option<bool>,
65    /// `peer_mac` — explicit ESP-NOW source-MAC filter, or `auto` for the
66    /// default magic-prefix pairing. ESP-NOW modes only.
67    pub peer_mac: Option<String>,
68    /// `ht40_secondary` — forced ESP-NOW TX secondary channel:
69    /// `above` | `below` | `none` (HT20/legacy). ESP-NOW modes only.
70    pub ht40: Option<String>,
71}
72
73/// `[Collection]` section in `show-config`.
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct CollectionSection {
76    /// `collection_mode` — `collector` | `listener`.
77    pub mode: Option<String>,
78    /// `trigger_freq` Hz. `0` disables traffic generation.
79    pub traffic_hz: Option<u64>,
80    /// `flood_unsolicited` — ICMP flood sends unsolicited echo replies
81    /// (one-directional traffic) instead of echo requests.
82    pub unsolicited: Option<bool>,
83    /// `phy_rate` enum — e.g. `mcs0-lgi`. Honored by all modes except `station`.
84    pub phy_rate: Option<String>,
85    /// `protocol` — Wi-Fi PHY protocol applied at the start of each run.
86    /// One of `b` | `g` | `n` | `lr` | `a` | `ac`; defaults to `lr`.
87    pub protocol: Option<String>,
88    /// `io_tasks.tx_enabled`.
89    pub io_tx_enabled: Option<bool>,
90    /// `io_tasks.rx_enabled`.
91    pub io_rx_enabled: Option<bool>,
92}
93
94/// `[CSI Config]` section in `show-config`. The classic (ESP32 / C3 / S3) and
95/// extended (ESP32-C5 / C6) fields are merged into a single struct so the JSON
96/// shape is stable across chip variants. The fields applicable to the active
97/// firmware are populated; the others remain `None`.
98///
99/// The classic block also includes three read-only fields
100/// (`channel_filter_enabled`, `manual_scale`, `shift`) that have no `set-csi`
101/// flag; they are populated by `POST /api/config/reset` from firmware defaults
102/// but otherwise stay fixed.
103///
104/// Any acquisition flag the core does not name (supplied by an embedder's
105/// [`CsiProfile`](crate::profile::CsiProfile) or a profile-aware client) is
106/// carried verbatim in [`extra`](Self::extra) and re-emitted generically.
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct CsiConfigSection {
109    // ── Classic (ESP32 / C3 / S3) ─────────────────────────────────────
110    /// `lltf_en`.
111    pub lltf_enabled: Option<bool>,
112    /// `htltf_en`.
113    pub htltf_enabled: Option<bool>,
114    /// `stbc_htltf2_en`.
115    pub stbc_htltf_enabled: Option<bool>,
116    /// `ltf_merge_en`.
117    pub ltf_merge_enabled: Option<bool>,
118    /// `channel_filter_en` — **read-only**; only restored by `reset-config`.
119    pub channel_filter_enabled: Option<bool>,
120    /// `manu_scale` — **read-only**; only restored by `reset-config`.
121    pub manual_scale: Option<bool>,
122    /// `shift` — **read-only**; only restored by `reset-config`.
123    pub shift: Option<u8>,
124    /// `dump_ack_en` — configurable on C5/C6 chips via `set-csi --dump-ack=`.
125    pub dump_ack_enabled: Option<bool>,
126
127    // ── Extended (ESP32-C5 / C6) ──────────────────────────────────────
128    /// `enable` (acquire CSI overall).
129    pub acquire_csi: Option<u32>,
130    /// `acquire_csi_legacy` — L-LTF / 11g.
131    pub acquire_csi_legacy: Option<u32>,
132    /// `acquire_csi_ht20`.
133    pub acquire_csi_ht20: Option<u32>,
134    /// `acquire_csi_ht40`.
135    pub acquire_csi_ht40: Option<u32>,
136    /// `val_scale_cfg`.
137    pub val_scale_cfg: Option<u32>,
138    /// Force L-LTF acquisition (ESP32-C5 only).
139    pub acquire_csi_force_lltf: Option<bool>,
140    /// VHT-LTF for VHT20 PPDUs (ESP32-C5 only).
141    pub acquire_csi_vht: Option<bool>,
142
143    /// Acquisition flags the core does not name. Populated from
144    /// [`CsiConfig::apply_to_cache`] passthrough or a profile's
145    /// [`resolve_preset`](crate::profile::CsiProfile::resolve_preset); each
146    /// entry re-emits as `--{key}={value}` in the `set-csi` command.
147    #[serde(flatten)]
148    pub extra: BTreeMap<String, serde_json::Value>,
149}
150
151impl DeviceConfig {
152    /// Snapshot of `UserConfig::new()` / `CsiConfig::default()` on the
153    /// device, as documented in the `show-config` spec. Populated into
154    /// the cache by `POST /api/config/reset` so the response after a
155    /// reset reflects what the firmware actually holds, even before the
156    /// user re-sends any `set-*` commands.
157    ///
158    /// Both the classic and the extended (C5/C6) CSI defaults are populated —
159    /// the caller can ignore the fields irrelevant to the connected chip
160    /// (consult `GET /api/info` for the `chip` field).
161    pub fn firmware_defaults() -> Self {
162        Self::firmware_defaults_for_chip(None)
163    }
164
165    /// Like [`firmware_defaults`], with chip-specific Wi-Fi channel (C5 → 149,
166    /// C6 → 6, others → 1) matching the esp-csi-cli-rs lab examples.
167    pub fn firmware_defaults_for_chip(chip: Option<&str>) -> Self {
168        let defaults = Self {
169            wifi: WifiSection {
170                mode: Some("sniffer".to_string()),
171                channel: Some(default_wifi_channel(chip)),
172                sta_ssid: Some(String::new()),
173                ap_ssid: Some("esp-csi-ap".to_string()),
174                ap_password: None,
175                ap_dhcp: Some(true),
176                ap_leases: Some(4),
177                ap_burst: Some(false),
178                peer_mac: Some("auto".to_string()),
179                ht40: Some("none".to_string()),
180            },
181            collection: CollectionSection {
182                mode: Some("collector".to_string()),
183                traffic_hz: Some(100),
184                unsolicited: Some(false),
185                phy_rate: Some("mcs0-lgi".to_string()),
186                protocol: Some("lr".to_string()),
187                io_tx_enabled: Some(true),
188                io_rx_enabled: Some(true),
189            },
190            csi_config: CsiConfigSection {
191                // Classic
192                lltf_enabled: Some(true),
193                htltf_enabled: Some(true),
194                stbc_htltf_enabled: Some(true),
195                ltf_merge_enabled: Some(true),
196                channel_filter_enabled: Some(false),
197                manual_scale: Some(false),
198                shift: Some(0),
199                dump_ack_enabled: Some(true),
200                // Extended (C5/C6 defaults — classic chips ignore these fields)
201                acquire_csi: Some(1),
202                acquire_csi_legacy: Some(1),
203                acquire_csi_ht20: Some(1),
204                acquire_csi_ht40: Some(1),
205                val_scale_cfg: Some(2),
206                acquire_csi_force_lltf: Some(true),
207                acquire_csi_vht: Some(true),
208                extra: BTreeMap::new(),
209            },
210            csi_delivery_mode: None,
211            csi_logging_enabled: None,
212        };
213        defaults
214    }
215}
216
217/// Default Wi-Fi channel for a chip, matching the esp-csi-cli-rs lab examples.
218pub fn default_wifi_channel(chip: Option<&str>) -> u8 {
219    match chip.map(|c| c.trim().to_ascii_lowercase()) {
220        Some(ref c) if c == "esp32c5" => 149,
221        Some(ref c) if c == "esp32c6" => 6,
222        _ => 1,
223    }
224}
225
226// ─── Quoting helpers ──────────────────────────────────────────────────────
227
228/// Quote a free-form string argument for `esp-csi-cli-rs`.
229///
230/// The CLI accepts both `'…'` and `"…"`; the opening quote style is
231/// matched by the same style and the other quote is treated literally.
232/// Spaces inside quotes are forwarded as `0x1F` and decoded back to `' '`
233/// in the device-side handler. Underscores are passed through literally
234/// (no shorthand substitution).
235fn quote_cli_arg(s: &str) -> Result<String, String> {
236    if s.contains('\n') || s.contains('\r') {
237        return Err("value cannot contain newline characters".to_string());
238    }
239    if !s.contains('\'') {
240        Ok(format!("'{s}'"))
241    } else if !s.contains('"') {
242        Ok(format!("\"{s}\""))
243    } else {
244        Err("value cannot contain both single and double quote characters".to_string())
245    }
246}
247
248// ─── HTTP request bodies ───────────────────────────────────────────────────
249
250/// Validate an ESP-NOW peer MAC the way the firmware does: six hex octets
251/// separated by `:` or `-` (case-insensitive). An empty string is *valid* and
252/// means "clear back to auto".
253fn validate_peer_mac(mac: &str) -> Result<(), String> {
254    if mac.is_empty() {
255        return Ok(());
256    }
257    let sep = if mac.contains(':') {
258        ':'
259    } else if mac.contains('-') {
260        '-'
261    } else {
262        return Err("peer_mac must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_string());
263    };
264    let octets: Vec<&str> = mac.split(sep).collect();
265    if octets.len() != 6 || octets.iter().any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit())) {
266        return Err(format!("Invalid peer_mac '{mac}' (use aa:bb:cc:dd:ee:ff)"));
267    }
268    Ok(())
269}
270
271/// Accepted `set-wifi --mode=` values (esp-csi-cli-rs v0.7.0).
272const WIFI_MODES: &[&str] = &[
273    "station",
274    "sniffer",
275    "wifi-ap",
276    "esp-now-central",
277    "esp-now-peripheral",
278    "esp-now-fast-collector",
279    "esp-now-fast-source",
280];
281
282#[derive(Debug, Deserialize)]
283pub struct WifiConfig {
284    /// One of the values in [`WIFI_MODES`].
285    pub mode: String,
286    pub sta_ssid: Option<String>,
287    pub sta_password: Option<String>,
288    /// SoftAP SSID for `wifi-ap` mode.
289    pub ap_ssid: Option<String>,
290    /// SoftAP password for `wifi-ap` mode; empty = open network.
291    pub ap_password: Option<String>,
292    /// Enable built-in DHCP in `wifi-ap` mode.
293    pub ap_dhcp: Option<bool>,
294    /// DHCP lease pool size in `wifi-ap` mode (1–8). With more than one
295    /// lease the ICMP flood targets every associated station.
296    pub ap_leases: Option<u8>,
297    /// Synchronized burst flood in `wifi-ap` mode: every flood tick sends one
298    /// unicast frame back-to-back to every active lease (time-aligned
299    /// multi-receiver CSI) instead of round-robining one station per tick.
300    pub ap_burst: Option<bool>,
301    pub channel: Option<u8>,
302    /// ESP-NOW peer source MAC (`aa:bb:cc:dd:ee:ff` or `aa-bb-...`). An empty
303    /// string clears the filter back to automatic magic-prefix pairing.
304    /// ESP-NOW modes only; ignored by the firmware in other modes.
305    pub peer_mac: Option<String>,
306    /// Forced ESP-NOW TX HT40 secondary channel: `above` | `below` |
307    /// `none` | `off`. ESP-NOW modes only.
308    pub ht40: Option<String>,
309}
310
311impl WifiConfig {
312    /// Validate values and emit the matching `set-wifi …` line.
313    ///
314    /// `chip` is used to pick the firmware default channel when `channel` is
315    /// omitted for non-`station` modes (C5 → 149, C6 → 6).
316    pub fn to_cli_command(&self, chip: Option<&str>) -> Result<String, String> {
317        if !WIFI_MODES.contains(&self.mode.as_str()) {
318            return Err(format!(
319                "Unknown wifi mode '{}'; expected one of: {}",
320                self.mode,
321                WIFI_MODES.join(", ")
322            ));
323        }
324
325        let mut cmd = format!("set-wifi --mode={}", self.mode);
326
327        if let Some(ssid) = &self.sta_ssid {
328            if ssid.len() > 32 {
329                return Err(format!(
330                    "sta_ssid is {} bytes; firmware limit is 32 bytes",
331                    ssid.len()
332                ));
333            }
334            cmd.push_str(&format!(" --sta-ssid={}", quote_cli_arg(ssid)?));
335        }
336
337        if let Some(pass) = &self.sta_password {
338            if pass.len() > 32 {
339                return Err(format!(
340                    "sta_password is {} bytes; firmware limit is 32 bytes",
341                    pass.len()
342                ));
343            }
344            cmd.push_str(&format!(" --sta-password={}", quote_cli_arg(pass)?));
345        }
346
347        if let Some(ssid) = &self.ap_ssid {
348            if ssid.len() > 32 {
349                return Err(format!(
350                    "ap_ssid is {} bytes; firmware limit is 32 bytes",
351                    ssid.len()
352                ));
353            }
354            cmd.push_str(&format!(" --ap-ssid={}", quote_cli_arg(ssid)?));
355        }
356
357        if let Some(pass) = &self.ap_password {
358            if pass.len() > 32 {
359                return Err(format!(
360                    "ap_password is {} bytes; firmware limit is 32 bytes",
361                    pass.len()
362                ));
363            }
364            cmd.push_str(&format!(" --ap-password={}", quote_cli_arg(pass)?));
365        }
366
367        if let Some(dhcp) = self.ap_dhcp {
368            cmd.push_str(if dhcp {
369                " --ap-dhcp=on"
370            } else {
371                " --ap-dhcp=off"
372            });
373        }
374
375        if let Some(leases) = self.ap_leases {
376            if !(1..=8).contains(&leases) {
377                return Err(format!("ap_leases is {leases}; firmware accepts 1-8"));
378            }
379            cmd.push_str(&format!(" --ap-leases={leases}"));
380        }
381
382        if let Some(burst) = self.ap_burst {
383            cmd.push_str(if burst {
384                " --ap-burst=on"
385            } else {
386                " --ap-burst=off"
387            });
388        }
389
390        // Channel handling by mode:
391        // - Non-station modes: send the operating channel, defaulting to the
392        //   chip's channel when the client omits it.
393        // - Station mode: the channel is an optional pre-association
394        //   band-selection hint (`WifiStationConfig::channel_hint`, meaningful
395        //   on the ESP32-C5's 5 GHz band). Forward it only when explicitly
396        //   provided; when omitted the firmware derives the channel from the
397        //   associated AP, so we send nothing.
398        if let Some(ch) = self
399            .channel
400            .or_else(|| (self.mode != "station").then(|| default_wifi_channel(chip)))
401        {
402            cmd.push_str(&format!(" --set-channel={ch}"));
403        }
404
405        if let Some(mac) = &self.peer_mac {
406            validate_peer_mac(mac)?;
407            // An empty value is forwarded verbatim (`--peer-mac=`) so the
408            // firmware clears the filter back to auto.
409            cmd.push_str(&format!(" --peer-mac={mac}"));
410        }
411
412        if let Some(ht40) = &self.ht40 {
413            match ht40.as_str() {
414                "above" | "below" | "none" | "off" => {}
415                other => {
416                    return Err(format!("Invalid ht40 '{other}' (use above, below, none, or off)"));
417                }
418            }
419            cmd.push_str(&format!(" --ht40={ht40}"));
420        }
421
422        Ok(cmd)
423    }
424}
425
426#[derive(Debug, Deserialize)]
427pub struct TrafficConfig {
428    /// Traffic generation frequency in Hz; `0` disables generation.
429    pub frequency_hz: u64,
430    /// `--unsolicited=on|off` (default off). When on, the ICMP flood sends
431    /// unsolicited echo *replies* instead of echo requests: the peer silently
432    /// ignores them at the IP level, so traffic is strictly one-directional —
433    /// stable offered rate, but the flooding node captures no CSI back from
434    /// replies. Omitted → flag not forwarded, firmware keeps its default.
435    pub unsolicited: Option<bool>,
436}
437
438impl TrafficConfig {
439    pub fn to_cli_command(&self) -> String {
440        let mut cmd = format!("set-traffic --frequency-hz={}", self.frequency_hz);
441        push_on_off(&mut cmd, "unsolicited", self.unsolicited);
442        cmd
443    }
444}
445
446/// CSI feature flags. Classic (ESP32 / ESP32-C3 / ESP32-S3) and extended
447/// (ESP32-C5 / ESP32-C6) parameters are merged here; the firmware will
448/// silently ignore flags that are not part of its compiled-in variant.
449///
450/// Flags the core does not name are accepted via the flattened [`extra`](Self::extra)
451/// map and re-emitted generically as `--{key}={value}`, so an embedder's
452/// [`CsiProfile`](crate::profile::CsiProfile) or a profile-aware client can drive
453/// acquisition options this build has no dedicated field for.
454#[derive(Debug, Deserialize)]
455pub struct CsiConfig {
456    // ── Classic (non-C5/C6) ────────────────────────────────────────────
457    /// `--lltf=on|off`
458    pub lltf: Option<bool>,
459    /// `--htltf=on|off`
460    pub htltf: Option<bool>,
461    /// `--stbc-htltf=on|off`
462    pub stbc_htltf: Option<bool>,
463    /// `--ltf-merge=on|off`
464    pub ltf_merge: Option<bool>,
465    // ── Extended (C5/C6) ───────────────────────────────────────────────
466    /// `--csi=on|off` — master acquisition switch.
467    pub csi: Option<bool>,
468    /// `--csi-legacy=on|off`
469    pub csi_legacy: Option<bool>,
470    /// `--csi-ht20=on|off`
471    pub csi_ht20: Option<bool>,
472    /// `--csi-ht40=on|off`
473    pub csi_ht40: Option<bool>,
474    /// `--dump-ack=on|off` (C5/C6 chips).
475    pub dump_ack: Option<bool>,
476    /// `--csi-force-lltf=on|off` (ESP32-C5 only).
477    pub csi_force_lltf: Option<bool>,
478    /// `--csi-vht=on|off` (ESP32-C5 only).
479    pub csi_vht: Option<bool>,
480    /// `0..=3`; default `2`.
481    pub val_scale_cfg: Option<u32>,
482    /// Flags the core does not name (including a `preset` key resolved via the
483    /// active [`CsiProfile`](crate::profile::CsiProfile)). Each entry other
484    /// than `preset` re-emits as `--{key}={value}`.
485    #[serde(flatten)]
486    pub extra: BTreeMap<String, serde_json::Value>,
487}
488
489impl CsiConfig {
490    pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
491        let mut cmd = "set-csi".to_string();
492
493        // A `preset` arrives via the flattened `extra` map. The core knows only
494        // `default`; anything else is resolved through the active profile.
495        if let Some(preset) = self.extra.get("preset") {
496            let name = preset
497                .as_str()
498                .ok_or_else(|| "preset must be a string".to_string())?;
499            if name == "default" {
500                cmd.push_str(" --preset=default");
501                return Ok(cmd);
502            }
503            if let Some(cli) = profile.preset_cli(name) {
504                return Ok(cli);
505            }
506            return Err(format!("unknown preset '{name}'; expected default"));
507        }
508
509        push_on_off(&mut cmd, "lltf", self.lltf);
510        push_on_off(&mut cmd, "htltf", self.htltf);
511        push_on_off(&mut cmd, "stbc-htltf", self.stbc_htltf);
512        push_on_off(&mut cmd, "ltf-merge", self.ltf_merge);
513        push_on_off(&mut cmd, "csi", self.csi);
514        push_on_off(&mut cmd, "csi-legacy", self.csi_legacy);
515        push_on_off(&mut cmd, "csi-ht20", self.csi_ht20);
516        push_on_off(&mut cmd, "csi-ht40", self.csi_ht40);
517        push_on_off(&mut cmd, "dump-ack", self.dump_ack);
518        push_on_off(&mut cmd, "csi-force-lltf", self.csi_force_lltf);
519        push_on_off(&mut cmd, "csi-vht", self.csi_vht);
520
521        if let Some(scale) = self.val_scale_cfg {
522            cmd.push_str(&format!(" --val-scale-cfg={scale}"));
523        }
524
525        // Generic passthrough for any flag the core does not name.
526        for (key, value) in &self.extra {
527            push_extra(&mut cmd, key, value);
528        }
529        Ok(cmd)
530    }
531
532    /// Apply this request body to the server-side config cache.
533    pub fn apply_to_cache(&self, cfg: &mut CsiConfigSection, profile: &dyn CsiProfile) {
534        if let Some(preset) = self.extra.get("preset") {
535            if let Some(name) = preset.as_str() {
536                *cfg = profile
537                    .resolve_preset(name)
538                    .unwrap_or_else(|| DeviceConfig::firmware_defaults().csi_config);
539                return;
540            }
541        }
542        apply_bool_cache(&mut cfg.lltf_enabled, self.lltf);
543        apply_bool_cache(&mut cfg.htltf_enabled, self.htltf);
544        apply_bool_cache(&mut cfg.stbc_htltf_enabled, self.stbc_htltf);
545        apply_bool_cache(&mut cfg.ltf_merge_enabled, self.ltf_merge);
546        apply_u32_cache(&mut cfg.acquire_csi, self.csi);
547        apply_u32_cache(&mut cfg.acquire_csi_legacy, self.csi_legacy);
548        apply_u32_cache(&mut cfg.acquire_csi_ht20, self.csi_ht20);
549        apply_u32_cache(&mut cfg.acquire_csi_ht40, self.csi_ht40);
550        apply_bool_cache(&mut cfg.dump_ack_enabled, self.dump_ack);
551        apply_bool_cache(&mut cfg.acquire_csi_force_lltf, self.csi_force_lltf);
552        apply_bool_cache(&mut cfg.acquire_csi_vht, self.csi_vht);
553        if let Some(scale) = self.val_scale_cfg {
554            cfg.val_scale_cfg = Some(scale);
555        }
556        // Carry unknown flags into the cache verbatim so `GET /api/config`
557        // round-trips whatever a profile-aware client set.
558        for (key, value) in &self.extra {
559            if key == "preset" {
560                continue;
561            }
562            cfg.extra.insert(key.clone(), value.clone());
563        }
564    }
565}
566
567fn push_on_off(cmd: &mut String, flag: &str, value: Option<bool>) {
568    if let Some(v) = value {
569        cmd.push_str(&format!(" --{flag}={}", if v { "on" } else { "off" }));
570    }
571}
572
573/// Render one unnamed acquisition flag as `--{key}={value}`, reusing the same
574/// on/off + numeric conventions the named flags use.
575fn push_extra(cmd: &mut String, key: &str, value: &serde_json::Value) {
576    use serde_json::Value;
577    let rendered = match value {
578        Value::Bool(b) => (if *b { "on" } else { "off" }).to_string(),
579        Value::Number(n) => n.to_string(),
580        Value::String(s) => s.clone(),
581        other => other.to_string(),
582    };
583    cmd.push_str(&format!(" --{key}={rendered}"));
584}
585
586fn apply_bool_cache(slot: &mut Option<bool>, value: Option<bool>) {
587    if let Some(v) = value {
588        *slot = Some(v);
589    }
590}
591
592fn apply_u32_cache(slot: &mut Option<u32>, value: Option<bool>) {
593    if let Some(v) = value {
594        *slot = Some(u32::from(v));
595    }
596}
597
598#[derive(Debug, Deserialize)]
599pub struct CollectionModeConfig {
600    /// `collector` or `listener`.
601    pub mode: String,
602}
603
604impl CollectionModeConfig {
605    pub fn to_cli_command(&self) -> Result<String, String> {
606        match self.mode.as_str() {
607            "collector" | "listener" => {
608                Ok(format!("set-collection-mode --mode={}", self.mode))
609            }
610            other => Err(format!(
611                "Unknown collection mode '{other}'; expected collector or listener"
612            )),
613        }
614    }
615}
616
617#[derive(Debug, Deserialize)]
618pub struct StartConfig {
619    /// Collection duration in seconds; omit for indefinite collection.
620    pub duration: Option<u64>,
621}
622
623impl StartConfig {
624    pub fn to_cli_command(&self) -> String {
625        match self.duration {
626            Some(d) => format!("start --duration={d}"),
627            None => "start".to_string(),
628        }
629    }
630}
631
632/// `POST /api/config/rate` — pin the Wi-Fi PHY rate (honored by all modes
633/// except `station` on the firmware side).
634#[derive(Debug, Deserialize)]
635pub struct RateConfig {
636    /// e.g. `1m`, `2m`, `5m5`, `11m`, `6m`..`54m`, `mcs0-lgi`..`mcs7-lgi`,
637    /// `mcs0-sgi`.
638    pub rate: String,
639}
640
641impl RateConfig {
642    pub fn to_cli_command(&self) -> String {
643        format!("set-rate --rate={}", self.rate)
644    }
645}
646
647/// `POST /api/config/protocol` — set the Wi-Fi PHY protocol applied to the node
648/// at the start of each collection run.
649#[derive(Debug, Deserialize)]
650pub struct ProtocolConfig {
651    /// One of `b` | `g` | `n` | `lr` | `a` | `ac`, plus any value the active
652    /// [`CsiProfile`](crate::profile::CsiProfile) advertises.
653    pub protocol: String,
654}
655
656impl ProtocolConfig {
657    /// Accepted protocol values, matching the firmware's `set-protocol` parser.
658    const VALID: &[&str] = &["b", "g", "n", "lr", "a", "ac"];
659
660    /// Build the `set-protocol` command, rejecting unknown values up front.
661    /// Accepts [`Self::VALID`] plus the profile's
662    /// [`extra_protocols`](crate::profile::CsiProfile::extra_protocols). This is
663    /// string-level validation only — the radio may still reject a protocol the
664    /// specific chip/band doesn't support, which surfaces at `start`, not here.
665    pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
666        let protocol = self.protocol.to_ascii_lowercase();
667        let accepted = profile.extra_protocols();
668        if !Self::VALID.contains(&protocol.as_str()) && !accepted.contains(&protocol.as_str()) {
669            let mut valid: Vec<&str> = Self::VALID.to_vec();
670            valid.extend_from_slice(accepted);
671            return Err(format!(
672                "unknown protocol '{}'; expected one of: {}",
673                self.protocol,
674                valid.join(", "),
675            ));
676        }
677        Ok(format!("set-protocol --protocol={protocol}"))
678    }
679}
680
681/// `POST /api/config/io-tasks` — toggle the per-direction TX/RX Embassy tasks.
682/// Both fields are independently optional; omitted fields keep their current
683/// device-side value.
684#[derive(Debug, Deserialize)]
685pub struct IoTasksConfig {
686    pub tx: Option<bool>,
687    pub rx: Option<bool>,
688}
689
690impl IoTasksConfig {
691    pub fn to_cli_command(&self) -> Result<String, String> {
692        if self.tx.is_none() && self.rx.is_none() {
693            return Err("at least one of tx or rx must be provided".to_string());
694        }
695        let mut cmd = "set-io-tasks".to_string();
696        if let Some(tx) = self.tx {
697            cmd.push_str(&format!(" --tx={}", if tx { "on" } else { "off" }));
698        }
699        if let Some(rx) = self.rx {
700            cmd.push_str(&format!(" --rx={}", if rx { "on" } else { "off" }));
701        }
702        Ok(cmd)
703    }
704}
705
706/// `POST /api/config/csi-delivery` — switch the CSI delivery path and
707/// inline log gate. Both fields are independent; either or both may be set.
708#[derive(Debug, Deserialize)]
709pub struct CsiDeliveryConfig {
710    /// `off` | `callback` | `async` | `raw`.
711    ///
712    /// `raw` enables the zero-copy fast-path; unlike the other modes it is
713    /// stored as a flag on the device and only takes effect on the next
714    /// `start` (no CSI data is delivered or logged in that mode).
715    pub mode: Option<String>,
716    /// Toggle for the per-packet UART/JTAG inline log path.
717    pub logging: Option<bool>,
718}
719
720impl CsiDeliveryConfig {
721    pub fn to_cli_command(&self) -> Result<String, String> {
722        if self.mode.is_none() && self.logging.is_none() {
723            return Err("at least one of mode or logging must be provided".to_string());
724        }
725        let mut cmd = "set-csi-delivery".to_string();
726        if let Some(mode) = &self.mode {
727            match mode.as_str() {
728                "off" | "callback" | "async" | "raw" => {}
729                other => {
730                    return Err(format!(
731                        "Unknown csi-delivery mode '{other}'; expected off, callback, async, or raw"
732                    ));
733                }
734            }
735            cmd.push_str(&format!(" --mode={mode}"));
736        }
737        if let Some(logging) = self.logging {
738            cmd.push_str(&format!(
739                " --logging={}",
740                if logging { "on" } else { "off" }
741            ));
742        }
743        Ok(cmd)
744    }
745}
746
747// ─── Output mode ──────────────────────────────────────────────────────────
748
749/// Controls where CSI frames are sent after being read from the serial port.
750#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
751#[serde(rename_all = "lowercase")]
752pub enum OutputMode {
753    /// Stream frames to WebSocket clients only (default).
754    #[default]
755    Stream,
756    /// Write frames to a session dump file only; /api/ws returns 403.
757    Dump,
758    /// Both stream to WebSocket clients and write to the dump file.
759    Both,
760}
761
762#[derive(Debug, Deserialize)]
763pub struct OutputModeConfig {
764    pub mode: String,
765}
766
767// ─── API response ──────────────────────────────────────────────────────────
768
769#[derive(Debug, Serialize)]
770pub struct ApiResponse {
771    pub success: bool,
772    pub message: String,
773}
774
775// ─── Device identification ─────────────────────────────────────────────────
776
777/// Parsed result of the `info` command on `esp-csi-cli-rs`.
778///
779/// The magic prefix `ESP-CSI-CLI/<version>` is what proves the firmware is
780/// `esp-csi-cli-rs`; if the prefix line never arrives, the device is either
781/// running unrelated firmware, an older `esp-csi-cli-rs` build that predates
782/// the `info` command, or no firmware at all.
783#[derive(Debug, Clone, Serialize)]
784pub struct DeviceInfo {
785    /// The version string from the `ESP-CSI-CLI/<version>` magic line.
786    pub banner_version: String,
787    /// `name=` line, expected to be `esp-csi-cli-rs`.
788    pub name: Option<String>,
789    /// `version=` line; should match `banner_version`.
790    pub version: Option<String>,
791    /// `chip=` line: `esp32` | `esp32c3` | `esp32c5` | `esp32c6` | `esp32s3` | `unknown`.
792    pub chip: Option<String>,
793    /// `mac=` line — the factory eFuse base MAC (`AA:BB:CC:DD:EE:FF`). On native
794    /// USB-Serial-JTAG boards this equals the USB `iSerialNumber` descriptor, so
795    /// it is the stable per-board identity the host pins to (see
796    /// [`crate::state::DeviceHandle::mac`]). Present from CLI protocol 2 onward.
797    pub mac: Option<String>,
798    /// `protocol=` line — a wire-format version number bumped on
799    /// incompatible grammar changes. Host tooling should refuse unknown
800    /// protocol values.
801    pub protocol: Option<u32>,
802    /// `features=` list (compile-time enabled Cargo features).
803    pub features: Vec<String>,
804}
805
806// ─── Runtime status ───────────────────────────────────────────────────────
807
808#[derive(Debug, Serialize)]
809pub struct CollectionStatusResponse {
810    pub serial_connected: bool,
811    pub collection_running: bool,
812    pub port_path: String,
813}
814
815impl CollectionStatusResponse {
816    pub fn from_state(
817        serial_connected: &AtomicBool,
818        collection_running: &AtomicBool,
819        port_path: String,
820    ) -> Self {
821        Self {
822            serial_connected: serial_connected.load(Ordering::SeqCst),
823            collection_running: collection_running.load(Ordering::SeqCst),
824            port_path,
825        }
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use crate::profile::StandardCsiProfile;
833
834    #[test]
835    fn traffic_emits_frequency_only_when_unsolicited_omitted() {
836        let cmd = TrafficConfig {
837            frequency_hz: 100,
838            unsolicited: None,
839        }
840        .to_cli_command();
841        assert_eq!(cmd, "set-traffic --frequency-hz=100");
842    }
843
844    #[test]
845    fn traffic_emits_unsolicited_flag() {
846        let on = TrafficConfig {
847            frequency_hz: 1000,
848            unsolicited: Some(true),
849        }
850        .to_cli_command();
851        assert_eq!(on, "set-traffic --frequency-hz=1000 --unsolicited=on");
852        let off = TrafficConfig {
853            frequency_hz: 1000,
854            unsolicited: Some(false),
855        }
856        .to_cli_command();
857        assert_eq!(off, "set-traffic --frequency-hz=1000 --unsolicited=off");
858    }
859
860    fn wifi(mode: &str, peer_mac: Option<&str>, ht40: Option<&str>) -> WifiConfig {
861        WifiConfig {
862            mode: mode.to_string(),
863            sta_ssid: None,
864            sta_password: None,
865            ap_ssid: None,
866            ap_password: None,
867            ap_dhcp: None,
868            ap_leases: None,
869            ap_burst: None,
870            channel: None,
871            peer_mac: peer_mac.map(str::to_string),
872            ht40: ht40.map(str::to_string),
873        }
874    }
875
876    #[test]
877    fn wifi_emits_peer_mac_and_ht40() {
878        let cmd = wifi("esp-now-central", Some("AA:BB:CC:DD:EE:FF"), Some("above"))
879            .to_cli_command(None)
880            .unwrap();
881        assert_eq!(
882            cmd,
883            "set-wifi --mode=esp-now-central --set-channel=1 --peer-mac=AA:BB:CC:DD:EE:FF --ht40=above"
884        );
885    }
886
887    #[test]
888    fn wifi_empty_peer_mac_clears_to_auto() {
889        let cmd = wifi("esp-now-peripheral", Some(""), None)
890            .to_cli_command(None)
891            .unwrap();
892        assert_eq!(cmd, "set-wifi --mode=esp-now-peripheral --set-channel=1 --peer-mac=");
893    }
894
895    #[test]
896    fn wifi_rejects_malformed_peer_mac() {
897        assert!(wifi("esp-now-central", Some("not-a-mac"), None)
898            .to_cli_command(None)
899            .is_err());
900    }
901
902    #[test]
903    fn wifi_rejects_bad_ht40() {
904        assert!(wifi("esp-now-central", None, Some("sideways"))
905            .to_cli_command(None)
906            .is_err());
907    }
908
909    #[test]
910    fn wifi_station_forwards_explicit_channel_hint() {
911        // An explicit channel in station mode is forwarded as a pre-association
912        // band-selection hint.
913        let cmd = WifiConfig {
914            mode: "station".to_string(),
915            sta_ssid: Some("MyNetwork".to_string()),
916            sta_password: None,
917            ap_ssid: None,
918            ap_password: None,
919            ap_dhcp: None,
920            ap_leases: None,
921            ap_burst: None,
922            channel: Some(6),
923            peer_mac: None,
924            ht40: None,
925        }
926        .to_cli_command(None)
927        .unwrap();
928        assert_eq!(
929            cmd,
930            "set-wifi --mode=station --sta-ssid='MyNetwork' --set-channel=6"
931        );
932    }
933
934    #[test]
935    fn wifi_station_omits_channel_when_unset() {
936        // Without an explicit channel, station mode sends no `--set-channel`
937        // (and never falls back to the chip default) — the firmware derives the
938        // channel from the associated AP.
939        let cmd = WifiConfig {
940            mode: "station".to_string(),
941            sta_ssid: Some("MyNetwork".to_string()),
942            sta_password: None,
943            ap_ssid: None,
944            ap_password: None,
945            ap_dhcp: None,
946            ap_leases: None,
947            ap_burst: None,
948            channel: None,
949            peer_mac: None,
950            ht40: None,
951        }
952        .to_cli_command(Some("esp32c5"))
953        .unwrap();
954        assert_eq!(cmd, "set-wifi --mode=station --sta-ssid='MyNetwork'");
955    }
956
957    #[test]
958    fn wifi_ap_c5_defaults_channel_149() {
959        let cmd = WifiConfig {
960            mode: "wifi-ap".to_string(),
961            sta_ssid: None,
962            sta_password: None,
963            ap_ssid: Some("esp-csi-ap".to_string()),
964            ap_password: None,
965            ap_dhcp: None,
966            ap_leases: None,
967            ap_burst: None,
968            channel: None,
969            peer_mac: None,
970            ht40: None,
971        }
972        .to_cli_command(Some("esp32c5"))
973        .unwrap();
974        assert_eq!(
975            cmd,
976            "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --set-channel=149"
977        );
978    }
979
980    #[test]
981    fn wifi_ap_emits_ap_fields() {
982        let cmd = WifiConfig {
983            mode: "wifi-ap".to_string(),
984            sta_ssid: None,
985            sta_password: None,
986            ap_ssid: Some("esp-csi-ap".to_string()),
987            ap_password: Some(String::new()),
988            ap_dhcp: Some(true),
989            ap_leases: None,
990            ap_burst: None,
991            channel: Some(6),
992            peer_mac: None,
993            ht40: None,
994        }
995        .to_cli_command(None)
996        .unwrap();
997        assert_eq!(
998            cmd,
999            "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-password='' --ap-dhcp=on --set-channel=6"
1000        );
1001    }
1002
1003    #[test]
1004    fn wifi_ap_emits_leases_and_burst() {
1005        let cmd = WifiConfig {
1006            mode: "wifi-ap".to_string(),
1007            sta_ssid: None,
1008            sta_password: None,
1009            ap_ssid: Some("esp-csi-ap".to_string()),
1010            ap_password: None,
1011            ap_dhcp: Some(true),
1012            ap_leases: Some(4),
1013            ap_burst: Some(true),
1014            channel: Some(6),
1015            peer_mac: None,
1016            ht40: None,
1017        }
1018        .to_cli_command(None)
1019        .unwrap();
1020        assert_eq!(
1021            cmd,
1022            "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-dhcp=on --ap-leases=4 --ap-burst=on --set-channel=6"
1023        );
1024    }
1025
1026    #[test]
1027    fn wifi_ap_burst_off_emits_off() {
1028        let mut cfg = wifi("wifi-ap", None, None);
1029        cfg.ap_burst = Some(false);
1030        let cmd = cfg.to_cli_command(None).unwrap();
1031        assert_eq!(cmd, "set-wifi --mode=wifi-ap --ap-burst=off --set-channel=1");
1032    }
1033
1034    #[test]
1035    fn wifi_ap_rejects_out_of_range_leases() {
1036        for bad in [0u8, 9] {
1037            let mut cfg = wifi("wifi-ap", None, None);
1038            cfg.ap_leases = Some(bad);
1039            assert!(cfg.to_cli_command(None).is_err());
1040        }
1041    }
1042
1043    #[test]
1044    fn wifi_fast_collector_emits_peer_mac_and_ht40() {
1045        let cmd = wifi("esp-now-fast-collector", Some("aa:bb:cc:dd:ee:ff"), Some("below"))
1046            .to_cli_command(None)
1047            .unwrap();
1048        assert_eq!(
1049            cmd,
1050            "set-wifi --mode=esp-now-fast-collector --set-channel=1 --peer-mac=aa:bb:cc:dd:ee:ff --ht40=below"
1051        );
1052    }
1053
1054    #[test]
1055    fn wifi_rejects_unknown_mode() {
1056        assert!(wifi("mesh", None, None).to_cli_command(None).is_err());
1057    }
1058
1059    /// A `CsiConfig` with every named flag unset and an empty `extra` map.
1060    fn csi_cfg() -> CsiConfig {
1061        CsiConfig {
1062            lltf: None,
1063            htltf: None,
1064            stbc_htltf: None,
1065            ltf_merge: None,
1066            csi: None,
1067            csi_legacy: None,
1068            csi_ht20: None,
1069            csi_ht40: None,
1070            dump_ack: None,
1071            csi_force_lltf: None,
1072            csi_vht: None,
1073            val_scale_cfg: None,
1074            extra: BTreeMap::new(),
1075        }
1076    }
1077
1078    #[test]
1079    fn csi_emits_on_off_toggles() {
1080        let mut cfg = csi_cfg();
1081        cfg.lltf = Some(false);
1082        cfg.csi = Some(true);
1083        cfg.csi_legacy = Some(false);
1084        cfg.dump_ack = Some(false);
1085        let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1086        assert_eq!(cmd, "set-csi --lltf=off --csi=on --csi-legacy=off --dump-ack=off");
1087    }
1088
1089    #[test]
1090    fn csi_emits_default_preset() {
1091        let mut cfg = csi_cfg();
1092        cfg.extra
1093            .insert("preset".to_string(), serde_json::json!("default"));
1094        let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1095        assert_eq!(cmd, "set-csi --preset=default");
1096    }
1097
1098    #[test]
1099    fn csi_emits_extra_flags_generically() {
1100        // Unknown acquisition flags round-trip through the flattened `extra`
1101        // map, formatted with the same on/off + numeric conventions.
1102        let mut cfg = csi_cfg();
1103        cfg.csi = Some(true);
1104        cfg.extra
1105            .insert("csi-su".to_string(), serde_json::json!(1));
1106        cfg.extra
1107            .insert("csi-beamformed".to_string(), serde_json::json!(true));
1108        let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1109        assert_eq!(
1110            cmd,
1111            "set-csi --csi=on --csi-beamformed=on --csi-su=1"
1112        );
1113    }
1114
1115    #[test]
1116    fn csi_rejects_unknown_preset() {
1117        let mut cfg = csi_cfg();
1118        cfg.extra
1119            .insert("preset".to_string(), serde_json::json!("turbo"));
1120        assert!(cfg.to_cli_command(&StandardCsiProfile).is_err());
1121    }
1122
1123    #[test]
1124    fn csi_delivery_accepts_raw() {
1125        let cmd = CsiDeliveryConfig {
1126            mode: Some("raw".to_string()),
1127            logging: None,
1128        }
1129        .to_cli_command()
1130        .unwrap();
1131        assert_eq!(cmd, "set-csi-delivery --mode=raw");
1132    }
1133
1134    #[test]
1135    fn csi_delivery_rejects_unknown_mode() {
1136        assert!(CsiDeliveryConfig {
1137            mode: Some("bogus".to_string()),
1138            logging: None,
1139        }
1140        .to_cli_command()
1141        .is_err());
1142    }
1143
1144    #[test]
1145    fn protocol_emits_lowercased_command() {
1146        let cmd = ProtocolConfig {
1147            protocol: "AC".to_string(),
1148        }
1149        .to_cli_command(&StandardCsiProfile)
1150        .unwrap();
1151        assert_eq!(cmd, "set-protocol --protocol=ac");
1152    }
1153
1154    #[test]
1155    fn protocol_accepts_all_valid_values() {
1156        for p in ["b", "g", "n", "lr", "a", "ac"] {
1157            assert!(ProtocolConfig {
1158                protocol: p.to_string(),
1159            }
1160            .to_cli_command(&StandardCsiProfile)
1161            .is_ok());
1162        }
1163    }
1164
1165    #[test]
1166    fn protocol_rejects_unknown_value() {
1167        assert!(ProtocolConfig {
1168            protocol: "wifi7".to_string(),
1169        }
1170        .to_cli_command(&StandardCsiProfile)
1171        .is_err());
1172    }
1173}