Skip to main content

csi_webclient/
app.rs

1use crate::core::messages::{ApiRequest, CoreCommand, CoreEvent, HttpMethod};
2use crate::core::CoreHandle;
3use crate::state::{AppState, DeviceConfig, Tab, UserIntent};
4use crate::ui;
5use eframe::egui;
6use serde_json::json;
7
8/// Top-level egui application.
9///
10/// This type orchestrates the intent-command-event flow:
11///
12/// - reads and drains user intents from [`crate::state::AppState`]
13/// - submits commands to [`crate::core::CoreHandle`]
14/// - applies resulting core events back into state
15pub struct CsiClientApp {
16    state: AppState,
17    core: CoreHandle,
18}
19
20impl CsiClientApp {
21    /// Create a new app instance with default state and a running core worker.
22    pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
23        Self {
24            state: AppState::with_defaults(),
25            core: CoreHandle::new(),
26        }
27    }
28
29    /// Drain queued user intents and translate them into core commands.
30    ///
31    /// This keeps network and runtime side effects out of the UI modules.
32    fn process_intents(&mut self) {
33        for intent in self.state.drain_intents() {
34            match intent {
35                UserIntent::FetchConfig => {
36                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
37                        label: "fetch_config".to_owned(),
38                        method: HttpMethod::Get,
39                        base_url: self.state.base_http_url(),
40                        path: "/api/config".to_owned(),
41                        body: None,
42                    }));
43                }
44                UserIntent::ResetConfig => {
45                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
46                        label: "reset_config".to_owned(),
47                        method: HttpMethod::Post,
48                        base_url: self.state.base_http_url(),
49                        path: "/api/config/reset".to_owned(),
50                        body: None,
51                    }));
52                }
53                UserIntent::SetWifi(wifi) => {
54                    let channel = parse_optional_u16(&wifi.channel);
55                    if wifi.channel.trim().is_empty() || channel.is_some() {
56                        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
57                            label: "set_wifi".to_owned(),
58                            method: HttpMethod::Post,
59                            base_url: self.state.base_http_url(),
60                            path: "/api/config/wifi".to_owned(),
61                            body: Some(json!({
62                                "mode": wifi.mode.as_api_value(),
63                                "sta_ssid": empty_to_none(wifi.sta_ssid),
64                                "sta_password": empty_to_none(wifi.sta_password),
65                                "channel": channel,
66                            })),
67                        }));
68                    } else {
69                        self.state.transient.error_message =
70                            "Wi-Fi channel must be a valid number".to_owned();
71                    }
72                }
73                UserIntent::SetTraffic(traffic) => {
74                    if let Some(frequency_hz) = parse_required_u16(&traffic.frequency_hz) {
75                        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
76                            label: "set_traffic".to_owned(),
77                            method: HttpMethod::Post,
78                            base_url: self.state.base_http_url(),
79                            path: "/api/config/traffic".to_owned(),
80                            body: Some(json!({ "frequency_hz": frequency_hz })),
81                        }));
82                    } else {
83                        self.state.transient.error_message =
84                            "Traffic frequency must be a valid number".to_owned();
85                    }
86                }
87                UserIntent::SetCsi(csi) => {
88                    let csi_he_stbc = parse_required_u8(&csi.csi_he_stbc);
89                    let val_scale_cfg = parse_required_u8(&csi.val_scale_cfg);
90
91                    if let (Some(csi_he_stbc), Some(val_scale_cfg)) = (csi_he_stbc, val_scale_cfg) {
92                        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
93                            label: "set_csi".to_owned(),
94                            method: HttpMethod::Post,
95                            base_url: self.state.base_http_url(),
96                            path: "/api/config/csi".to_owned(),
97                            body: Some(json!({
98                                "disable_lltf": csi.disable_lltf,
99                                "disable_htltf": csi.disable_htltf,
100                                "disable_stbc_htltf": csi.disable_stbc_htltf,
101                                "disable_ltf_merge": csi.disable_ltf_merge,
102                                "disable_csi": csi.disable_csi,
103                                "disable_csi_legacy": csi.disable_csi_legacy,
104                                "disable_csi_ht20": csi.disable_csi_ht20,
105                                "disable_csi_ht40": csi.disable_csi_ht40,
106                                "disable_csi_su": csi.disable_csi_su,
107                                "disable_csi_mu": csi.disable_csi_mu,
108                                "disable_csi_dcm": csi.disable_csi_dcm,
109                                "disable_csi_beamformed": csi.disable_csi_beamformed,
110                                "csi_he_stbc": csi_he_stbc,
111                                "val_scale_cfg": val_scale_cfg
112                            })),
113                        }));
114                    } else {
115                        self.state.transient.error_message =
116                            "CSI u8 fields must be valid numbers in 0..255".to_owned();
117                    }
118                }
119                UserIntent::SetCollectionMode(mode) => {
120                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
121                        label: "set_collection_mode".to_owned(),
122                        method: HttpMethod::Post,
123                        base_url: self.state.base_http_url(),
124                        path: "/api/config/collection-mode".to_owned(),
125                        body: Some(json!({ "mode": mode.as_api_value() })),
126                    }));
127                }
128                UserIntent::SetLogMode(mode) => {
129                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
130                        label: "set_log_mode".to_owned(),
131                        method: HttpMethod::Post,
132                        base_url: self.state.base_http_url(),
133                        path: "/api/config/log-mode".to_owned(),
134                        body: Some(json!({ "mode": mode.as_api_value() })),
135                    }));
136                }
137                UserIntent::SetOutputMode(mode) => {
138                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
139                        label: "set_output_mode".to_owned(),
140                        method: HttpMethod::Post,
141                        base_url: self.state.base_http_url(),
142                        path: "/api/config/output-mode".to_owned(),
143                        body: Some(json!({ "mode": mode.as_api_value() })),
144                    }));
145                }
146                UserIntent::StartCollection { duration_seconds } => {
147                    let duration = parse_optional_u64(&duration_seconds);
148                    if duration_seconds.trim().is_empty() || duration.is_some() {
149                        self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
150                            label: "start_collection".to_owned(),
151                            method: HttpMethod::Post,
152                            base_url: self.state.base_http_url(),
153                            path: "/api/control/start".to_owned(),
154                            body: duration.map(|d| json!({ "duration": d })),
155                        }));
156                    } else {
157                        self.state.transient.error_message =
158                            "Duration must be a valid number of seconds".to_owned();
159                    }
160                }
161                UserIntent::ResetDevice => {
162                    self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
163                        label: "reset_device".to_owned(),
164                        method: HttpMethod::Post,
165                        base_url: self.state.base_http_url(),
166                        path: "/api/control/reset".to_owned(),
167                        body: None,
168                    }));
169                }
170                UserIntent::ConnectWebSocket => {
171                    self.core.submit(CoreCommand::ConnectWebSocket {
172                        url: self.state.base_ws_url(),
173                    });
174                }
175                UserIntent::DisconnectWebSocket => {
176                    self.core.submit(CoreCommand::DisconnectWebSocket);
177                }
178                UserIntent::ClearFrames => {
179                    self.state.runtime.recent_frames.clear();
180                    self.state.runtime.frames_received = 0;
181                    self.state.runtime.bytes_received = 0;
182                }
183            }
184        }
185    }
186
187    /// Poll and apply core worker events without blocking the frame loop.
188    fn process_core_events(&mut self) {
189        while let Some(event) = self.core.try_recv() {
190            match event {
191                CoreEvent::ApiResponse(response) => {
192                    if response.success {
193                        match response.label.as_str() {
194                            "start_collection" => {
195                                self.state.runtime.collection_active_estimate = true;
196                            }
197                            "reset_device" => {
198                                self.state.runtime.collection_active_estimate = false;
199                            }
200                            _ => {}
201                        }
202                    }
203
204                    self.state.runtime.last_http_status = Some(response.status);
205
206                    if response.success {
207                        self.state.transient.status_message = format!(
208                            "{} (HTTP {}): {}",
209                            response.label, response.status, response.message
210                        );
211                        self.state.transient.error_message.clear();
212                    } else {
213                        self.state.transient.error_message = format!(
214                            "{} failed (HTTP {}): {}",
215                            response.label, response.status, response.message
216                        );
217                    }
218
219                    self.state.push_event(format!(
220                        "{} -> HTTP {}: {}",
221                        response.label, response.status, response.message
222                    ));
223
224                    if response.label == "fetch_config" {
225                        if let Some(data) = response.data {
226                            if let Some(config) = parse_device_config(data) {
227                                self.state.apply_device_config(config);
228                            }
229                        }
230                    }
231                }
232                CoreEvent::WebSocketConnected => {
233                    self.state.runtime.ws_connected = true;
234                    self.state.transient.status_message = "WebSocket connected".to_owned();
235                    self.state.transient.error_message.clear();
236                    self.state.push_event("WebSocket connected");
237                }
238                CoreEvent::WebSocketDisconnected { reason } => {
239                    self.state.runtime.ws_connected = false;
240                    self.state.push_event(format!("WebSocket disconnected: {reason}"));
241                }
242                CoreEvent::WebSocketFrame(bytes) => {
243                    self.state.push_frame(&bytes);
244                }
245                CoreEvent::Log(line) => {
246                    self.state.push_event(line);
247                }
248            }
249        }
250    }
251
252    /// Render the shared top panel (host/port fields, tabs, status and errors).
253    fn render_top_bar(&mut self, ctx: &egui::Context) {
254        egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
255            ui.horizontal(|ui| {
256                ui.label("Host");
257                ui.text_edit_singleline(&mut self.state.persistent.server_host);
258                ui.label("Port");
259                ui.text_edit_singleline(&mut self.state.persistent.server_port);
260                if ui.button("Fetch Config").clicked() {
261                    self.state.push_intent(UserIntent::FetchConfig);
262                }
263            });
264
265            ui.horizontal(|ui| {
266                tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
267                tab_button(ui, &mut self.state, Tab::Config, "Config");
268                tab_button(ui, &mut self.state, Tab::Control, "Control");
269                tab_button(ui, &mut self.state, Tab::Stream, "Stream");
270            });
271
272            if !self.state.transient.status_message.is_empty() {
273                ui.label(format!("Status: {}", self.state.transient.status_message));
274            }
275
276            if !self.state.transient.error_message.is_empty() {
277                ui.colored_label(
278                    egui::Color32::from_rgb(220, 80, 80),
279                    format!("Error: {}", self.state.transient.error_message),
280                );
281            }
282        });
283    }
284}
285
286impl eframe::App for CsiClientApp {
287    /// Main egui frame update callback.
288    ///
289    /// The update order is:
290    /// 1. apply incoming core events
291    /// 2. process queued user intents
292    /// 3. render UI panels
293    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
294        self.process_core_events();
295        self.process_intents();
296
297        self.render_top_bar(ctx);
298
299        egui::CentralPanel::default().show(ctx, |ui| match self.state.transient.active_tab {
300            Tab::Dashboard => ui::dashboard::render(ui, &mut self.state),
301            Tab::Config => ui::config::render(ui, &mut self.state),
302            Tab::Control => ui::control::render(ui, &mut self.state),
303            Tab::Stream => ui::stream::render(ui, &mut self.state),
304        });
305
306        ctx.request_repaint_after(std::time::Duration::from_millis(16));
307    }
308}
309
310/// Parse a `DeviceConfig` from a direct payload or common API envelope.
311fn parse_device_config(data: serde_json::Value) -> Option<DeviceConfig> {
312    if let Ok(config) = serde_json::from_value::<DeviceConfig>(data.clone()) {
313        return Some(config);
314    }
315
316    if let Some(inner) = data.get("data") {
317        return serde_json::from_value::<DeviceConfig>(inner.clone()).ok();
318    }
319
320    None
321}
322
323/// Parse an optional `u16` where empty input means `None`.
324fn parse_optional_u16(input: &str) -> Option<u16> {
325    let trimmed = input.trim();
326    if trimmed.is_empty() {
327        return None;
328    }
329    trimmed.parse::<u16>().ok()
330}
331
332/// Parse a required `u16`.
333fn parse_required_u16(input: &str) -> Option<u16> {
334    input.trim().parse::<u16>().ok()
335}
336
337/// Parse a required `u8`.
338fn parse_required_u8(input: &str) -> Option<u8> {
339    input.trim().parse::<u8>().ok()
340}
341
342/// Parse an optional `u64` where empty input means `None`.
343fn parse_optional_u64(input: &str) -> Option<u64> {
344    let trimmed = input.trim();
345    if trimmed.is_empty() {
346        return None;
347    }
348    trimmed.parse::<u64>().ok()
349}
350
351/// Convert user text to optional string while preserving significant whitespace.
352///
353/// Returns `None` only when the input is entirely whitespace.
354fn empty_to_none(input: String) -> Option<String> {
355    if input.trim().is_empty() {
356        None
357    } else {
358        Some(input)
359    }
360}
361
362/// Render one tab selector button and switch active tab on click.
363fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
364    let selected = state.transient.active_tab == tab;
365    if ui.selectable_label(selected, label).clicked() {
366        state.transient.active_tab = tab;
367    }
368}