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