Skip to main content

csi_webclient/
app.rs

1use crate::core::CoreHandle;
2use crate::core::messages::{ApiRequest, CoreCommand, CoreEvent, HttpMethod};
3use crate::state::{
4    AppState, ControlStatus, DeviceConfig, DeviceInfo, Tab, UserIntent, WiFiForm,
5};
6use crate::ui;
7use eframe::egui;
8use serde_json::{Value, json};
9
10const STA_FIELD_MAX_BYTES: usize = 32;
11
12/// Top-level egui application.
13///
14/// This type orchestrates the intent-command-event flow:
15///
16/// - reads and drains user intents from [`crate::state::AppState`]
17/// - submits commands to [`crate::core::CoreHandle`]
18/// - applies resulting core events back into state
19pub struct CsiClientApp {
20    state: AppState,
21    core: CoreHandle,
22}
23
24impl CsiClientApp {
25    /// Create a new app instance with default state and a running core worker.
26    pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
27        Self {
28            state: AppState::with_defaults(),
29            core: CoreHandle::new(),
30        }
31    }
32
33    /// Drain queued user intents and translate them into core commands.
34    fn process_intents(&mut self) {
35        for intent in self.state.drain_intents() {
36            match intent {
37                UserIntent::FetchConfig => self.submit_get("fetch_config", "/api/config"),
38                UserIntent::FetchInfo => self.submit_get("fetch_info", "/api/info"),
39                UserIntent::FetchStatus => {
40                    self.submit_get("fetch_status", "/api/control/status");
41                }
42                UserIntent::ResetConfig => self.submit_post("reset_config", "/api/config/reset", None),
43                UserIntent::SetWifi(wifi) => self.submit_set_wifi(wifi),
44                UserIntent::SetTraffic(traffic) => {
45                    if let Some(frequency_hz) = parse_required_u64(&traffic.frequency_hz) {
46                        self.submit_post(
47                            "set_traffic",
48                            "/api/config/traffic",
49                            Some(json!({ "frequency_hz": frequency_hz })),
50                        );
51                    } else {
52                        self.set_error("Traffic frequency must be a non-negative integer");
53                    }
54                }
55                UserIntent::SetCsi(csi) => {
56                    let csi_he_stbc = parse_required_u32(&csi.csi_he_stbc);
57                    let val_scale_cfg = parse_required_u32(&csi.val_scale_cfg);
58
59                    if let (Some(csi_he_stbc), Some(val_scale_cfg)) = (csi_he_stbc, val_scale_cfg) {
60                        self.submit_post(
61                            "set_csi",
62                            "/api/config/csi",
63                            Some(json!({
64                                "disable_lltf": csi.disable_lltf,
65                                "disable_htltf": csi.disable_htltf,
66                                "disable_stbc_htltf": csi.disable_stbc_htltf,
67                                "disable_ltf_merge": csi.disable_ltf_merge,
68                                "disable_csi": csi.disable_csi,
69                                "disable_csi_legacy": csi.disable_csi_legacy,
70                                "disable_csi_ht20": csi.disable_csi_ht20,
71                                "disable_csi_ht40": csi.disable_csi_ht40,
72                                "disable_csi_su": csi.disable_csi_su,
73                                "disable_csi_mu": csi.disable_csi_mu,
74                                "disable_csi_dcm": csi.disable_csi_dcm,
75                                "disable_csi_beamformed": csi.disable_csi_beamformed,
76                                "csi_he_stbc": csi_he_stbc,
77                                "val_scale_cfg": val_scale_cfg,
78                            })),
79                        );
80                    } else {
81                        self.set_error("csi_he_stbc and val_scale_cfg must be valid u32 numbers");
82                    }
83                }
84                UserIntent::SetCollectionMode(mode) => {
85                    self.submit_post(
86                        "set_collection_mode",
87                        "/api/config/collection-mode",
88                        Some(json!({ "mode": mode.as_api_value() })),
89                    );
90                }
91                UserIntent::SetLogMode(mode) => {
92                    self.submit_post(
93                        "set_log_mode",
94                        "/api/config/log-mode",
95                        Some(json!({ "mode": mode.as_api_value() })),
96                    );
97                }
98                UserIntent::SetOutputMode(mode) => {
99                    self.submit_post(
100                        "set_output_mode",
101                        "/api/config/output-mode",
102                        Some(json!({ "mode": mode.as_api_value() })),
103                    );
104                }
105                UserIntent::SetPhyRate(form) => {
106                    let rate = form.rate.trim().to_owned();
107                    if rate.is_empty() {
108                        self.set_error("PHY rate must not be empty");
109                    } else {
110                        self.submit_post(
111                            "set_rate",
112                            "/api/config/rate",
113                            Some(json!({ "rate": rate })),
114                        );
115                    }
116                }
117                UserIntent::SetIoTasks(form) => {
118                    self.submit_post(
119                        "set_io_tasks",
120                        "/api/config/io-tasks",
121                        Some(json!({ "tx": form.tx, "rx": form.rx })),
122                    );
123                }
124                UserIntent::SetCsiDelivery(form) => {
125                    self.submit_post(
126                        "set_csi_delivery",
127                        "/api/config/csi-delivery",
128                        Some(json!({
129                            "mode": form.mode.as_api_value(),
130                            "logging": form.logging,
131                        })),
132                    );
133                }
134                UserIntent::StartCollection { duration_seconds } => {
135                    let duration = parse_optional_u64(&duration_seconds);
136                    if duration_seconds.trim().is_empty() || duration.is_some() {
137                        self.submit_post(
138                            "start_collection",
139                            "/api/control/start",
140                            duration.map(|d| json!({ "duration": d })),
141                        );
142                    } else {
143                        self.set_error("Duration must be a valid number of seconds");
144                    }
145                }
146                UserIntent::StopCollection => {
147                    self.submit_post("stop_collection", "/api/control/stop", None);
148                }
149                UserIntent::ShowStats => {
150                    self.submit_post("show_stats", "/api/control/stats", None);
151                }
152                UserIntent::ResetDevice => {
153                    self.submit_post("reset_device", "/api/control/reset", None);
154                }
155                UserIntent::ConnectWebSocket => {
156                    self.core.submit(CoreCommand::ConnectWebSocket {
157                        url: self.state.base_ws_url(),
158                    });
159                }
160                UserIntent::DisconnectWebSocket => {
161                    self.core.submit(CoreCommand::DisconnectWebSocket);
162                }
163                UserIntent::ClearFrames => {
164                    self.state.runtime.recent_frames.clear();
165                    self.state.runtime.frames_received = 0;
166                    self.state.runtime.bytes_received = 0;
167                }
168            }
169        }
170    }
171
172    fn submit_get(&self, label: &str, path: &str) {
173        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
174            label: label.to_owned(),
175            method: HttpMethod::Get,
176            base_url: self.state.base_http_url(),
177            path: path.to_owned(),
178            body: None,
179        }));
180    }
181
182    fn submit_post(&self, label: &str, path: &str, body: Option<Value>) {
183        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
184            label: label.to_owned(),
185            method: HttpMethod::Post,
186            base_url: self.state.base_http_url(),
187            path: path.to_owned(),
188            body,
189        }));
190    }
191
192    fn set_error(&mut self, message: impl Into<String>) {
193        self.state.transient.error_message = message.into();
194    }
195
196    fn submit_set_wifi(&mut self, wifi: WiFiForm) {
197        if let Err(message) = validate_sta_field("STA SSID", &wifi.sta_ssid) {
198            self.set_error(message);
199            return;
200        }
201        if let Err(message) = validate_sta_field("STA password", &wifi.sta_password) {
202            self.set_error(message);
203            return;
204        }
205
206        let channel = parse_optional_u16(&wifi.channel);
207        if !wifi.channel.trim().is_empty() && channel.is_none() {
208            self.set_error("Wi-Fi channel must be a valid number");
209            return;
210        }
211
212        if let Err(message) = validate_peer_mac(&wifi.peer_mac) {
213            self.set_error(message);
214            return;
215        }
216
217        // `peer_mac` is only meaningful for ESP-NOW modes. Send it (even empty,
218        // to clear back to auto) only for those modes so the server cache for
219        // station/sniffer isn't churned.
220        let esp_now = matches!(
221            wifi.mode,
222            crate::state::WiFiMode::EspNowCentral | crate::state::WiFiMode::EspNowPeripheral
223        );
224
225        self.submit_post(
226            "set_wifi",
227            "/api/config/wifi",
228            Some(json!({
229                "mode": wifi.mode.as_api_value(),
230                "sta_ssid": empty_to_none(&wifi.sta_ssid),
231                "sta_password": empty_to_none(&wifi.sta_password),
232                "channel": channel,
233                "peer_mac": esp_now.then(|| wifi.peer_mac.trim().to_owned()),
234                "ht40": esp_now.then(|| wifi.ht40.clone()),
235            })),
236        );
237    }
238
239    /// Poll and apply core worker events without blocking the frame loop.
240    fn process_core_events(&mut self) {
241        while let Some(event) = self.core.try_recv() {
242            match event {
243                CoreEvent::ApiResponse(response) => {
244                    self.state.runtime.last_http_status = Some(response.status);
245
246                    if response.success {
247                        self.state.transient.status_message = format!(
248                            "{} (HTTP {}): {}",
249                            response.label, response.status, response.message
250                        );
251                        self.state.transient.error_message.clear();
252                    } else {
253                        self.state.transient.error_message = format_error(
254                            &response.label,
255                            response.status,
256                            &response.message,
257                        );
258                    }
259
260                    self.state.push_event(format!(
261                        "{} -> HTTP {}: {}",
262                        response.label, response.status, response.message
263                    ));
264
265                    if response.success {
266                        match response.label.as_str() {
267                            "fetch_config" => {
268                                if let Some(config) =
269                                    response.data.and_then(parse_envelope::<DeviceConfig>)
270                                {
271                                    let applied = self.state.apply_device_config(config);
272                                    if applied == 0 && !self.state.runtime.auto_resetting_cache {
273                                        self.state.runtime.auto_resetting_cache = true;
274                                        self.state.push_intent(UserIntent::ResetConfig);
275                                    } else {
276                                        self.state.runtime.auto_resetting_cache = false;
277                                    }
278                                } else {
279                                    self.state.runtime.auto_resetting_cache = false;
280                                }
281                            }
282                            "fetch_info" => {
283                                if let Some(info) =
284                                    response.data.and_then(parse_envelope::<DeviceInfo>)
285                                {
286                                    self.state.runtime.firmware_verified = Some(true);
287                                    self.state.runtime.latest_info = Some(info);
288                                }
289                            }
290                            "fetch_status" => {
291                                if let Some(status) =
292                                    response.data.and_then(parse_envelope::<ControlStatus>)
293                                {
294                                    self.state.apply_control_status(status);
295                                }
296                            }
297                            "start_collection" => {
298                                self.state.runtime.collection_running = Some(true);
299                            }
300                            "stop_collection" => {
301                                self.state.runtime.collection_running = Some(false);
302                            }
303                            "reset_device" => {
304                                self.state.runtime.collection_running = Some(false);
305                                self.state.runtime.firmware_verified = None;
306                                self.state.runtime.latest_info = None;
307                            }
308                            // Any successful config-mutating POST repopulates a slot in the
309                            // server cache, so re-pull `/api/config` to keep the form in sync.
310                            "reset_config"
311                            | "set_wifi"
312                            | "set_traffic"
313                            | "set_csi"
314                            | "set_collection_mode"
315                            | "set_log_mode"
316                            | "set_rate"
317                            | "set_io_tasks"
318                            | "set_csi_delivery" => {
319                                self.state.push_intent(UserIntent::FetchConfig);
320                            }
321                            _ => {}
322                        }
323                    } else if response.label == "fetch_info" && response.status != 0 {
324                        self.state.runtime.firmware_verified = Some(false);
325                    } else if response.label == "reset_config" {
326                        self.state.runtime.auto_resetting_cache = false;
327                    }
328                }
329                CoreEvent::WebSocketConnected => {
330                    self.state.runtime.ws_connected = true;
331                    self.state.transient.status_message = "WebSocket connected".to_owned();
332                    self.state.transient.error_message.clear();
333                    self.state.push_event("WebSocket connected");
334                }
335                CoreEvent::WebSocketDisconnected { reason } => {
336                    self.state.runtime.ws_connected = false;
337                    self.state.push_event(format!("WebSocket disconnected: {reason}"));
338                }
339                CoreEvent::WebSocketFrame(bytes) => {
340                    self.state.push_frame(&bytes);
341                }
342                CoreEvent::Log(line) => {
343                    self.state.push_event(line);
344                }
345            }
346        }
347    }
348
349    /// Render the shared top panel (host/port fields, tabs, status and errors).
350    fn render_top_bar(&mut self, ctx: &egui::Context) {
351        egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
352            ui.horizontal_wrapped(|ui| {
353                ui.label("Host");
354                ui.add(
355                    egui::TextEdit::singleline(&mut self.state.persistent.server_host)
356                        .desired_width(140.0),
357                );
358                ui.label("Port");
359                ui.add(
360                    egui::TextEdit::singleline(&mut self.state.persistent.server_port)
361                        .desired_width(60.0),
362                );
363                if ui.button("Fetch Info").clicked() {
364                    self.state.push_intent(UserIntent::FetchInfo);
365                }
366                if ui.button("Fetch Config").clicked() {
367                    self.state.push_intent(UserIntent::FetchConfig);
368                }
369                if ui.button("Fetch Status").clicked() {
370                    self.state.push_intent(UserIntent::FetchStatus);
371                }
372            });
373
374            ui.horizontal_wrapped(|ui| {
375                tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
376                tab_button(ui, &mut self.state, Tab::Config, "Config");
377                tab_button(ui, &mut self.state, Tab::Control, "Control");
378                tab_button(ui, &mut self.state, Tab::Stream, "Stream");
379            });
380
381            if !self.state.transient.status_message.is_empty() {
382                ui.add(
383                    egui::Label::new(format!("Status: {}", self.state.transient.status_message))
384                        .wrap(),
385                );
386            }
387
388            if !self.state.transient.error_message.is_empty() {
389                ui.add(
390                    egui::Label::new(
391                        egui::RichText::new(format!(
392                            "Error: {}",
393                            self.state.transient.error_message
394                        ))
395                        .color(egui::Color32::from_rgb(220, 80, 80)),
396                    )
397                    .wrap(),
398                );
399            }
400        });
401    }
402}
403
404impl eframe::App for CsiClientApp {
405    /// Main egui frame update callback.
406    ///
407    /// The update order is:
408    /// 1. apply incoming core events
409    /// 2. process queued user intents
410    /// 3. render UI panels
411    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
412        self.process_core_events();
413        self.process_intents();
414
415        self.render_top_bar(ctx);
416
417        egui::CentralPanel::default().show(ctx, |ui| match self.state.transient.active_tab {
418            Tab::Dashboard => ui::dashboard::render(ui, &mut self.state),
419            Tab::Config => ui::config::render(ui, &mut self.state),
420            Tab::Control => ui::control::render(ui, &mut self.state),
421            Tab::Stream => ui::stream::render(ui, &mut self.state),
422        });
423
424        ctx.request_repaint_after(std::time::Duration::from_millis(16));
425    }
426}
427
428/// Parse a typed payload from a direct value or the standard `data` envelope.
429fn parse_envelope<T: serde::de::DeserializeOwned>(data: serde_json::Value) -> Option<T> {
430    if let Ok(value) = serde_json::from_value::<T>(data.clone()) {
431        return Some(value);
432    }
433    if let Some(inner) = data.get("data") {
434        return serde_json::from_value::<T>(inner.clone()).ok();
435    }
436    None
437}
438
439/// Reject SSID/password values the firmware tokenizer cannot accept.
440///
441/// Mirrors the server-side rules from §1.4 of the webserver spec so users
442/// see the failure inline rather than as a 400 round-trip.
443fn validate_sta_field(label: &str, value: &str) -> Result<(), String> {
444    if value.is_empty() {
445        return Ok(());
446    }
447    if value.len() > STA_FIELD_MAX_BYTES {
448        return Err(format!("{label} exceeds 32-byte firmware limit"));
449    }
450    if value.contains('\r') || value.contains('\n') {
451        return Err(format!("{label} must not contain newlines"));
452    }
453    if value.contains('\'') && value.contains('"') {
454        return Err(format!(
455            "{label} cannot contain both ' and \" — firmware tokenizer cannot disambiguate"
456        ));
457    }
458    Ok(())
459}
460
461/// Reject malformed ESP-NOW peer MACs before the round-trip. Mirrors the
462/// firmware rule: six hex octets joined by `:` or `-`. Empty is valid and
463/// means "clear back to auto".
464fn validate_peer_mac(mac: &str) -> Result<(), String> {
465    let mac = mac.trim();
466    if mac.is_empty() {
467        return Ok(());
468    }
469    let sep = if mac.contains(':') {
470        ':'
471    } else if mac.contains('-') {
472        '-'
473    } else {
474        return Err("Peer MAC must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_owned());
475    };
476    let octets: Vec<&str> = mac.split(sep).collect();
477    let valid = octets.len() == 6
478        && octets
479            .iter()
480            .all(|o| o.len() == 2 && o.bytes().all(|b| b.is_ascii_hexdigit()));
481    if valid {
482        Ok(())
483    } else {
484        Err("Peer MAC must be aa:bb:cc:dd:ee:ff".to_owned())
485    }
486}
487
488/// Map known status codes onto operator-friendly hints.
489fn format_error(label: &str, status: u16, message: &str) -> String {
490    let hint = match status {
491        412 => Some("firmware not verified — try Fetch Info or Reset Device"),
492        503 => Some("ESP32 not connected, or operation not valid for current state"),
493        502 => Some("device responded but the info block was malformed"),
494        504 => Some("info block timed out — firmware may not be esp-csi-cli-rs"),
495        403 => Some("output mode is dump — switch to stream/both before opening WebSocket"),
496        _ => None,
497    };
498    match hint {
499        Some(h) => format!("{label} failed (HTTP {status}): {message} — {h}"),
500        None => format!("{label} failed (HTTP {status}): {message}"),
501    }
502}
503
504/// Parse an optional `u16` where empty input means `None`.
505fn parse_optional_u16(input: &str) -> Option<u16> {
506    let trimmed = input.trim();
507    if trimmed.is_empty() {
508        return None;
509    }
510    trimmed.parse::<u16>().ok()
511}
512
513/// Parse a required `u64`.
514fn parse_required_u64(input: &str) -> Option<u64> {
515    input.trim().parse::<u64>().ok()
516}
517
518/// Parse a required `u32`.
519fn parse_required_u32(input: &str) -> Option<u32> {
520    input.trim().parse::<u32>().ok()
521}
522
523/// Parse an optional `u64` where empty input means `None`.
524fn parse_optional_u64(input: &str) -> Option<u64> {
525    let trimmed = input.trim();
526    if trimmed.is_empty() {
527        return None;
528    }
529    trimmed.parse::<u64>().ok()
530}
531
532/// Convert user text to optional string while preserving significant whitespace.
533fn empty_to_none(input: &str) -> Option<String> {
534    if input.trim().is_empty() {
535        None
536    } else {
537        Some(input.to_owned())
538    }
539}
540
541/// Render one tab selector button and switch active tab on click.
542fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
543    let selected = state.transient.active_tab == tab;
544    if ui.selectable_label(selected, label).clicked() {
545        state.transient.active_tab = tab;
546    }
547}