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 if let Err(message) = validate_peer_mac(&wifi.peer_mac) {
213 self.set_error(message);
214 return;
215 }
216
217 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 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 "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 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 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
428fn 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
439fn 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
461fn 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
488fn 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
504fn 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
513fn parse_required_u64(input: &str) -> Option<u64> {
515 input.trim().parse::<u64>().ok()
516}
517
518fn parse_required_u32(input: &str) -> Option<u32> {
520 input.trim().parse::<u32>().ok()
521}
522
523fn 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
532fn 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
541fn 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}