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 supported by the backend API.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WiFiMode {
16    Sta,
17    Monitor,
18    Sniffer,
19}
20
21impl WiFiMode {
22    /// Convert enum variant to backend API value.
23    pub fn as_api_value(self) -> &'static str {
24        match self {
25            Self::Sta => "sta",
26            Self::Monitor => "monitor",
27            Self::Sniffer => "sniffer",
28        }
29    }
30}
31
32impl Default for WiFiMode {
33    fn default() -> Self {
34        Self::Sta
35    }
36}
37
38/// Collection role for the ESP32 firmware session.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum CollectionMode {
41    Collector,
42    Listener,
43}
44
45impl CollectionMode {
46    /// Convert enum variant to backend API value.
47    pub fn as_api_value(self) -> &'static str {
48        match self {
49            Self::Collector => "collector",
50            Self::Listener => "listener",
51        }
52    }
53}
54
55impl Default for CollectionMode {
56    fn default() -> Self {
57        Self::Collector
58    }
59}
60
61/// Serial framing/log mode accepted by `POST /api/config/log-mode`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum LogMode {
64    Text,
65    ArrayList,
66    Serialized,
67}
68
69impl LogMode {
70    /// Convert enum variant to backend API value.
71    pub fn as_api_value(self) -> &'static str {
72        match self {
73            Self::Text => "text",
74            Self::ArrayList => "array-list",
75            Self::Serialized => "serialized",
76        }
77    }
78}
79
80impl Default for LogMode {
81    fn default() -> Self {
82        Self::ArrayList
83    }
84}
85
86/// Output routing mode for CSI frames.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum OutputMode {
89    Stream,
90    Dump,
91    Both,
92}
93
94impl OutputMode {
95    /// Convert enum variant to backend API value.
96    pub fn as_api_value(self) -> &'static str {
97        match self {
98            Self::Stream => "stream",
99            Self::Dump => "dump",
100            Self::Both => "both",
101        }
102    }
103}
104
105impl Default for OutputMode {
106    fn default() -> Self {
107        Self::Stream
108    }
109}
110
111/// Editable Wi-Fi form values in the Config view.
112#[derive(Debug, Clone, Default)]
113pub struct WiFiForm {
114    pub mode: WiFiMode,
115    pub sta_ssid: String,
116    pub sta_password: String,
117    pub channel: String,
118}
119
120/// Editable traffic configuration form values.
121#[derive(Debug, Clone)]
122pub struct TrafficForm {
123    pub frequency_hz: String,
124}
125
126impl Default for TrafficForm {
127    fn default() -> Self {
128        Self {
129            frequency_hz: "100".to_owned(),
130        }
131    }
132}
133
134/// Editable CSI feature flags and numeric values.
135#[derive(Debug, Clone)]
136pub struct CsiForm {
137    pub disable_lltf: bool,
138    pub disable_htltf: bool,
139    pub disable_stbc_htltf: bool,
140    pub disable_ltf_merge: bool,
141    pub disable_csi: bool,
142    pub disable_csi_legacy: bool,
143    pub disable_csi_ht20: bool,
144    pub disable_csi_ht40: bool,
145    pub disable_csi_su: bool,
146    pub disable_csi_mu: bool,
147    pub disable_csi_dcm: bool,
148    pub disable_csi_beamformed: bool,
149    pub csi_he_stbc: String,
150    pub val_scale_cfg: String,
151}
152
153impl Default for CsiForm {
154    fn default() -> Self {
155        Self {
156            disable_lltf: false,
157            disable_htltf: false,
158            disable_stbc_htltf: false,
159            disable_ltf_merge: false,
160            disable_csi: false,
161            disable_csi_legacy: false,
162            disable_csi_ht20: false,
163            disable_csi_ht40: false,
164            disable_csi_su: false,
165            disable_csi_mu: false,
166            disable_csi_dcm: false,
167            disable_csi_beamformed: false,
168            csi_he_stbc: "0".to_owned(),
169            val_scale_cfg: "0".to_owned(),
170        }
171    }
172}
173
174/// User/session-level state persisted during app runtime.
175#[derive(Debug, Clone, Default)]
176pub struct PersistentState {
177    pub server_host: String,
178    pub server_port: String,
179    pub wifi: WiFiForm,
180    pub traffic: TrafficForm,
181    pub csi: CsiForm,
182    pub collection_mode: CollectionMode,
183    pub log_mode: LogMode,
184    pub output_mode: OutputMode,
185    pub start_duration_seconds: String,
186}
187
188/// Ephemeral UI state that is not part of backend/device config.
189#[derive(Debug, Clone)]
190pub struct TransientUiState {
191    pub active_tab: Tab,
192    pub status_message: String,
193    pub error_message: String,
194    pub auto_scroll_stream: bool,
195}
196
197impl Default for TransientUiState {
198    fn default() -> Self {
199        Self {
200            active_tab: Tab::Dashboard,
201            status_message: "Ready".to_owned(),
202            error_message: String::new(),
203            auto_scroll_stream: true,
204        }
205    }
206}
207
208/// Lightweight frame metadata shown in the Stream tab.
209#[derive(Debug, Clone, Default)]
210pub struct FrameSummary {
211    pub timestamp: String,
212    pub length: usize,
213    pub preview_hex: String,
214}
215
216/// Runtime status produced by background IO work.
217#[derive(Debug, Clone, Default)]
218pub struct RuntimeState {
219    pub ws_connected: bool,
220    /// Client-estimated collection session state derived from control API responses.
221    pub collection_active_estimate: bool,
222    pub frames_received: u64,
223    pub bytes_received: u64,
224    pub recent_frames: Vec<FrameSummary>,
225    pub events: Vec<String>,
226    pub last_http_status: Option<u16>,
227    pub latest_config: Option<DeviceConfig>,
228}
229
230/// High-level user actions queued by the UI for orchestration.
231#[derive(Debug, Clone)]
232pub enum UserIntent {
233    FetchConfig,
234    ResetConfig,
235    SetWifi(WiFiForm),
236    SetTraffic(TrafficForm),
237    SetCsi(CsiForm),
238    SetCollectionMode(CollectionMode),
239    SetLogMode(LogMode),
240    SetOutputMode(OutputMode),
241    StartCollection { duration_seconds: String },
242    ResetDevice,
243    ConnectWebSocket,
244    DisconnectWebSocket,
245    ClearFrames,
246}
247
248/// Cached server-side device configuration model.
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250pub struct DeviceConfig {
251    pub wifi_mode: Option<String>,
252    pub channel: Option<u16>,
253    pub sta_ssid: Option<String>,
254    pub traffic_hz: Option<u16>,
255    pub collection_mode: Option<String>,
256    pub log_mode: Option<String>,
257    pub log_format: Option<String>,
258}
259
260/// Full application state.
261///
262/// This is the single source of truth for all UI-visible data.
263#[derive(Debug, Clone, Default)]
264pub struct AppState {
265    pub persistent: PersistentState,
266    pub transient: TransientUiState,
267    pub runtime: RuntimeState,
268    intent_queue: Vec<UserIntent>,
269}
270
271impl AppState {
272    /// Construct default state with localhost webserver settings.
273    pub fn with_defaults() -> Self {
274        let mut state = Self::default();
275        state.persistent.server_host = "127.0.0.1".to_owned();
276        state.persistent.server_port = "3000".to_owned();
277        state
278    }
279
280    /// Queue one user intent.
281    pub fn push_intent(&mut self, intent: UserIntent) {
282        self.intent_queue.push(intent);
283    }
284
285    /// Drain queued intents in FIFO order.
286    pub fn drain_intents(&mut self) -> Vec<UserIntent> {
287        std::mem::take(&mut self.intent_queue)
288    }
289
290    /// Append one event line to runtime history.
291    pub fn push_event(&mut self, message: impl Into<String>) {
292        self.runtime.events.push(message.into());
293        if self.runtime.events.len() > 300 {
294            let drain_to = self.runtime.events.len() - 300;
295            self.runtime.events.drain(0..drain_to);
296        }
297    }
298
299    /// Record one received frame and update stream counters/history.
300    pub fn push_frame(&mut self, bytes: &[u8]) {
301        self.runtime.frames_received = self.runtime.frames_received.saturating_add(1);
302        self.runtime.bytes_received = self.runtime.bytes_received.saturating_add(bytes.len() as u64);
303
304        let preview = bytes
305            .iter()
306            .take(24)
307            .map(|b| format!("{b:02X}"))
308            .collect::<Vec<_>>()
309            .join(" ");
310
311        self.runtime.recent_frames.push(FrameSummary {
312            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
313            length: bytes.len(),
314            preview_hex: preview,
315        });
316
317        if self.runtime.recent_frames.len() > 300 {
318            let drain_to = self.runtime.recent_frames.len() - 300;
319            self.runtime.recent_frames.drain(0..drain_to);
320        }
321    }
322
323    /// Build HTTP base URL from host/port fields.
324    pub fn base_http_url(&self) -> String {
325        format!(
326            "http://{}:{}",
327            self.persistent.server_host.trim(),
328            self.persistent.server_port.trim()
329        )
330    }
331
332    /// Build WebSocket stream URL from host/port fields.
333    pub fn base_ws_url(&self) -> String {
334        format!(
335            "ws://{}:{}/api/ws",
336            self.persistent.server_host.trim(),
337            self.persistent.server_port.trim()
338        )
339    }
340
341    /// Apply server config payload into local persistent state fields.
342    pub fn apply_device_config(&mut self, config: DeviceConfig) {
343        if let Some(mode) = config.wifi_mode.as_deref() {
344            self.persistent.wifi.mode = match mode {
345                "monitor" => WiFiMode::Monitor,
346                "sniffer" => WiFiMode::Sniffer,
347                _ => WiFiMode::Sta,
348            };
349        }
350
351        if let Some(channel) = config.channel {
352            self.persistent.wifi.channel = channel.to_string();
353        }
354
355        if let Some(ssid) = &config.sta_ssid {
356            self.persistent.wifi.sta_ssid = ssid.clone();
357        }
358
359        if let Some(traffic_hz) = config.traffic_hz {
360            self.persistent.traffic.frequency_hz = traffic_hz.to_string();
361        }
362
363        if let Some(mode) = config.collection_mode.as_deref() {
364            self.persistent.collection_mode = if mode == "listener" {
365                CollectionMode::Listener
366            } else {
367                CollectionMode::Collector
368            };
369        }
370
371        if let Some(mode) = config.log_mode.as_deref().or(config.log_format.as_deref()) {
372            self.persistent.log_mode = match mode {
373                "text" => LogMode::Text,
374                // Backward compatibility for older backend values.
375                "cobs" | "serialized" => LogMode::Serialized,
376                _ => LogMode::ArrayList,
377            };
378        }
379
380        self.runtime.latest_config = Some(config);
381    }
382}