Skip to main content

csi_webclient/state/
mod.rs

1use serde::{Deserialize, Serialize};
2
3/// UI navigation tabs for the main window.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum Tab {
6    #[default]
7    Devices,
8    Dashboard,
9    Config,
10    Control,
11    Stream,
12}
13
14/// Wi-Fi operating modes accepted by `POST /api/devices/{id}/config/wifi`.
15///
16/// Serde names match [`Self::as_api_value`] so config snapshot files use the
17/// same strings as the HTTP API.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum WiFiMode {
21    Station,
22    Sniffer,
23    WifiAp,
24    EspNowCentral,
25    EspNowPeripheral,
26    EspNowFastCollector,
27    EspNowFastSource,
28}
29
30impl WiFiMode {
31    /// Convert enum variant to backend API value.
32    pub fn as_api_value(self) -> &'static str {
33        match self {
34            Self::Station => "station",
35            Self::Sniffer => "sniffer",
36            Self::WifiAp => "wifi-ap",
37            Self::EspNowCentral => "esp-now-central",
38            Self::EspNowPeripheral => "esp-now-peripheral",
39            Self::EspNowFastCollector => "esp-now-fast-collector",
40            Self::EspNowFastSource => "esp-now-fast-source",
41        }
42    }
43
44    /// Resolve a backend value back to a variant.
45    pub fn from_api_value(value: &str) -> Option<Self> {
46        match value {
47            "station" => Some(Self::Station),
48            "sniffer" => Some(Self::Sniffer),
49            "wifi-ap" => Some(Self::WifiAp),
50            "esp-now-central" => Some(Self::EspNowCentral),
51            "esp-now-peripheral" => Some(Self::EspNowPeripheral),
52            "esp-now-fast-collector" => Some(Self::EspNowFastCollector),
53            "esp-now-fast-source" => Some(Self::EspNowFastSource),
54            _ => None,
55        }
56    }
57
58    /// True for all ESP-NOW operating modes (balanced and fast simplex).
59    pub fn is_esp_now(self) -> bool {
60        matches!(
61            self,
62            Self::EspNowCentral
63                | Self::EspNowPeripheral
64                | Self::EspNowFastCollector
65                | Self::EspNowFastSource
66        )
67    }
68
69    /// Requires `esp-csi-cli-rs` ≥ 0.7.0 on the device.
70    pub fn requires_v07(self) -> bool {
71        matches!(
72            self,
73            Self::WifiAp | Self::EspNowFastCollector | Self::EspNowFastSource
74        )
75    }
76
77    /// Whether the channel is an optional pre-association *hint* rather than the
78    /// operating channel.
79    ///
80    /// In station mode the firmware normally derives the channel from the
81    /// associated AP; a supplied channel is forwarded as
82    /// `WifiStationConfig::channel_hint` (a band-selection hint, meaningful on
83    /// the ESP32-C5's 5 GHz band). Every mode accepts a `channel` field.
84    pub fn channel_is_hint(self) -> bool {
85        matches!(self, Self::Station)
86    }
87}
88
89impl Default for WiFiMode {
90    fn default() -> Self {
91        Self::Station
92    }
93}
94
95/// Collection role for the ESP32 firmware session.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum CollectionMode {
99    Collector,
100    Listener,
101}
102
103impl CollectionMode {
104    /// Convert enum variant to backend API value.
105    pub fn as_api_value(self) -> &'static str {
106        match self {
107            Self::Collector => "collector",
108            Self::Listener => "listener",
109        }
110    }
111}
112
113impl Default for CollectionMode {
114    fn default() -> Self {
115        Self::Collector
116    }
117}
118
119/// Forced ESP-NOW TX HT40 secondary-channel selection (`set-wifi --ht40`).
120///
121/// Only meaningful in ESP-NOW modes; ignored by the firmware otherwise.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum Ht40Mode {
125    #[default]
126    None,
127    Above,
128    Below,
129}
130
131impl Ht40Mode {
132    /// Convert enum variant to backend API value.
133    pub fn as_api_value(self) -> &'static str {
134        match self {
135            Self::None => "none",
136            Self::Above => "above",
137            Self::Below => "below",
138        }
139    }
140
141    /// Resolve a backend value back to a variant (`off` aliases `none`).
142    pub fn from_api_value(value: &str) -> Option<Self> {
143        match value {
144            "none" | "off" => Some(Self::None),
145            "above" => Some(Self::Above),
146            "below" => Some(Self::Below),
147            _ => None,
148        }
149    }
150}
151
152/// Output routing mode for CSI frames.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum OutputMode {
156    Stream,
157    Dump,
158    Both,
159}
160
161impl OutputMode {
162    /// Convert enum variant to backend API value.
163    pub fn as_api_value(self) -> &'static str {
164        match self {
165            Self::Stream => "stream",
166            Self::Dump => "dump",
167            Self::Both => "both",
168        }
169    }
170}
171
172impl Default for OutputMode {
173    fn default() -> Self {
174        Self::Stream
175    }
176}
177
178/// CSI delivery path accepted by `POST /api/devices/{id}/config/csi-delivery`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum CsiDeliveryMode {
182    Off,
183    Callback,
184    Async,
185    /// Zero-copy fast-path; stored as a device flag, takes effect on next
186    /// `start`. No CSI data is delivered or logged while active.
187    Raw,
188}
189
190impl CsiDeliveryMode {
191    pub fn as_api_value(self) -> &'static str {
192        match self {
193            Self::Off => "off",
194            Self::Callback => "callback",
195            Self::Async => "async",
196            Self::Raw => "raw",
197        }
198    }
199}
200
201impl Default for CsiDeliveryMode {
202    fn default() -> Self {
203        Self::Async
204    }
205}
206
207/// Wi-Fi PHY protocol applied at the start of each collection run
208/// (`POST /api/devices/{id}/config/protocol`).
209///
210/// Default on the device is `lr` (Espressif Long-Range), which is
211/// proprietary and won't associate with a standard AP — set `n`
212/// explicitly for station mode.
213///
214/// [`Self::Ext`] is a generic escape hatch: a [`crate::profile::ClientProfile`]
215/// can advertise additional protocol strings the core library does not name.
216/// Those round-trip verbatim through [`Self::as_api_value`] /
217/// [`Self::from_api_value`] and the JSON snapshot codec.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum WifiProtocol {
220    B,
221    G,
222    N,
223    Lr,
224    A,
225    Ac,
226    /// A profile-supplied protocol string (e.g. injected by a companion crate)
227    /// carried through the client without the core library naming it.
228    Ext(&'static str),
229}
230
231impl WifiProtocol {
232    /// Convert enum variant to backend API value.
233    pub fn as_api_value(self) -> &'static str {
234        match self {
235            Self::B => "b",
236            Self::G => "g",
237            Self::N => "n",
238            Self::Lr => "lr",
239            Self::A => "a",
240            Self::Ac => "ac",
241            Self::Ext(s) => s,
242        }
243    }
244
245    /// Resolve a backend value back to a variant (case-insensitive).
246    ///
247    /// Unknown values become [`Self::Ext`] so a profile-supplied protocol
248    /// round-trips even though the core library does not name it. The string is
249    /// interned (leaked once) to obtain the `'static` lifetime; the set of
250    /// distinct protocol strings a device reports is tiny and bounded.
251    pub fn from_api_value(value: &str) -> Option<Self> {
252        Some(match value.to_ascii_lowercase().as_str() {
253            "b" => Self::B,
254            "g" => Self::G,
255            "n" => Self::N,
256            "lr" => Self::Lr,
257            "a" => Self::A,
258            "ac" => Self::Ac,
259            other => Self::Ext(intern(other)),
260        })
261    }
262}
263
264/// Intern a protocol string into a leaked `'static` slice. Called only from the
265/// [`WifiProtocol::from_api_value`] fallback for profile-supplied protocols, so
266/// the number of leaked strings is bounded by the distinct protocol names a
267/// device ever reports.
268fn intern(value: &str) -> &'static str {
269    Box::leak(value.to_owned().into_boxed_str())
270}
271
272impl Serialize for WifiProtocol {
273    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
274        serializer.serialize_str(self.as_api_value())
275    }
276}
277
278impl<'de> Deserialize<'de> for WifiProtocol {
279    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
280        let value = String::deserialize(deserializer)?;
281        Ok(Self::from_api_value(&value).unwrap_or_default())
282    }
283}
284
285impl Default for WifiProtocol {
286    fn default() -> Self {
287        Self::Lr
288    }
289}
290
291/// PHY rate options accepted by `POST /api/devices/{id}/config/rate`.
292///
293/// Honored by all modes except `station` on the firmware side.
294pub const PHY_RATES: &[&str] = &[
295    "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
296    "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
297    "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
298];
299
300/// Editable Wi-Fi form values in the Config view.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(default)]
303pub struct WiFiForm {
304    pub mode: WiFiMode,
305    pub sta_ssid: String,
306    pub sta_password: String,
307    pub ap_ssid: String,
308    pub ap_password: String,
309    pub ap_dhcp: bool,
310    /// DHCP lease pool size in `wifi-ap` mode (1–8). With more than one
311    /// lease the ICMP flood targets every associated station.
312    pub ap_leases: u8,
313    /// Synchronized burst flood in `wifi-ap` mode: every flood tick sends one
314    /// frame to every active lease (time-aligned multi-receiver CSI) instead
315    /// of round-robining one station per tick.
316    pub ap_burst: bool,
317    pub channel: String,
318    /// ESP-NOW peer source-MAC filter (`aa:bb:cc:dd:ee:ff`); empty means
319    /// clear back to automatic magic-prefix pairing. ESP-NOW modes only.
320    pub peer_mac: String,
321    /// Forced ESP-NOW TX HT40 secondary channel. ESP-NOW modes only.
322    pub ht40: Ht40Mode,
323}
324
325impl Default for WiFiForm {
326    fn default() -> Self {
327        Self {
328            mode: WiFiMode::Station,
329            sta_ssid: String::new(),
330            sta_password: String::new(),
331            ap_ssid: "esp-csi-ap".to_owned(),
332            ap_password: String::new(),
333            ap_dhcp: true,
334            ap_leases: 4,
335            ap_burst: false,
336            channel: String::new(),
337            peer_mac: String::new(),
338            ht40: Ht40Mode::None,
339        }
340    }
341}
342
343/// Pairing cookbook from esp-csi-cli-rs WEBSERVER.md (two-device setups).
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum PairingPreset {
346    SoftApLab,
347    EspNowFastSimplex,
348    EspNowBalanced,
349}
350
351impl PairingPreset {
352    pub fn label(self) -> &'static str {
353        match self {
354            Self::SoftApLab => "SoftAP lab pair",
355            Self::EspNowFastSimplex => "ESP-NOW fast simplex",
356            Self::EspNowBalanced => "ESP-NOW balanced",
357        }
358    }
359
360    /// Whether this preset requires firmware ≥ 0.7.0 on both boards.
361    pub fn requires_v07(self) -> bool {
362        matches!(self, Self::SoftApLab | Self::EspNowFastSimplex)
363    }
364}
365
366/// Editable traffic-generation form values.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(default)]
369pub struct TrafficForm {
370    pub frequency_hz: String,
371    /// Flood unsolicited echo replies instead of echo requests: strictly
372    /// one-directional traffic (peer never answers at the IP level), stable
373    /// offered rate — but this device captures no CSI back from replies.
374    /// Only meaningful for WiFi AP/station modes with frequency > 0.
375    pub unsolicited: bool,
376}
377
378impl Default for TrafficForm {
379    fn default() -> Self {
380        Self {
381            frequency_hz: "100".to_owned(),
382            unsolicited: false,
383        }
384    }
385}
386
387/// Editable CSI feature flags and numeric values.
388///
389/// Boolean fields use explicit on/off semantics matching `POST …/config/csi`
390/// (`lltf`, `csi_legacy`, …). Defaults mirror firmware `reset-config`.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392#[serde(default)]
393pub struct CsiForm {
394    // Classic (ESP32 / C3 / S3)
395    pub lltf: bool,
396    pub htltf: bool,
397    pub stbc_htltf: bool,
398    pub ltf_merge: bool,
399    // HE (ESP32-C5 / C6)
400    pub csi: bool,
401    pub csi_legacy: bool,
402    pub csi_ht20: bool,
403    pub csi_ht40: bool,
404    pub dump_ack: bool,
405    pub csi_force_lltf: bool,
406    pub csi_vht: bool,
407    pub val_scale_cfg: String,
408}
409
410impl Default for CsiForm {
411    fn default() -> Self {
412        Self {
413            lltf: true,
414            htltf: true,
415            stbc_htltf: true,
416            ltf_merge: true,
417            csi: true,
418            csi_legacy: true,
419            csi_ht20: true,
420            csi_ht40: true,
421            dump_ack: true,
422            csi_force_lltf: true,
423            csi_vht: true,
424            val_scale_cfg: "2".to_owned(),
425        }
426    }
427}
428
429/// Editable PHY rate form value.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(default)]
432pub struct PhyRateForm {
433    pub rate: String,
434}
435
436impl Default for PhyRateForm {
437    fn default() -> Self {
438        Self {
439            rate: "mcs0-lgi".to_owned(),
440        }
441    }
442}
443
444/// Editable IO tasks toggle form values.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(default)]
447pub struct IoTasksForm {
448    pub tx: bool,
449    pub rx: bool,
450}
451
452impl Default for IoTasksForm {
453    fn default() -> Self {
454        Self { tx: true, rx: true }
455    }
456}
457
458/// Editable CSI delivery form values.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(default)]
461pub struct CsiDeliveryForm {
462    pub mode: CsiDeliveryMode,
463    pub logging: bool,
464}
465
466impl Default for CsiDeliveryForm {
467    fn default() -> Self {
468        Self {
469            mode: CsiDeliveryMode::Async,
470            logging: true,
471        }
472    }
473}
474
475/// All editable configuration form values for one device.
476///
477/// These mirror the per-device `config` sub-resources and are populated from
478/// `GET /api/devices/{id}/config`.
479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
480#[serde(default)]
481pub struct DeviceForms {
482    pub wifi: WiFiForm,
483    pub traffic: TrafficForm,
484    pub csi: CsiForm,
485    pub collection_mode: CollectionMode,
486    pub output_mode: OutputMode,
487    pub protocol: WifiProtocol,
488    pub phy_rate: PhyRateForm,
489    pub io_tasks: IoTasksForm,
490    pub csi_delivery: CsiDeliveryForm,
491    pub start_duration_seconds: String,
492}
493
494/// On-disk JSON snapshot of one device's editable configuration.
495///
496/// This is the client-side form state (including Wi-Fi passwords, which the
497/// server never returns), so a saved file can be replayed onto any device via
498/// the full apply sequence. Missing fields fall back to form defaults, so
499/// hand-trimmed files apply only the sections they contain.
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct ConfigSnapshotFile {
502    /// Snapshot format marker for future migrations.
503    #[serde(default = "snapshot_format_version")]
504    pub format: u32,
505    /// Device the snapshot was taken from (informational).
506    #[serde(default)]
507    pub device_id: Option<String>,
508    /// Local wall-clock time the snapshot was saved (informational).
509    #[serde(default)]
510    pub saved_at: Option<String>,
511    pub forms: DeviceForms,
512}
513
514/// Current [`ConfigSnapshotFile::format`] version.
515fn snapshot_format_version() -> u32 {
516    1
517}
518
519/// Lightweight frame metadata shown in the Stream tab.
520#[derive(Debug, Clone, Default)]
521pub struct FrameSummary {
522    pub timestamp: String,
523    pub length: usize,
524    pub preview_hex: String,
525}
526
527/// Connection state to the webserver, derived from `GET /api/devices` results.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub enum ServerStatus {
530    /// No attempt has completed yet (initial state).
531    #[default]
532    Unknown,
533    /// A connection attempt is in flight (e.g. Connect was just clicked).
534    Connecting,
535    /// The last `GET /api/devices` succeeded — the server is reachable.
536    Connected,
537    /// The last `GET /api/devices` failed (network error or HTTP error).
538    Disconnected,
539}
540
541impl ServerStatus {
542    /// Short human-readable label for display.
543    pub fn label(self) -> &'static str {
544        match self {
545            Self::Unknown => "Not connected",
546            Self::Connecting => "Connecting…",
547            Self::Connected => "Connected",
548            Self::Disconnected => "Disconnected",
549        }
550    }
551}
552
553/// Ephemeral UI state that is not part of backend/device config.
554#[derive(Debug, Clone)]
555pub struct TransientUiState {
556    pub active_tab: Tab,
557    pub status_message: String,
558    pub error_message: String,
559    pub server_status: ServerStatus,
560    /// Channel used by two-device pairing presets on the Devices tab.
561    pub preset_channel: String,
562}
563
564impl Default for TransientUiState {
565    fn default() -> Self {
566        Self {
567            active_tab: Tab::Devices,
568            status_message: "Ready".to_owned(),
569            error_message: String::new(),
570            server_status: ServerStatus::Unknown,
571            preset_channel: "6".to_owned(),
572        }
573    }
574}
575
576/// Complete per-device state: identity, config forms, status, and stream.
577///
578/// Every attached device discovered via `GET /api/devices` owns one of these.
579/// State is fully isolated per device, matching the server's per-device model.
580#[derive(Debug, Clone)]
581pub struct DeviceState {
582    /// Stable device id used to build `/api/devices/{id}/...` paths.
583    pub id: String,
584    /// Stable board MAC when reported by the webserver (USB iSerialNumber).
585    pub mac: Option<String>,
586    // ---- live metadata from GET /api/devices ----
587    pub port_path: Option<String>,
588    pub baud_rate: Option<u32>,
589    pub serial_connected: Option<bool>,
590    pub collection_running: Option<bool>,
591    pub firmware_verified: Option<bool>,
592    /// Chip fault reported by the webserver (e.g. the ESP32-C5/C6 USB-JTAG
593    /// reset-loop wedge), including the recovery action. `None` = healthy.
594    pub fault: Option<String>,
595    pub latest_info: Option<DeviceInfo>,
596    // ---- editable config + cache ----
597    pub forms: DeviceForms,
598    pub latest_config: Option<DeviceConfig>,
599    /// Guards against an infinite reset/fetch loop when an empty fetch
600    /// auto-issues `config/reset` and the follow-up fetch is also empty.
601    pub auto_resetting_cache: bool,
602    // ---- stream ----
603    pub ws_connected: bool,
604    pub frames_received: u64,
605    pub bytes_received: u64,
606    pub recent_frames: Vec<FrameSummary>,
607    pub auto_scroll_stream: bool,
608    /// True once the one-time info/config/status fetch has been issued for
609    /// this freshly-discovered device.
610    pub details_loaded: bool,
611    // ---- config save/load/copy ----
612    /// Config snapshot file path in the Config tab; empty = auto-name on save.
613    pub config_path: String,
614    /// Device id selected in the "Copy from" picker on the Config tab.
615    pub copy_source: String,
616    // ---- local Parquet export ----
617    /// True while this device's stream is being recorded to a Parquet file.
618    pub recording: bool,
619    /// Output path of the current (or most recent) recording.
620    pub record_path: Option<String>,
621    /// Frames written to the active recording.
622    pub recorded_frames: u64,
623    /// Frames the recorder could not decode (wire drift / truncation).
624    pub record_decode_errors: u64,
625}
626
627impl DeviceState {
628    /// Create a new device state with default forms.
629    pub fn new(id: impl Into<String>) -> Self {
630        Self {
631            id: id.into(),
632            mac: None,
633            port_path: None,
634            baud_rate: None,
635            serial_connected: None,
636            collection_running: None,
637            firmware_verified: None,
638            fault: None,
639            latest_info: None,
640            forms: DeviceForms::default(),
641            latest_config: None,
642            auto_resetting_cache: false,
643            ws_connected: false,
644            frames_received: 0,
645            bytes_received: 0,
646            recent_frames: Vec::new(),
647            auto_scroll_stream: true,
648            details_loaded: false,
649            config_path: String::new(),
650            copy_source: String::new(),
651            recording: false,
652            record_path: None,
653            recorded_frames: 0,
654            record_decode_errors: 0,
655        }
656    }
657
658    /// Refresh the live status fields from a `GET /api/devices` list entry.
659    ///
660    /// This keeps the dashboard/fleet view current on every discovery poll
661    /// without per-device round-trips.
662    pub fn apply_list_entry(&mut self, entry: &DeviceListEntry) {
663        self.mac = entry.mac.clone();
664        self.port_path = entry.port_path.clone();
665        self.baud_rate = entry.baud_rate;
666        self.serial_connected = entry.serial_connected;
667        self.collection_running = entry.collection_running;
668        self.firmware_verified = entry.firmware_verified;
669        self.fault = entry.fault.clone();
670        if let Some(info) = &entry.device_info {
671            self.latest_info = Some(info.clone());
672        }
673    }
674
675    /// Record one received frame and update stream counters/history.
676    pub fn push_frame(&mut self, bytes: &[u8]) {
677        self.frames_received = self.frames_received.saturating_add(1);
678        self.bytes_received = self.bytes_received.saturating_add(bytes.len() as u64);
679
680        let preview = bytes
681            .iter()
682            .take(24)
683            .map(|b| format!("{b:02X}"))
684            .collect::<Vec<_>>()
685            .join(" ");
686
687        self.recent_frames.push(FrameSummary {
688            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
689            length: bytes.len(),
690            preview_hex: preview,
691        });
692
693        if self.recent_frames.len() > 300 {
694            let drain_to = self.recent_frames.len() - 300;
695            self.recent_frames.drain(0..drain_to);
696        }
697    }
698
699    /// Clear stream frames and counters for this device.
700    pub fn clear_frames(&mut self) {
701        self.recent_frames.clear();
702        self.frames_received = 0;
703        self.bytes_received = 0;
704    }
705
706    /// Apply a `control/status` payload to this device's runtime status.
707    pub fn apply_control_status(&mut self, status: ControlStatus) {
708        self.serial_connected = status.serial_connected;
709        self.collection_running = status.collection_running;
710        self.port_path = status.port_path;
711    }
712
713    /// Apply server config payload into this device's form fields.
714    ///
715    /// Returns the number of fields that were actually applied; callers
716    /// use a zero return to detect an empty server cache.
717    pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
718        let mut applied = 0;
719        let forms = &mut self.forms;
720
721        if let Some(wifi) = config.wifi.as_ref() {
722            if let Some(mode) = wifi.mode.as_deref() {
723                if let Some(parsed) = WiFiMode::from_api_value(mode) {
724                    forms.wifi.mode = parsed;
725                    applied += 1;
726                }
727            }
728            if let Some(channel) = wifi.channel {
729                forms.wifi.channel = channel.to_string();
730                applied += 1;
731            }
732            if let Some(ssid) = &wifi.sta_ssid {
733                forms.wifi.sta_ssid = ssid.clone();
734                applied += 1;
735            }
736            if let Some(ap_ssid) = &wifi.ap_ssid {
737                forms.wifi.ap_ssid = ap_ssid.clone();
738                applied += 1;
739            }
740            if let Some(ap_dhcp) = wifi.ap_dhcp {
741                forms.wifi.ap_dhcp = ap_dhcp;
742                applied += 1;
743            }
744            if let Some(ap_leases) = wifi.ap_leases {
745                forms.wifi.ap_leases = ap_leases;
746                applied += 1;
747            }
748            if let Some(ap_burst) = wifi.ap_burst {
749                forms.wifi.ap_burst = ap_burst;
750                applied += 1;
751            }
752            if let Some(peer_mac) = &wifi.peer_mac {
753                // The server reports "auto" for the default pairing; surface
754                // that as an empty field so re-submitting keeps it automatic.
755                forms.wifi.peer_mac = if peer_mac == "auto" {
756                    String::new()
757                } else {
758                    peer_mac.clone()
759                };
760                applied += 1;
761            }
762            if let Some(ht40) = wifi.ht40.as_deref() {
763                if let Some(parsed) = Ht40Mode::from_api_value(ht40) {
764                    forms.wifi.ht40 = parsed;
765                    applied += 1;
766                }
767            }
768        }
769
770        if let Some(collection) = config.collection.as_ref() {
771            if let Some(traffic_hz) = collection.traffic_hz {
772                forms.traffic.frequency_hz = traffic_hz.to_string();
773                applied += 1;
774            }
775            if let Some(unsolicited) = collection.unsolicited {
776                forms.traffic.unsolicited = unsolicited;
777                applied += 1;
778            }
779            if let Some(mode) = collection.mode.as_deref() {
780                forms.collection_mode = if mode == "listener" {
781                    CollectionMode::Listener
782                } else {
783                    CollectionMode::Collector
784                };
785                applied += 1;
786            }
787            if let Some(rate) = &collection.phy_rate {
788                forms.phy_rate.rate = rate.clone();
789                applied += 1;
790            }
791            if let Some(protocol) = collection.protocol.as_deref() {
792                if let Some(parsed) = WifiProtocol::from_api_value(protocol) {
793                    forms.protocol = parsed;
794                    applied += 1;
795                }
796            }
797            if let Some(tx) = collection.io_tx_enabled {
798                forms.io_tasks.tx = tx;
799                applied += 1;
800            }
801            if let Some(rx) = collection.io_rx_enabled {
802                forms.io_tasks.rx = rx;
803                applied += 1;
804            }
805        }
806
807        if let Some(csi_cfg) = config.csi_config.as_ref() {
808            if let Some(v) = csi_cfg.lltf_enabled {
809                forms.csi.lltf = v;
810                applied += 1;
811            }
812            if let Some(v) = csi_cfg.htltf_enabled {
813                forms.csi.htltf = v;
814                applied += 1;
815            }
816            if let Some(v) = csi_cfg.stbc_htltf_enabled {
817                forms.csi.stbc_htltf = v;
818                applied += 1;
819            }
820            if let Some(v) = csi_cfg.ltf_merge_enabled {
821                forms.csi.ltf_merge = v;
822                applied += 1;
823            }
824            if let Some(v) = csi_cfg.acquire_csi {
825                forms.csi.csi = v != 0;
826                applied += 1;
827            }
828            if let Some(v) = csi_cfg.acquire_csi_legacy {
829                forms.csi.csi_legacy = v != 0;
830                applied += 1;
831            }
832            if let Some(v) = csi_cfg.acquire_csi_ht20 {
833                forms.csi.csi_ht20 = v != 0;
834                applied += 1;
835            }
836            if let Some(v) = csi_cfg.acquire_csi_ht40 {
837                forms.csi.csi_ht40 = v != 0;
838                applied += 1;
839            }
840            if let Some(v) = csi_cfg.dump_ack_enabled {
841                forms.csi.dump_ack = v;
842                applied += 1;
843            }
844            if let Some(v) = csi_cfg.acquire_csi_force_lltf {
845                forms.csi.csi_force_lltf = v;
846                applied += 1;
847            }
848            if let Some(v) = csi_cfg.acquire_csi_vht {
849                forms.csi.csi_vht = v;
850                applied += 1;
851            }
852            if let Some(v) = csi_cfg.val_scale_cfg {
853                forms.csi.val_scale_cfg = v.to_string();
854                applied += 1;
855            }
856        }
857
858        if let Some(mode) = config.csi_delivery_mode.as_deref() {
859            forms.csi_delivery.mode = match mode {
860                "off" => CsiDeliveryMode::Off,
861                "callback" => CsiDeliveryMode::Callback,
862                "raw" => CsiDeliveryMode::Raw,
863                _ => CsiDeliveryMode::Async,
864            };
865            applied += 1;
866        }
867        if let Some(logging) = config.csi_logging_enabled {
868            forms.csi_delivery.logging = logging;
869            applied += 1;
870        }
871
872        self.latest_config = Some(config);
873        applied
874    }
875}
876
877/// High-level user actions queued by the UI for orchestration.
878#[derive(Debug, Clone)]
879pub enum UserIntent {
880    /// Refresh the attached-device list (`GET /api/devices`).
881    FetchDevices,
882    /// Toggle whether a device is in the detail-tab selection set.
883    ToggleDeviceSelection(String),
884    /// Select every attached device for the detail tabs.
885    SelectAllDevices,
886    /// Clear the detail-tab device selection.
887    ClearDeviceSelection,
888    /// Start collection on every attached device.
889    StartAllCollections { duration_seconds: String },
890    /// Stop collection on every attached device.
891    StopAllCollections,
892    /// Start collection on every selected device (synchronized FDM start).
893    StartSelectedCollections { duration_seconds: String },
894    /// Stop collection on every selected device.
895    StopSelectedCollections,
896    /// Start Parquet recording on every selected device.
897    StartSelectedRecording,
898    /// Stop Parquet recording on every selected device.
899    StopSelectedRecording,
900    /// An action addressed to one specific device.
901    Device { id: String, action: DeviceAction },
902}
903
904/// Per-device action; addressed to a device id by [`UserIntent::Device`].
905#[derive(Debug, Clone)]
906pub enum DeviceAction {
907    FetchConfig,
908    FetchInfo,
909    FetchStatus,
910    ResetConfig,
911    SetWifi(WiFiForm),
912    SetTraffic(TrafficForm),
913    SetCsi(CsiForm),
914    /// Apply a named full CSI acquisition preset (e.g. `default`). Additional
915    /// preset names may be supplied by a [`crate::profile::ClientProfile`].
916    SetCsiPreset(&'static str),
917    SetCollectionMode(CollectionMode),
918    SetOutputMode(OutputMode),
919    SetProtocol(WifiProtocol),
920    SetPhyRate(PhyRateForm),
921    SetIoTasks(IoTasksForm),
922    SetCsiDelivery(CsiDeliveryForm),
923    StartCollection { duration_seconds: String },
924    StopCollection,
925    ShowStats,
926    ResetDevice,
927    ConnectWebSocket,
928    DisconnectWebSocket,
929    ClearFrames,
930    /// Begin recording this device's incoming CSI stream to a Parquet file.
931    StartRecording,
932    /// Stop the active recording and finalize the Parquet file.
933    StopRecording,
934    /// Apply a two-device pairing cookbook (ordered config steps per board).
935    ApplyPairingPreset {
936        preset: PairingPreset,
937        device_ids: [String; 2],
938        channel: u8,
939    },
940    /// Save this device's form values to a JSON snapshot file.
941    /// Empty path = auto-named file in the export directory.
942    SaveConfigFile { path: String },
943    /// Load a JSON snapshot file into this device's form values and apply
944    /// every section to the device (same sequence as [`Self::ApplyFullConfig`]).
945    LoadConfigFile { path: String },
946    /// Push every config section from the current form values to the device
947    /// as one ordered step sequence.
948    ApplyFullConfig,
949    /// Copy another device's form values onto this device and apply them.
950    CopyConfigFrom { source_id: String },
951}
952
953/// One element of the `GET /api/devices` array.
954#[derive(Debug, Clone, Serialize, Deserialize, Default)]
955pub struct DeviceListEntry {
956    pub id: String,
957    pub mac: Option<String>,
958    pub port_path: Option<String>,
959    pub baud_rate: Option<u32>,
960    pub serial_connected: Option<bool>,
961    pub collection_running: Option<bool>,
962    pub firmware_verified: Option<bool>,
963    #[serde(default)]
964    pub device_info: Option<DeviceInfo>,
965    #[serde(default)]
966    pub fault: Option<String>,
967}
968
969/// Wi-Fi section of `GET /api/devices/{id}/config`.
970#[derive(Debug, Clone, Serialize, Deserialize, Default)]
971pub struct DeviceWifiConfig {
972    pub mode: Option<String>,
973    pub channel: Option<u16>,
974    pub sta_ssid: Option<String>,
975    pub ap_ssid: Option<String>,
976    pub ap_dhcp: Option<bool>,
977    pub ap_leases: Option<u8>,
978    pub ap_burst: Option<bool>,
979    pub peer_mac: Option<String>,
980    pub ht40: Option<String>,
981}
982
983/// Collection section of `GET /api/devices/{id}/config`.
984#[derive(Debug, Clone, Serialize, Deserialize, Default)]
985pub struct DeviceCollectionConfig {
986    pub mode: Option<String>,
987    pub traffic_hz: Option<u64>,
988    pub unsolicited: Option<bool>,
989    pub phy_rate: Option<String>,
990    pub protocol: Option<String>,
991    pub io_tx_enabled: Option<bool>,
992    pub io_rx_enabled: Option<bool>,
993}
994
995/// CSI section of `GET /api/devices/{id}/config`.
996///
997/// Mirrors firmware `show-config`: classic-chip booleans, HE-chip
998/// `acquire_csi*` integers, plus read-only fields the device exposes
999/// but does not accept via `POST /api/devices/{id}/config/csi`.
1000#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1001pub struct DeviceCsiConfig {
1002    pub lltf_enabled: Option<bool>,
1003    pub htltf_enabled: Option<bool>,
1004    pub stbc_htltf_enabled: Option<bool>,
1005    pub ltf_merge_enabled: Option<bool>,
1006    pub channel_filter_enabled: Option<bool>,
1007    pub manual_scale: Option<bool>,
1008    pub shift: Option<i32>,
1009    pub dump_ack_enabled: Option<bool>,
1010    pub acquire_csi_force_lltf: Option<bool>,
1011    pub acquire_csi_vht: Option<bool>,
1012    pub acquire_csi: Option<u32>,
1013    pub acquire_csi_legacy: Option<u32>,
1014    pub acquire_csi_ht20: Option<u32>,
1015    pub acquire_csi_ht40: Option<u32>,
1016    pub val_scale_cfg: Option<u32>,
1017}
1018
1019/// Cached server-side device configuration model.
1020///
1021/// Mirrors `GET /api/devices/{id}/config`. Sub-sections are `Option` so an
1022/// explicit `null` from the server (cache-not-yet-populated) deserializes as
1023/// `None` instead of erroring the whole payload out.
1024#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025pub struct DeviceConfig {
1026    #[serde(default)]
1027    pub wifi: Option<DeviceWifiConfig>,
1028    #[serde(default)]
1029    pub collection: Option<DeviceCollectionConfig>,
1030    #[serde(default)]
1031    pub csi_config: Option<DeviceCsiConfig>,
1032    pub csi_delivery_mode: Option<String>,
1033    pub csi_logging_enabled: Option<bool>,
1034}
1035
1036/// Firmware identity from `GET /api/devices/{id}/info`.
1037#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1038pub struct DeviceInfo {
1039    pub banner_version: Option<String>,
1040    pub name: Option<String>,
1041    pub version: Option<String>,
1042    pub chip: Option<String>,
1043    pub mac: Option<String>,
1044    pub protocol: Option<u32>,
1045    #[serde(default)]
1046    pub features: Vec<String>,
1047}
1048
1049impl DeviceInfo {
1050    /// True when firmware is new enough for v0.7.0 Wi-Fi modes.
1051    pub fn supports_v07_modes(&self) -> bool {
1052        firmware_version_at_least(&self.version, 0, 7, 0)
1053            || firmware_version_at_least(&self.banner_version, 0, 7, 0)
1054    }
1055
1056    /// True when firmware understands `set-traffic --unsolicited` (≥ 0.7.0).
1057    /// Older firmware's CLI rejects the WHOLE set-traffic command on an
1058    /// unknown flag ("Error: Did not understand"), silently discarding the
1059    /// frequency too — so the client must not send the flag to old firmware.
1060    pub fn supports_unsolicited(&self) -> bool {
1061        firmware_version_at_least(&self.version, 0, 7, 0)
1062            || firmware_version_at_least(&self.banner_version, 0, 7, 0)
1063    }
1064}
1065
1066impl DeviceState {
1067    /// True when cached firmware info supports v0.7.0 modes.
1068    pub fn supports_v07_modes(&self) -> bool {
1069        self.latest_info
1070            .as_ref()
1071            .is_some_and(DeviceInfo::supports_v07_modes)
1072    }
1073
1074    /// True when cached firmware info supports `set-traffic --unsolicited`.
1075    pub fn supports_unsolicited(&self) -> bool {
1076        self.latest_info
1077            .as_ref()
1078            .is_some_and(DeviceInfo::supports_unsolicited)
1079    }
1080}
1081
1082/// Parse `major.minor.patch` and test ≥ `(req_major, req_minor, req_patch)`.
1083fn firmware_version_at_least(
1084    version: &Option<String>,
1085    req_major: u64,
1086    req_minor: u64,
1087    req_patch: u64,
1088) -> bool {
1089    let Some(v) = version.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
1090        return false;
1091    };
1092    let mut parts = v.split('.');
1093    let Some(Ok(major)) = parts.next().map(str::parse) else {
1094        return false;
1095    };
1096    let Some(Ok(minor)) = parts.next().map(str::parse) else {
1097        return false;
1098    };
1099    let patch: u64 = parts
1100        .next()
1101        .unwrap_or("0")
1102        .parse()
1103        .unwrap_or(0);
1104    (major, minor, patch) >= (req_major, req_minor, req_patch)
1105}
1106
1107/// Runtime status payload from `GET /api/devices/{id}/control/status`.
1108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1109pub struct ControlStatus {
1110    pub serial_connected: Option<bool>,
1111    pub collection_running: Option<bool>,
1112    pub port_path: Option<String>,
1113}
1114
1115/// Result of reconciling a fresh `GET /api/devices` payload into state.
1116#[derive(Debug, Clone, Default)]
1117pub struct ReconcileOutcome {
1118    /// Ids of devices that were newly discovered this poll.
1119    pub new_ids: Vec<String>,
1120    /// Ids of devices that disappeared since the last poll.
1121    pub removed_ids: Vec<String>,
1122    /// True when the device set (membership or selection) changed.
1123    pub changed: bool,
1124}
1125
1126/// Full application state.
1127///
1128/// This is the single source of truth for all UI-visible data.
1129#[derive(Debug, Clone, Default)]
1130pub struct AppState {
1131    pub server_host: String,
1132    pub server_port: String,
1133    /// Directory where locally-recorded Parquet exports are written.
1134    pub export_dir: String,
1135    pub devices: Vec<DeviceState>,
1136    /// Devices currently selected for the detail tabs. Multiple devices can be
1137    /// selected at once for side-by-side (FDM mesh) collection.
1138    pub selected_device_ids: Vec<String>,
1139    pub transient: TransientUiState,
1140    pub events: Vec<String>,
1141    intent_queue: Vec<UserIntent>,
1142}
1143
1144impl AppState {
1145    /// Construct default state with localhost webserver settings.
1146    pub fn with_defaults() -> Self {
1147        let mut state = Self::default();
1148        state.server_host = "127.0.0.1".to_owned();
1149        state.server_port = "3000".to_owned();
1150        state.export_dir = ".".to_owned();
1151        state
1152    }
1153
1154    /// Queue one user intent.
1155    pub fn push_intent(&mut self, intent: UserIntent) {
1156        self.intent_queue.push(intent);
1157    }
1158
1159    /// Queue a per-device action addressed to `id`.
1160    pub fn push_device_action(&mut self, id: impl Into<String>, action: DeviceAction) {
1161        self.intent_queue.push(UserIntent::Device {
1162            id: id.into(),
1163            action,
1164        });
1165    }
1166
1167    /// Drain queued intents in FIFO order.
1168    pub fn drain_intents(&mut self) -> Vec<UserIntent> {
1169        std::mem::take(&mut self.intent_queue)
1170    }
1171
1172    /// Append one event line to the global event history.
1173    pub fn push_event(&mut self, message: impl Into<String>) {
1174        self.events.push(message.into());
1175        if self.events.len() > 300 {
1176            let drain_to = self.events.len() - 300;
1177            self.events.drain(0..drain_to);
1178        }
1179    }
1180
1181    /// Indices of the currently selected devices, in `devices` order (so the
1182    /// detail tabs render columns in a stable order regardless of click order).
1183    pub fn selected_indices(&self) -> Vec<usize> {
1184        self.devices
1185            .iter()
1186            .enumerate()
1187            .filter(|(_, d)| self.selected_device_ids.iter().any(|id| id == &d.id))
1188            .map(|(idx, _)| idx)
1189            .collect()
1190    }
1191
1192    /// Whether the device with the given id is currently selected.
1193    pub fn is_selected(&self, id: &str) -> bool {
1194        self.selected_device_ids.iter().any(|s| s == id)
1195    }
1196
1197    /// Toggle membership of `id` in the selection set.
1198    pub fn toggle_selection(&mut self, id: String) {
1199        if let Some(pos) = self.selected_device_ids.iter().position(|s| s == &id) {
1200            self.selected_device_ids.remove(pos);
1201        } else {
1202            self.selected_device_ids.push(id);
1203        }
1204    }
1205
1206    /// Index of the device with the given id.
1207    pub fn device_index_by_id(&self, id: &str) -> Option<usize> {
1208        self.devices.iter().position(|d| d.id == id)
1209    }
1210
1211    /// Mutable reference to the device with the given id.
1212    pub fn device_mut_by_id(&mut self, id: &str) -> Option<&mut DeviceState> {
1213        self.devices.iter_mut().find(|d| d.id == id)
1214    }
1215
1216    /// Build the HTTP base URL from host/port fields.
1217    pub fn base_http_url(&self) -> String {
1218        format!(
1219            "http://{}:{}",
1220            self.server_host.trim(),
1221            self.server_port.trim()
1222        )
1223    }
1224
1225    /// Build the per-device WebSocket stream URL.
1226    pub fn device_ws_url(&self, id: &str) -> String {
1227        format!(
1228            "ws://{}:{}/api/devices/{}/ws",
1229            self.server_host.trim(),
1230            self.server_port.trim(),
1231            id
1232        )
1233    }
1234
1235    /// Reconcile a fresh `GET /api/devices` payload into per-device state.
1236    ///
1237    /// Adds new devices (default forms), refreshes live status on existing
1238    /// ones, drops devices that vanished, and auto-selects the first device
1239    /// when the current selection is empty or stale.
1240    pub fn reconcile_devices(&mut self, entries: Vec<DeviceListEntry>) -> ReconcileOutcome {
1241        let mut outcome = ReconcileOutcome::default();
1242
1243        let incoming_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1244
1245        outcome.removed_ids = self
1246            .devices
1247            .iter()
1248            .map(|d| d.id.clone())
1249            .filter(|id| !incoming_ids.contains(id))
1250            .collect();
1251
1252        let before = self.devices.len();
1253        self.devices.retain(|d| incoming_ids.contains(&d.id));
1254        if self.devices.len() != before {
1255            outcome.changed = true;
1256        }
1257
1258        for entry in &entries {
1259            if let Some(idx) = self.device_index_by_id(&entry.id) {
1260                self.devices[idx].apply_list_entry(entry);
1261            } else {
1262                let mut device = DeviceState::new(entry.id.clone());
1263                device.apply_list_entry(entry);
1264                self.devices.push(device);
1265                outcome.new_ids.push(entry.id.clone());
1266                outcome.changed = true;
1267            }
1268        }
1269
1270        // Drop any selected ids whose device vanished.
1271        let before_selection = self.selected_device_ids.len();
1272        self.selected_device_ids
1273            .retain(|id| self.devices.iter().any(|d| &d.id == id));
1274        if self.selected_device_ids.len() != before_selection {
1275            outcome.changed = true;
1276        }
1277        // Seed the selection with the first device on a fresh start so the
1278        // detail tabs aren't empty when devices are present.
1279        if self.selected_device_ids.is_empty() {
1280            if let Some(first) = self.devices.first() {
1281                self.selected_device_ids.push(first.id.clone());
1282                outcome.changed = true;
1283            }
1284        }
1285
1286        outcome
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293
1294    #[test]
1295    fn device_config_parses_full_nested_response() {
1296        let json = r#"{
1297            "wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
1298            "collection": {
1299                "mode": "collector", "traffic_hz": 100, "unsolicited": true,
1300                "phy_rate": "mcs0-lgi",
1301                "protocol": "n", "io_tx_enabled": true, "io_rx_enabled": true
1302            },
1303            "csi_config": {
1304                "lltf_enabled": true, "htltf_enabled": true,
1305                "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
1306                "val_scale_cfg": 2,
1307                "acquire_csi": 1, "acquire_csi_legacy": 0
1308            },
1309            "csi_delivery_mode": "async",
1310            "csi_logging_enabled": true
1311        }"#;
1312        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1313        let mut device = DeviceState::new("ttyUSB0");
1314        let applied = device.apply_device_config(cfg);
1315        assert!(applied > 0);
1316        assert_eq!(device.forms.wifi.mode, WiFiMode::Sniffer);
1317        assert_eq!(device.forms.wifi.channel, "6");
1318        assert_eq!(device.forms.traffic.frequency_hz, "100");
1319        assert!(device.forms.traffic.unsolicited);
1320        assert!(device.forms.csi.csi);
1321        assert!(!device.forms.csi.csi_legacy);
1322        assert_eq!(device.forms.protocol, WifiProtocol::N);
1323    }
1324
1325    #[test]
1326    fn device_config_tolerates_null_sub_objects() {
1327        let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
1328        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
1329        let mut device = DeviceState::new("ttyUSB0");
1330        assert_eq!(device.apply_device_config(cfg), 0);
1331    }
1332
1333    #[test]
1334    fn device_config_tolerates_missing_sub_objects() {
1335        let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
1336        let mut device = DeviceState::new("ttyUSB0");
1337        assert_eq!(device.apply_device_config(cfg), 0);
1338    }
1339
1340    #[test]
1341    fn config_snapshot_roundtrips_via_json() {
1342        let mut forms = DeviceForms::default();
1343        forms.wifi.mode = WiFiMode::EspNowCentral;
1344        forms.wifi.channel = "11".to_owned();
1345        forms.wifi.sta_password = "hunter22".to_owned();
1346        forms.traffic.frequency_hz = "500".to_owned();
1347        // Exercise the generic profile-supplied protocol escape hatch.
1348        forms.protocol = WifiProtocol::Ext("myproto");
1349        forms.collection_mode = CollectionMode::Listener;
1350        forms.output_mode = OutputMode::Both;
1351        forms.csi_delivery.mode = CsiDeliveryMode::Raw;
1352        forms.csi.csi_vht = false;
1353
1354        let snapshot = ConfigSnapshotFile {
1355            format: 1,
1356            device_id: Some("dev-a".to_owned()),
1357            saved_at: None,
1358            forms,
1359        };
1360        let json = serde_json::to_string_pretty(&snapshot).expect("serialize");
1361        // Enum values on disk match the HTTP API strings.
1362        assert!(json.contains("\"esp-now-central\""));
1363        assert!(json.contains("\"myproto\""));
1364        assert!(json.contains("\"listener\""));
1365
1366        let parsed: ConfigSnapshotFile = serde_json::from_str(&json).expect("parse");
1367        assert_eq!(parsed.forms.wifi.mode, WiFiMode::EspNowCentral);
1368        assert_eq!(parsed.forms.wifi.channel, "11");
1369        assert_eq!(parsed.forms.wifi.sta_password, "hunter22");
1370        assert_eq!(parsed.forms.traffic.frequency_hz, "500");
1371        assert_eq!(parsed.forms.protocol, WifiProtocol::Ext("myproto"));
1372        assert_eq!(parsed.forms.collection_mode, CollectionMode::Listener);
1373        assert_eq!(parsed.forms.output_mode, OutputMode::Both);
1374        assert_eq!(parsed.forms.csi_delivery.mode, CsiDeliveryMode::Raw);
1375        assert!(!parsed.forms.csi.csi_vht);
1376    }
1377
1378    #[test]
1379    fn config_snapshot_fills_missing_fields_with_defaults() {
1380        // A hand-trimmed file with only a Wi-Fi mode: everything else falls
1381        // back to form defaults, and the format marker defaults to 1.
1382        let json = r#"{ "forms": { "wifi": { "mode": "sniffer" } } }"#;
1383        let parsed: ConfigSnapshotFile = serde_json::from_str(json).expect("parse partial");
1384        assert_eq!(parsed.format, 1);
1385        assert_eq!(parsed.forms.wifi.mode, WiFiMode::Sniffer);
1386        assert_eq!(parsed.forms.wifi.ap_ssid, "esp-csi-ap");
1387        assert_eq!(parsed.forms.traffic.frequency_hz, "100");
1388        assert_eq!(parsed.forms.phy_rate.rate, "mcs0-lgi");
1389    }
1390
1391    #[test]
1392    fn wifi_mode_parses_v07_values() {
1393        assert_eq!(
1394            WiFiMode::from_api_value("wifi-ap"),
1395            Some(WiFiMode::WifiAp)
1396        );
1397        assert!(WiFiMode::EspNowFastCollector.is_esp_now());
1398        assert!(WiFiMode::WifiAp.requires_v07());
1399    }
1400
1401    #[test]
1402    fn wifi_mode_station_channel_is_hint() {
1403        assert!(WiFiMode::Station.channel_is_hint());
1404        assert!(!WiFiMode::Sniffer.channel_is_hint());
1405        assert!(!WiFiMode::WifiAp.channel_is_hint());
1406        assert!(!WiFiMode::EspNowCentral.channel_is_hint());
1407    }
1408
1409    #[test]
1410    fn device_config_applies_ap_fields() {
1411        let json = r#"{
1412            "wifi": {
1413                "mode": "wifi-ap",
1414                "channel": 6,
1415                "ap_ssid": "lab-ap",
1416                "ap_dhcp": false
1417            }
1418        }"#;
1419        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1420        let mut device = DeviceState::new("D0CF13E290E8");
1421        device.apply_device_config(cfg);
1422        assert_eq!(device.forms.wifi.mode, WiFiMode::WifiAp);
1423        assert_eq!(device.forms.wifi.ap_ssid, "lab-ap");
1424        assert!(!device.forms.wifi.ap_dhcp);
1425    }
1426
1427    #[test]
1428    fn firmware_version_gating() {
1429        let info = DeviceInfo {
1430            version: Some("0.7.0".to_owned()),
1431            ..Default::default()
1432        };
1433        assert!(info.supports_v07_modes());
1434        let old = DeviceInfo {
1435            version: Some("0.6.0".to_owned()),
1436            ..Default::default()
1437        };
1438        assert!(!old.supports_v07_modes());
1439
1440        // `--unsolicited` is understood from 0.7.0 (the current release); older
1441        // firmware rejects the whole set-traffic command on the unknown flag.
1442        let v06 = DeviceInfo {
1443            version: Some("0.6.0".to_owned()),
1444            ..Default::default()
1445        };
1446        assert!(!v06.supports_unsolicited());
1447        let v07 = DeviceInfo {
1448            version: Some("0.7.0".to_owned()),
1449            ..Default::default()
1450        };
1451        assert!(v07.supports_unsolicited());
1452    }
1453
1454    #[test]
1455    fn device_list_parses_mac_field() {
1456        let json = r#"[
1457            {
1458                "id": "D0-CF-13-E2-90-E8",
1459                "mac": "D0:CF:13:E2:90:E8",
1460                "port_path": "/dev/ttyACM0",
1461                "serial_connected": true,
1462                "firmware_verified": true
1463            }
1464        ]"#;
1465        let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1466        let mut state = AppState::with_defaults();
1467        state.reconcile_devices(entries);
1468        assert_eq!(state.devices[0].id, "D0-CF-13-E2-90-E8");
1469        assert_eq!(
1470            state.devices[0].mac.as_deref(),
1471            Some("D0:CF:13:E2:90:E8")
1472        );
1473    }
1474
1475    #[test]
1476    fn device_list_parses_and_reconciles() {
1477        let json = r#"[
1478            {
1479                "id": "ttyUSB0", "port_path": "/dev/ttyUSB0", "baud_rate": 115200,
1480                "serial_connected": true, "collection_running": false,
1481                "firmware_verified": true,
1482                "device_info": { "name": "esp-csi-cli-rs", "chip": "esp32c6", "protocol": 1 },
1483                "fault": "USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — replug"
1484            }
1485        ]"#;
1486        let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1487        let mut state = AppState::with_defaults();
1488        let outcome = state.reconcile_devices(entries);
1489        assert!(outcome.changed);
1490        assert_eq!(outcome.new_ids, vec!["ttyUSB0".to_owned()]);
1491        assert_eq!(state.devices.len(), 1);
1492        assert_eq!(state.selected_device_ids, vec!["ttyUSB0".to_owned()]);
1493        let device = &state.devices[0];
1494        assert_eq!(device.serial_connected, Some(true));
1495        assert_eq!(device.latest_info.as_ref().unwrap().chip.as_deref(), Some("esp32c6"));
1496        assert!(device.fault.as_deref().unwrap().contains("USB-JTAG reset loop"));
1497    }
1498
1499    #[test]
1500    fn reconcile_drops_vanished_and_reselects() {
1501        let mut state = AppState::with_defaults();
1502        state.reconcile_devices(vec![
1503            DeviceListEntry { id: "a".to_owned(), ..Default::default() },
1504            DeviceListEntry { id: "b".to_owned(), ..Default::default() },
1505        ]);
1506        assert_eq!(state.selected_device_ids, vec!["a".to_owned()]);
1507
1508        // Device "a" unplugged; selection should fall back to "b".
1509        let outcome = state.reconcile_devices(vec![DeviceListEntry {
1510            id: "b".to_owned(),
1511            ..Default::default()
1512        }]);
1513        assert!(outcome.changed);
1514        assert_eq!(outcome.removed_ids, vec!["a".to_owned()]);
1515        assert_eq!(state.devices.len(), 1);
1516        assert_eq!(state.selected_device_ids, vec!["b".to_owned()]);
1517    }
1518}