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    Dashboard,
8    Config,
9    Control,
10    Stream,
11}
12
13/// Wi-Fi operating modes accepted by `POST /api/config/wifi`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WiFiMode {
16    Station,
17    Sniffer,
18    EspNowCentral,
19    EspNowPeripheral,
20}
21
22impl WiFiMode {
23    /// Convert enum variant to backend API value.
24    pub fn as_api_value(self) -> &'static str {
25        match self {
26            Self::Station => "station",
27            Self::Sniffer => "sniffer",
28            Self::EspNowCentral => "esp-now-central",
29            Self::EspNowPeripheral => "esp-now-peripheral",
30        }
31    }
32
33    /// Resolve a backend value back to a variant.
34    pub fn from_api_value(value: &str) -> Option<Self> {
35        match value {
36            "station" => Some(Self::Station),
37            "sniffer" => Some(Self::Sniffer),
38            "esp-now-central" => Some(Self::EspNowCentral),
39            "esp-now-peripheral" => Some(Self::EspNowPeripheral),
40            _ => None,
41        }
42    }
43}
44
45impl Default for WiFiMode {
46    fn default() -> Self {
47        Self::Station
48    }
49}
50
51/// Collection role for the ESP32 firmware session.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CollectionMode {
54    Collector,
55    Listener,
56}
57
58impl CollectionMode {
59    /// Convert enum variant to backend API value.
60    pub fn as_api_value(self) -> &'static str {
61        match self {
62            Self::Collector => "collector",
63            Self::Listener => "listener",
64        }
65    }
66}
67
68impl Default for CollectionMode {
69    fn default() -> Self {
70        Self::Collector
71    }
72}
73
74/// Serial framing/log mode accepted by `POST /api/config/log-mode`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum LogMode {
77    Text,
78    ArrayList,
79    Serialized,
80    EspCsiTool,
81}
82
83impl LogMode {
84    /// Convert enum variant to backend API value.
85    pub fn as_api_value(self) -> &'static str {
86        match self {
87            Self::Text => "text",
88            Self::ArrayList => "array-list",
89            Self::Serialized => "serialized",
90            Self::EspCsiTool => "esp-csi-tool",
91        }
92    }
93}
94
95impl Default for LogMode {
96    fn default() -> Self {
97        Self::ArrayList
98    }
99}
100
101/// Output routing mode for CSI frames.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum OutputMode {
104    Stream,
105    Dump,
106    Both,
107}
108
109impl OutputMode {
110    /// Convert enum variant to backend API value.
111    pub fn as_api_value(self) -> &'static str {
112        match self {
113            Self::Stream => "stream",
114            Self::Dump => "dump",
115            Self::Both => "both",
116        }
117    }
118}
119
120impl Default for OutputMode {
121    fn default() -> Self {
122        Self::Stream
123    }
124}
125
126/// CSI delivery path accepted by `POST /api/config/csi-delivery`.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum CsiDeliveryMode {
129    Off,
130    Callback,
131    Async,
132    /// Zero-copy fast-path; applies on the next `start`, delivers no CSI data.
133    Raw,
134}
135
136impl CsiDeliveryMode {
137    pub fn as_api_value(self) -> &'static str {
138        match self {
139            Self::Off => "off",
140            Self::Callback => "callback",
141            Self::Async => "async",
142            Self::Raw => "raw",
143        }
144    }
145}
146
147impl Default for CsiDeliveryMode {
148    fn default() -> Self {
149        Self::Async
150    }
151}
152
153/// PHY rate options accepted by `POST /api/config/rate`.
154///
155/// Only honored by ESP-NOW central/peripheral modes on the firmware side.
156pub const PHY_RATES: &[&str] = &[
157    "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
158    "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
159    "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
160];
161
162/// HT40 forced-secondary-channel options for ESP-NOW (`POST /api/config/wifi`).
163pub const HT40_OPTIONS: &[&str] = &["none", "above", "below"];
164
165/// Editable Wi-Fi form values in the Config view.
166#[derive(Debug, Clone)]
167pub struct WiFiForm {
168    pub mode: WiFiMode,
169    pub sta_ssid: String,
170    pub sta_password: String,
171    pub channel: String,
172    /// ESP-NOW peer source MAC; empty means automatic pairing (`auto`).
173    pub peer_mac: String,
174    /// Forced ESP-NOW TX HT40 secondary channel: `none` | `above` | `below`.
175    pub ht40: String,
176}
177
178impl Default for WiFiForm {
179    fn default() -> Self {
180        Self {
181            mode: WiFiMode::default(),
182            sta_ssid: String::new(),
183            sta_password: String::new(),
184            channel: String::new(),
185            peer_mac: String::new(),
186            ht40: "none".to_owned(),
187        }
188    }
189}
190
191/// Editable traffic configuration form values.
192#[derive(Debug, Clone)]
193pub struct TrafficForm {
194    pub frequency_hz: String,
195}
196
197impl Default for TrafficForm {
198    fn default() -> Self {
199        Self {
200            frequency_hz: "100".to_owned(),
201        }
202    }
203}
204
205/// Editable CSI feature flags and numeric values.
206#[derive(Debug, Clone)]
207pub struct CsiForm {
208    pub disable_lltf: bool,
209    pub disable_htltf: bool,
210    pub disable_stbc_htltf: bool,
211    pub disable_ltf_merge: bool,
212    pub disable_csi: bool,
213    pub disable_csi_legacy: bool,
214    pub disable_csi_ht20: bool,
215    pub disable_csi_ht40: bool,
216    pub disable_csi_su: bool,
217    pub disable_csi_mu: bool,
218    pub disable_csi_dcm: bool,
219    pub disable_csi_beamformed: bool,
220    pub csi_he_stbc: String,
221    pub val_scale_cfg: String,
222}
223
224impl Default for CsiForm {
225    fn default() -> Self {
226        Self {
227            disable_lltf: false,
228            disable_htltf: false,
229            disable_stbc_htltf: false,
230            disable_ltf_merge: false,
231            disable_csi: false,
232            disable_csi_legacy: false,
233            disable_csi_ht20: false,
234            disable_csi_ht40: false,
235            disable_csi_su: false,
236            disable_csi_mu: false,
237            disable_csi_dcm: false,
238            disable_csi_beamformed: false,
239            csi_he_stbc: "2".to_owned(),
240            val_scale_cfg: "2".to_owned(),
241        }
242    }
243}
244
245/// Editable PHY rate form value.
246#[derive(Debug, Clone)]
247pub struct PhyRateForm {
248    pub rate: String,
249}
250
251impl Default for PhyRateForm {
252    fn default() -> Self {
253        Self {
254            rate: "mcs0-lgi".to_owned(),
255        }
256    }
257}
258
259/// Editable IO tasks toggle form values.
260#[derive(Debug, Clone)]
261pub struct IoTasksForm {
262    pub tx: bool,
263    pub rx: bool,
264}
265
266impl Default for IoTasksForm {
267    fn default() -> Self {
268        Self { tx: true, rx: true }
269    }
270}
271
272/// Editable CSI delivery form values.
273#[derive(Debug, Clone)]
274pub struct CsiDeliveryForm {
275    pub mode: CsiDeliveryMode,
276    pub logging: bool,
277}
278
279impl Default for CsiDeliveryForm {
280    fn default() -> Self {
281        Self {
282            mode: CsiDeliveryMode::Async,
283            logging: true,
284        }
285    }
286}
287
288/// User/session-level state persisted during app runtime.
289#[derive(Debug, Clone, Default)]
290pub struct PersistentState {
291    pub server_host: String,
292    pub server_port: String,
293    pub wifi: WiFiForm,
294    pub traffic: TrafficForm,
295    pub csi: CsiForm,
296    pub collection_mode: CollectionMode,
297    pub log_mode: LogMode,
298    pub output_mode: OutputMode,
299    pub phy_rate: PhyRateForm,
300    pub io_tasks: IoTasksForm,
301    pub csi_delivery: CsiDeliveryForm,
302    pub start_duration_seconds: String,
303}
304
305/// Ephemeral UI state that is not part of backend/device config.
306#[derive(Debug, Clone)]
307pub struct TransientUiState {
308    pub active_tab: Tab,
309    pub status_message: String,
310    pub error_message: String,
311    pub auto_scroll_stream: bool,
312}
313
314impl Default for TransientUiState {
315    fn default() -> Self {
316        Self {
317            active_tab: Tab::Dashboard,
318            status_message: "Ready".to_owned(),
319            error_message: String::new(),
320            auto_scroll_stream: true,
321        }
322    }
323}
324
325/// Lightweight frame metadata shown in the Stream tab.
326#[derive(Debug, Clone, Default)]
327pub struct FrameSummary {
328    pub timestamp: String,
329    pub length: usize,
330    pub preview_hex: String,
331}
332
333/// Runtime status produced by background IO work and `/api/control/status`.
334#[derive(Debug, Clone, Default)]
335pub struct RuntimeState {
336    pub ws_connected: bool,
337    pub serial_connected: Option<bool>,
338    pub collection_running: Option<bool>,
339    pub port_path: Option<String>,
340    pub firmware_verified: Option<bool>,
341    pub frames_received: u64,
342    pub bytes_received: u64,
343    pub recent_frames: Vec<FrameSummary>,
344    pub events: Vec<String>,
345    pub last_http_status: Option<u16>,
346    pub latest_config: Option<DeviceConfig>,
347    pub latest_info: Option<DeviceInfo>,
348    /// Guards against an infinite reset/fetch loop when an empty fetch
349    /// auto-issues `/api/config/reset` and the follow-up fetch is also empty.
350    pub auto_resetting_cache: bool,
351}
352
353/// High-level user actions queued by the UI for orchestration.
354#[derive(Debug, Clone)]
355pub enum UserIntent {
356    FetchConfig,
357    FetchInfo,
358    FetchStatus,
359    ResetConfig,
360    SetWifi(WiFiForm),
361    SetTraffic(TrafficForm),
362    SetCsi(CsiForm),
363    SetCollectionMode(CollectionMode),
364    SetLogMode(LogMode),
365    SetOutputMode(OutputMode),
366    SetPhyRate(PhyRateForm),
367    SetIoTasks(IoTasksForm),
368    SetCsiDelivery(CsiDeliveryForm),
369    StartCollection { duration_seconds: String },
370    StopCollection,
371    ShowStats,
372    ResetDevice,
373    ConnectWebSocket,
374    DisconnectWebSocket,
375    ClearFrames,
376}
377
378/// Wi-Fi section of `GET /api/config`.
379#[derive(Debug, Clone, Serialize, Deserialize, Default)]
380pub struct DeviceWifiConfig {
381    pub mode: Option<String>,
382    pub channel: Option<u16>,
383    pub sta_ssid: Option<String>,
384    pub peer_mac: Option<String>,
385    pub ht40: Option<String>,
386}
387
388/// Collection section of `GET /api/config`.
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct DeviceCollectionConfig {
391    pub mode: Option<String>,
392    pub traffic_hz: Option<u64>,
393    pub phy_rate: Option<String>,
394    pub io_tx_enabled: Option<bool>,
395    pub io_rx_enabled: Option<bool>,
396}
397
398/// CSI section of `GET /api/config`.
399///
400/// Mirrors firmware `show-config`: classic-chip booleans, HE-chip
401/// `acquire_csi*` integers, plus read-only fields the device exposes
402/// but does not accept via `POST /api/config/csi`.
403#[derive(Debug, Clone, Serialize, Deserialize, Default)]
404pub struct DeviceCsiConfig {
405    pub lltf_enabled: Option<bool>,
406    pub htltf_enabled: Option<bool>,
407    pub stbc_htltf_enabled: Option<bool>,
408    pub ltf_merge_enabled: Option<bool>,
409    pub channel_filter_enabled: Option<bool>,
410    pub manual_scale: Option<bool>,
411    pub shift: Option<i32>,
412    pub dump_ack_enabled: Option<bool>,
413    pub acquire_csi: Option<u32>,
414    pub acquire_csi_legacy: Option<u32>,
415    pub acquire_csi_ht20: Option<u32>,
416    pub acquire_csi_ht40: Option<u32>,
417    pub acquire_csi_su: Option<u32>,
418    pub acquire_csi_mu: Option<u32>,
419    pub acquire_csi_dcm: Option<u32>,
420    pub acquire_csi_beamformed: Option<u32>,
421    pub csi_he_stbc: Option<u32>,
422    pub val_scale_cfg: Option<u32>,
423}
424
425/// Cached server-side device configuration model.
426///
427/// Mirrors `GET /api/config`. Sub-sections are `Option` so an explicit
428/// `null` from the server (cache-not-yet-populated) deserializes as
429/// `None` instead of erroring the whole payload out.
430#[derive(Debug, Clone, Serialize, Deserialize, Default)]
431pub struct DeviceConfig {
432    #[serde(default)]
433    pub wifi: Option<DeviceWifiConfig>,
434    #[serde(default)]
435    pub collection: Option<DeviceCollectionConfig>,
436    #[serde(default)]
437    pub csi_config: Option<DeviceCsiConfig>,
438    pub log_mode: Option<String>,
439    pub csi_delivery_mode: Option<String>,
440    pub csi_logging_enabled: Option<bool>,
441}
442
443/// Firmware identity from `GET /api/info`.
444#[derive(Debug, Clone, Serialize, Deserialize, Default)]
445pub struct DeviceInfo {
446    pub banner_version: Option<String>,
447    pub name: Option<String>,
448    pub version: Option<String>,
449    pub chip: Option<String>,
450    pub protocol: Option<u32>,
451    #[serde(default)]
452    pub features: Vec<String>,
453}
454
455/// Runtime status payload from `GET /api/control/status`.
456#[derive(Debug, Clone, Serialize, Deserialize, Default)]
457pub struct ControlStatus {
458    pub serial_connected: Option<bool>,
459    pub collection_running: Option<bool>,
460    pub port_path: Option<String>,
461}
462
463/// Full application state.
464///
465/// This is the single source of truth for all UI-visible data.
466#[derive(Debug, Clone, Default)]
467pub struct AppState {
468    pub persistent: PersistentState,
469    pub transient: TransientUiState,
470    pub runtime: RuntimeState,
471    intent_queue: Vec<UserIntent>,
472}
473
474impl AppState {
475    /// Construct default state with localhost webserver settings.
476    pub fn with_defaults() -> Self {
477        let mut state = Self::default();
478        state.persistent.server_host = "127.0.0.1".to_owned();
479        state.persistent.server_port = "3000".to_owned();
480        state
481    }
482
483    /// Queue one user intent.
484    pub fn push_intent(&mut self, intent: UserIntent) {
485        self.intent_queue.push(intent);
486    }
487
488    /// Drain queued intents in FIFO order.
489    pub fn drain_intents(&mut self) -> Vec<UserIntent> {
490        std::mem::take(&mut self.intent_queue)
491    }
492
493    /// Append one event line to runtime history.
494    pub fn push_event(&mut self, message: impl Into<String>) {
495        self.runtime.events.push(message.into());
496        if self.runtime.events.len() > 300 {
497            let drain_to = self.runtime.events.len() - 300;
498            self.runtime.events.drain(0..drain_to);
499        }
500    }
501
502    /// Record one received frame and update stream counters/history.
503    pub fn push_frame(&mut self, bytes: &[u8]) {
504        self.runtime.frames_received = self.runtime.frames_received.saturating_add(1);
505        self.runtime.bytes_received = self.runtime.bytes_received.saturating_add(bytes.len() as u64);
506
507        let preview = bytes
508            .iter()
509            .take(24)
510            .map(|b| format!("{b:02X}"))
511            .collect::<Vec<_>>()
512            .join(" ");
513
514        self.runtime.recent_frames.push(FrameSummary {
515            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
516            length: bytes.len(),
517            preview_hex: preview,
518        });
519
520        if self.runtime.recent_frames.len() > 300 {
521            let drain_to = self.runtime.recent_frames.len() - 300;
522            self.runtime.recent_frames.drain(0..drain_to);
523        }
524    }
525
526    /// Build HTTP base URL from host/port fields.
527    pub fn base_http_url(&self) -> String {
528        format!(
529            "http://{}:{}",
530            self.persistent.server_host.trim(),
531            self.persistent.server_port.trim()
532        )
533    }
534
535    /// Build WebSocket stream URL from host/port fields.
536    pub fn base_ws_url(&self) -> String {
537        format!(
538            "ws://{}:{}/api/ws",
539            self.persistent.server_host.trim(),
540            self.persistent.server_port.trim()
541        )
542    }
543
544    /// Apply server config payload into local persistent state fields.
545    ///
546    /// Returns the number of fields that were actually applied; callers
547    /// use a zero return to detect an empty server cache.
548    pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
549        let mut applied = 0;
550
551        if let Some(wifi) = config.wifi.as_ref() {
552            if let Some(mode) = wifi.mode.as_deref() {
553                if let Some(parsed) = WiFiMode::from_api_value(mode) {
554                    self.persistent.wifi.mode = parsed;
555                    applied += 1;
556                }
557            }
558            if let Some(channel) = wifi.channel {
559                self.persistent.wifi.channel = channel.to_string();
560                applied += 1;
561            }
562            if let Some(ssid) = &wifi.sta_ssid {
563                self.persistent.wifi.sta_ssid = ssid.clone();
564                applied += 1;
565            }
566            if let Some(mac) = &wifi.peer_mac {
567                // The server reports `auto` for the default (no explicit
568                // filter); show that as an empty editable field.
569                self.persistent.wifi.peer_mac =
570                    if mac == "auto" { String::new() } else { mac.clone() };
571                applied += 1;
572            }
573            if let Some(ht40) = &wifi.ht40 {
574                self.persistent.wifi.ht40 = ht40.clone();
575                applied += 1;
576            }
577        }
578
579        if let Some(collection) = config.collection.as_ref() {
580            if let Some(traffic_hz) = collection.traffic_hz {
581                self.persistent.traffic.frequency_hz = traffic_hz.to_string();
582                applied += 1;
583            }
584            if let Some(mode) = collection.mode.as_deref() {
585                self.persistent.collection_mode = if mode == "listener" {
586                    CollectionMode::Listener
587                } else {
588                    CollectionMode::Collector
589                };
590                applied += 1;
591            }
592            if let Some(rate) = &collection.phy_rate {
593                self.persistent.phy_rate.rate = rate.clone();
594                applied += 1;
595            }
596            if let Some(tx) = collection.io_tx_enabled {
597                self.persistent.io_tasks.tx = tx;
598                applied += 1;
599            }
600            if let Some(rx) = collection.io_rx_enabled {
601                self.persistent.io_tasks.rx = rx;
602                applied += 1;
603            }
604        }
605
606        if let Some(csi_cfg) = config.csi_config.as_ref() {
607            if let Some(v) = csi_cfg.lltf_enabled {
608                self.persistent.csi.disable_lltf = !v;
609                applied += 1;
610            }
611            if let Some(v) = csi_cfg.htltf_enabled {
612                self.persistent.csi.disable_htltf = !v;
613                applied += 1;
614            }
615            if let Some(v) = csi_cfg.stbc_htltf_enabled {
616                self.persistent.csi.disable_stbc_htltf = !v;
617                applied += 1;
618            }
619            if let Some(v) = csi_cfg.ltf_merge_enabled {
620                self.persistent.csi.disable_ltf_merge = !v;
621                applied += 1;
622            }
623            if let Some(v) = csi_cfg.acquire_csi {
624                self.persistent.csi.disable_csi = v == 0;
625                applied += 1;
626            }
627            if let Some(v) = csi_cfg.acquire_csi_legacy {
628                self.persistent.csi.disable_csi_legacy = v == 0;
629                applied += 1;
630            }
631            if let Some(v) = csi_cfg.acquire_csi_ht20 {
632                self.persistent.csi.disable_csi_ht20 = v == 0;
633                applied += 1;
634            }
635            if let Some(v) = csi_cfg.acquire_csi_ht40 {
636                self.persistent.csi.disable_csi_ht40 = v == 0;
637                applied += 1;
638            }
639            if let Some(v) = csi_cfg.acquire_csi_su {
640                self.persistent.csi.disable_csi_su = v == 0;
641                applied += 1;
642            }
643            if let Some(v) = csi_cfg.acquire_csi_mu {
644                self.persistent.csi.disable_csi_mu = v == 0;
645                applied += 1;
646            }
647            if let Some(v) = csi_cfg.acquire_csi_dcm {
648                self.persistent.csi.disable_csi_dcm = v == 0;
649                applied += 1;
650            }
651            if let Some(v) = csi_cfg.acquire_csi_beamformed {
652                self.persistent.csi.disable_csi_beamformed = v == 0;
653                applied += 1;
654            }
655            if let Some(v) = csi_cfg.csi_he_stbc {
656                self.persistent.csi.csi_he_stbc = v.to_string();
657                applied += 1;
658            }
659            if let Some(v) = csi_cfg.val_scale_cfg {
660                self.persistent.csi.val_scale_cfg = v.to_string();
661                applied += 1;
662            }
663        }
664
665        if let Some(mode) = config.log_mode.as_deref() {
666            self.persistent.log_mode = match mode {
667                "text" => LogMode::Text,
668                "serialized" => LogMode::Serialized,
669                "esp-csi-tool" => LogMode::EspCsiTool,
670                _ => LogMode::ArrayList,
671            };
672            applied += 1;
673        }
674
675        if let Some(mode) = config.csi_delivery_mode.as_deref() {
676            self.persistent.csi_delivery.mode = match mode {
677                "off" => CsiDeliveryMode::Off,
678                "callback" => CsiDeliveryMode::Callback,
679                "raw" => CsiDeliveryMode::Raw,
680                _ => CsiDeliveryMode::Async,
681            };
682            applied += 1;
683        }
684        if let Some(logging) = config.csi_logging_enabled {
685            self.persistent.csi_delivery.logging = logging;
686            applied += 1;
687        }
688
689        self.runtime.latest_config = Some(config);
690        applied
691    }
692
693    /// Apply a `/api/control/status` payload to runtime state.
694    pub fn apply_control_status(&mut self, status: ControlStatus) {
695        self.runtime.serial_connected = status.serial_connected;
696        self.runtime.collection_running = status.collection_running;
697        self.runtime.port_path = status.port_path;
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn device_config_parses_full_nested_response() {
707        let json = r#"{
708            "wifi": {
709                "mode": "esp-now-central", "channel": 6, "sta_ssid": "MyNetwork",
710                "peer_mac": "aa:bb:cc:dd:ee:ff", "ht40": "above"
711            },
712            "collection": {
713                "mode": "collector", "traffic_hz": 100, "phy_rate": "mcs0-lgi",
714                "io_tx_enabled": true, "io_rx_enabled": true
715            },
716            "csi_config": {
717                "lltf_enabled": true, "htltf_enabled": true,
718                "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
719                "csi_he_stbc": 2, "val_scale_cfg": 2,
720                "acquire_csi": 1, "acquire_csi_legacy": 0
721            },
722            "log_mode": "array-list",
723            "csi_delivery_mode": "raw",
724            "csi_logging_enabled": true
725        }"#;
726        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
727        let mut state = AppState::with_defaults();
728        let applied = state.apply_device_config(cfg);
729        assert!(applied > 0);
730        assert_eq!(state.persistent.wifi.mode, WiFiMode::EspNowCentral);
731        assert_eq!(state.persistent.wifi.channel, "6");
732        assert_eq!(state.persistent.wifi.peer_mac, "aa:bb:cc:dd:ee:ff");
733        assert_eq!(state.persistent.wifi.ht40, "above");
734        assert_eq!(state.persistent.traffic.frequency_hz, "100");
735        assert!(!state.persistent.csi.disable_csi);
736        assert!(state.persistent.csi.disable_csi_legacy);
737        assert_eq!(state.persistent.csi_delivery.mode, CsiDeliveryMode::Raw);
738    }
739
740    #[test]
741    fn device_config_maps_auto_peer_mac_to_empty_field() {
742        let json = r#"{ "wifi": { "mode": "sniffer", "peer_mac": "auto", "ht40": "none" } }"#;
743        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
744        let mut state = AppState::with_defaults();
745        state.apply_device_config(cfg);
746        assert_eq!(state.persistent.wifi.peer_mac, "");
747        assert_eq!(state.persistent.wifi.ht40, "none");
748    }
749
750    #[test]
751    fn device_config_tolerates_null_sub_objects() {
752        let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
753        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
754        let mut state = AppState::with_defaults();
755        assert_eq!(state.apply_device_config(cfg), 0);
756    }
757
758    #[test]
759    fn device_config_tolerates_missing_sub_objects() {
760        let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
761        let mut state = AppState::with_defaults();
762        assert_eq!(state.apply_device_config(cfg), 0);
763    }
764}