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
12pub struct CsiClientApp {
20 state: AppState,
21 core: CoreHandle,
22}
23
24impl CsiClientApp {
25 pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
27 Self {
28 state: AppState::with_defaults(),
29 core: CoreHandle::new(),
30 }
31 }
32
33 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 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 "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 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 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
413fn 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
424fn 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
446fn 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
462fn 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
471fn parse_required_u64(input: &str) -> Option<u64> {
473 input.trim().parse::<u64>().ok()
474}
475
476fn parse_required_u32(input: &str) -> Option<u32> {
478 input.trim().parse::<u32>().ok()
479}
480
481fn 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
490fn 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
499fn 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}