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        self.submit_post(
213            "set_wifi",
214            "/api/config/wifi",
215            Some(json!({
216                "mode": wifi.mode.as_api_value(),
217                "sta_ssid": empty_to_none(&wifi.sta_ssid),
218                "sta_password": empty_to_none(&wifi.sta_password),
219                "channel": channel,
220            })),
221        );
222    }
223
224    /// Poll and apply core worker events without blocking the frame loop.
225    fn process_core_events(&mut self) {
226        while let Some(event) = self.core.try_recv() {
227            match event {
228                CoreEvent::ApiResponse(response) => {
229                    self.state.runtime.last_http_status = Some(response.status);
230
231                    if response.success {
232                        self.state.transient.status_message = format!(
233                            "{} (HTTP {}): {}",
234                            response.label, response.status, response.message
235                        );
236                        self.state.transient.error_message.clear();
237                    } else {
238                        self.state.transient.error_message = format_error(
239                            &response.label,
240                            response.status,
241                            &response.message,
242                        );
243                    }
244
245                    self.state.push_event(format!(
246                        "{} -> HTTP {}: {}",
247                        response.label, response.status, response.message
248                    ));
249
250                    if response.success {
251                        match response.label.as_str() {
252                            "fetch_config" => {
253                                if let Some(config) =
254                                    response.data.and_then(parse_envelope::<DeviceConfig>)
255                                {
256                                    let applied = self.state.apply_device_config(config);
257                                    if applied == 0 && !self.state.runtime.auto_resetting_cache {
258                                        self.state.runtime.auto_resetting_cache = true;
259                                        self.state.push_intent(UserIntent::ResetConfig);
260                                    } else {
261                                        self.state.runtime.auto_resetting_cache = false;
262                                    }
263                                } else {
264                                    self.state.runtime.auto_resetting_cache = false;
265                                }
266                            }
267                            "fetch_info" => {
268                                if let Some(info) =
269                                    response.data.and_then(parse_envelope::<DeviceInfo>)
270                                {
271                                    self.state.runtime.firmware_verified = Some(true);
272                                    self.state.runtime.latest_info = Some(info);
273                                }
274                            }
275                            "fetch_status" => {
276                                if let Some(status) =
277                                    response.data.and_then(parse_envelope::<ControlStatus>)
278                                {
279                                    self.state.apply_control_status(status);
280                                }
281                            }
282                            "start_collection" => {
283                                self.state.runtime.collection_running = Some(true);
284                            }
285                            "stop_collection" => {
286                                self.state.runtime.collection_running = Some(false);
287                            }
288                            "reset_device" => {
289                                self.state.runtime.collection_running = Some(false);
290                                self.state.runtime.firmware_verified = None;
291                                self.state.runtime.latest_info = None;
292                            }
293                            // Any successful config-mutating POST repopulates a slot in the
294                            // server cache, so re-pull `/api/config` to keep the form in sync.
295                            "reset_config"
296                            | "set_wifi"
297                            | "set_traffic"
298                            | "set_csi"
299                            | "set_collection_mode"
300                            | "set_log_mode"
301                            | "set_rate"
302                            | "set_io_tasks"
303                            | "set_csi_delivery" => {
304                                self.state.push_intent(UserIntent::FetchConfig);
305                            }
306                            _ => {}
307                        }
308                    } else if response.label == "fetch_info" && response.status != 0 {
309                        self.state.runtime.firmware_verified = Some(false);
310                    } else if response.label == "reset_config" {
311                        self.state.runtime.auto_resetting_cache = false;
312                    }
313                }
314                CoreEvent::WebSocketConnected => {
315                    self.state.runtime.ws_connected = true;
316                    self.state.transient.status_message = "WebSocket connected".to_owned();
317                    self.state.transient.error_message.clear();
318                    self.state.push_event("WebSocket connected");
319                }
320                CoreEvent::WebSocketDisconnected { reason } => {
321                    self.state.runtime.ws_connected = false;
322                    self.state.push_event(format!("WebSocket disconnected: {reason}"));
323                }
324                CoreEvent::WebSocketFrame(bytes) => {
325                    self.state.push_frame(&bytes);
326                }
327                CoreEvent::Log(line) => {
328                    self.state.push_event(line);
329                }
330            }
331        }
332    }
333
334    /// Render the shared top panel (host/port fields, tabs, status and errors).
335    fn render_top_bar(&mut self, ctx: &egui::Context) {
336        egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
337            ui.horizontal_wrapped(|ui| {
338                ui.label("Host");
339                ui.add(
340                    egui::TextEdit::singleline(&mut self.state.persistent.server_host)
341                        .desired_width(140.0),
342                );
343                ui.label("Port");
344                ui.add(
345                    egui::TextEdit::singleline(&mut self.state.persistent.server_port)
346                        .desired_width(60.0),
347                );
348                if ui.button("Fetch Info").clicked() {
349                    self.state.push_intent(UserIntent::FetchInfo);
350                }
351                if ui.button("Fetch Config").clicked() {
352                    self.state.push_intent(UserIntent::FetchConfig);
353                }
354                if ui.button("Fetch Status").clicked() {
355                    self.state.push_intent(UserIntent::FetchStatus);
356                }
357            });
358
359            ui.horizontal_wrapped(|ui| {
360                tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
361                tab_button(ui, &mut self.state, Tab::Config, "Config");
362                tab_button(ui, &mut self.state, Tab::Control, "Control");
363                tab_button(ui, &mut self.state, Tab::Stream, "Stream");
364            });
365
366            if !self.state.transient.status_message.is_empty() {
367                ui.add(
368                    egui::Label::new(format!("Status: {}", self.state.transient.status_message))
369                        .wrap(),
370                );
371            }
372
373            if !self.state.transient.error_message.is_empty() {
374                ui.add(
375                    egui::Label::new(
376                        egui::RichText::new(format!(
377                            "Error: {}",
378                            self.state.transient.error_message
379                        ))
380                        .color(egui::Color32::from_rgb(220, 80, 80)),
381                    )
382                    .wrap(),
383                );
384            }
385        });
386    }
387}
388
389impl eframe::App for CsiClientApp {
390    /// Main egui frame update callback.
391    ///
392    /// The update order is:
393    /// 1. apply incoming core events
394    /// 2. process queued user intents
395    /// 3. render UI panels
396    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
397        self.process_core_events();
398        self.process_intents();
399
400        self.render_top_bar(ctx);
401
402        egui::CentralPanel::default().show(ctx, |ui| match self.state.transient.active_tab {
403            Tab::Dashboard => ui::dashboard::render(ui, &mut self.state),
404            Tab::Config => ui::config::render(ui, &mut self.state),
405            Tab::Control => ui::control::render(ui, &mut self.state),
406            Tab::Stream => ui::stream::render(ui, &mut self.state),
407        });
408
409        ctx.request_repaint_after(std::time::Duration::from_millis(16));
410    }
411}
412
413/// Parse a typed payload from a direct value or the standard `data` envelope.
414fn parse_envelope<T: serde::de::DeserializeOwned>(data: serde_json::Value) -> Option<T> {
415    if let Ok(value) = serde_json::from_value::<T>(data.clone()) {
416        return Some(value);
417    }
418    if let Some(inner) = data.get("data") {
419        return serde_json::from_value::<T>(inner.clone()).ok();
420    }
421    None
422}
423
424/// Reject SSID/password values the firmware tokenizer cannot accept.
425///
426/// Mirrors the server-side rules from §1.4 of the webserver spec so users
427/// see the failure inline rather than as a 400 round-trip.
428fn validate_sta_field(label: &str, value: &str) -> Result<(), String> {
429    if value.is_empty() {
430        return Ok(());
431    }
432    if value.len() > STA_FIELD_MAX_BYTES {
433        return Err(format!("{label} exceeds 32-byte firmware limit"));
434    }
435    if value.contains('\r') || value.contains('\n') {
436        return Err(format!("{label} must not contain newlines"));
437    }
438    if value.contains('\'') && value.contains('"') {
439        return Err(format!(
440            "{label} cannot contain both ' and \" — firmware tokenizer cannot disambiguate"
441        ));
442    }
443    Ok(())
444}
445
446/// Map known status codes onto operator-friendly hints.
447fn format_error(label: &str, status: u16, message: &str) -> String {
448    let hint = match status {
449        412 => Some("firmware not verified — try Fetch Info or Reset Device"),
450        503 => Some("ESP32 not connected, or operation not valid for current state"),
451        502 => Some("device responded but the info block was malformed"),
452        504 => Some("info block timed out — firmware may not be esp-csi-cli-rs"),
453        403 => Some("output mode is dump — switch to stream/both before opening WebSocket"),
454        _ => None,
455    };
456    match hint {
457        Some(h) => format!("{label} failed (HTTP {status}): {message} — {h}"),
458        None => format!("{label} failed (HTTP {status}): {message}"),
459    }
460}
461
462/// Parse an optional `u16` where empty input means `None`.
463fn parse_optional_u16(input: &str) -> Option<u16> {
464    let trimmed = input.trim();
465    if trimmed.is_empty() {
466        return None;
467    }
468    trimmed.parse::<u16>().ok()
469}
470
471/// Parse a required `u64`.
472fn parse_required_u64(input: &str) -> Option<u64> {
473    input.trim().parse::<u64>().ok()
474}
475
476/// Parse a required `u32`.
477fn parse_required_u32(input: &str) -> Option<u32> {
478    input.trim().parse::<u32>().ok()
479}
480
481/// Parse an optional `u64` where empty input means `None`.
482fn parse_optional_u64(input: &str) -> Option<u64> {
483    let trimmed = input.trim();
484    if trimmed.is_empty() {
485        return None;
486    }
487    trimmed.parse::<u64>().ok()
488}
489
490/// Convert user text to optional string while preserving significant whitespace.
491fn empty_to_none(input: &str) -> Option<String> {
492    if input.trim().is_empty() {
493        None
494    } else {
495        Some(input.to_owned())
496    }
497}
498
499/// Render one tab selector button and switch active tab on click.
500fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
501    let selected = state.transient.active_tab == tab;
502    if ui.selectable_label(selected, label).clicked() {
503        state.transient.active_tab = tab;
504    }
505}