1use crate::core::CoreHandle;
2use crate::core::messages::{ApiRequest, ApiResponseEvent, CoreCommand, CoreEvent, HttpMethod};
3use crate::export::Recorder;
4use crate::state::{
5 AppState, ControlStatus, DeviceAction, DeviceConfig, DeviceInfo, DeviceListEntry,
6 PairingPreset, ServerStatus, Tab, TrafficForm, UserIntent, WiFiForm, WiFiMode, WifiProtocol,
7};
8use crate::ui;
9use eframe::egui;
10use serde_json::{Value, json};
11use std::collections::HashMap;
12
13const STA_FIELD_MAX_BYTES: usize = 32;
14const DEVICE_POLL_INTERVAL_SECS: f64 = 2.0;
16
17pub struct CsiClientApp {
25 state: AppState,
26 core: CoreHandle,
27 last_devices_poll: Option<f64>,
29 recorders: HashMap<String, Recorder>,
32 preset_queue: Vec<(String, DeviceAction)>,
34}
35
36impl CsiClientApp {
37 pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
39 Self {
40 state: AppState::with_defaults(),
41 core: CoreHandle::new(),
42 last_devices_poll: None,
43 recorders: HashMap::new(),
44 preset_queue: Vec::new(),
45 }
46 }
47
48 fn process_intents(&mut self) {
50 for intent in self.state.drain_intents() {
51 match intent {
52 UserIntent::FetchDevices => {
53 self.submit_get("fetch_devices", None, "/api/devices".to_owned());
54 }
55 UserIntent::ToggleDeviceSelection(id) => {
56 self.state.toggle_selection(id);
57 }
58 UserIntent::SelectAllDevices => {
59 self.state.selected_device_ids = self.device_ids();
60 }
61 UserIntent::ClearDeviceSelection => {
62 self.state.selected_device_ids.clear();
63 }
64 UserIntent::StartAllCollections { duration_seconds } => {
65 for id in self.device_ids() {
66 self.process_device_action(
67 id,
68 DeviceAction::StartCollection {
69 duration_seconds: duration_seconds.clone(),
70 },
71 );
72 }
73 }
74 UserIntent::StopAllCollections => {
75 for id in self.device_ids() {
76 self.process_device_action(id, DeviceAction::StopCollection);
77 }
78 }
79 UserIntent::StartSelectedCollections { duration_seconds } => {
80 for id in self.state.selected_device_ids.clone() {
81 self.process_device_action(
82 id,
83 DeviceAction::StartCollection {
84 duration_seconds: duration_seconds.clone(),
85 },
86 );
87 }
88 }
89 UserIntent::StopSelectedCollections => {
90 for id in self.state.selected_device_ids.clone() {
91 self.process_device_action(id, DeviceAction::StopCollection);
92 }
93 }
94 UserIntent::StartSelectedRecording => {
95 for id in self.state.selected_device_ids.clone() {
96 self.start_recording(&id);
97 }
98 }
99 UserIntent::StopSelectedRecording => {
100 for id in self.state.selected_device_ids.clone() {
101 self.stop_recording(&id);
102 }
103 }
104 UserIntent::Device { id, action } => self.process_device_action(id, action),
105 }
106 }
107 }
108
109 fn device_ids(&self) -> Vec<String> {
111 self.state.devices.iter().map(|d| d.id.clone()).collect()
112 }
113
114 fn process_device_action(&mut self, id: String, action: DeviceAction) {
116 match action {
117 DeviceAction::FetchConfig => self.submit_device_get(&id, "fetch_config", "config"),
118 DeviceAction::FetchInfo => self.submit_device_get(&id, "fetch_info", "info"),
119 DeviceAction::FetchStatus => {
120 self.submit_device_get(&id, "fetch_status", "control/status")
121 }
122 DeviceAction::ResetConfig => {
123 self.submit_device_post(&id, "reset_config", "config/reset", None)
124 }
125 DeviceAction::SetWifi(wifi) => self.submit_set_wifi(&id, wifi),
126 DeviceAction::SetTraffic(traffic) => {
127 if let Some(frequency_hz) = parse_required_u64(&traffic.frequency_hz) {
128 let send_unsolicited = self
136 .state
137 .devices
138 .iter()
139 .find(|d| d.id == id)
140 .is_some_and(|d| {
141 matches!(d.forms.wifi.mode, WiFiMode::WifiAp | WiFiMode::Station)
142 && d.supports_unsolicited()
143 });
144 let body = if send_unsolicited {
145 json!({ "frequency_hz": frequency_hz, "unsolicited": traffic.unsolicited })
146 } else {
147 json!({ "frequency_hz": frequency_hz })
148 };
149 if let Some(device) = self.state.device_mut_by_id(&id) {
150 device.forms.traffic = traffic.clone();
151 }
152 self.submit_device_post(&id, "set_traffic", "config/traffic", Some(body));
153 } else {
154 self.set_error("Traffic frequency must be a non-negative integer");
155 }
156 }
157 DeviceAction::SetCsi(csi) => {
158 let csi_he_stbc = parse_required_u32(&csi.csi_he_stbc);
159 let val_scale_cfg = parse_required_u32(&csi.val_scale_cfg);
160
161 if let (Some(csi_he_stbc), Some(val_scale_cfg)) = (csi_he_stbc, val_scale_cfg) {
162 self.submit_device_post(
163 &id,
164 "set_csi",
165 "config/csi",
166 Some(json!({
167 "lltf": csi.lltf,
168 "htltf": csi.htltf,
169 "stbc_htltf": csi.stbc_htltf,
170 "ltf_merge": csi.ltf_merge,
171 "csi": csi.csi,
172 "csi_legacy": csi.csi_legacy,
173 "csi_ht20": csi.csi_ht20,
174 "csi_ht40": csi.csi_ht40,
175 "csi_su": csi.csi_su,
176 "csi_mu": csi.csi_mu,
177 "csi_dcm": csi.csi_dcm,
178 "csi_beamformed": csi.csi_beamformed,
179 "dump_ack": csi.dump_ack,
180 "csi_force_lltf": csi.csi_force_lltf,
181 "csi_vht": csi.csi_vht,
182 "csi_he_stbc": csi_he_stbc,
183 "val_scale_cfg": val_scale_cfg,
184 })),
185 );
186 } else {
187 self.set_error("csi_he_stbc and val_scale_cfg must be valid u32 numbers");
188 }
189 }
190 DeviceAction::SetCsiPreset(preset) => {
191 self.submit_device_post(
192 &id,
193 "set_csi_preset",
194 "config/csi",
195 Some(json!({ "preset": preset })),
196 );
197 }
198 DeviceAction::SetCollectionMode(mode) => {
199 self.submit_device_post(
200 &id,
201 "set_collection_mode",
202 "config/collection-mode",
203 Some(json!({ "mode": mode.as_api_value() })),
204 );
205 }
206 DeviceAction::SetOutputMode(mode) => {
207 self.submit_device_post(
208 &id,
209 "set_output_mode",
210 "config/output-mode",
211 Some(json!({ "mode": mode.as_api_value() })),
212 );
213 }
214 DeviceAction::SetProtocol(protocol) => {
215 self.submit_device_post(
216 &id,
217 "set_protocol",
218 "config/protocol",
219 Some(json!({ "protocol": protocol.as_api_value() })),
220 );
221 }
222 DeviceAction::SetPhyRate(form) => {
223 let rate = form.rate.trim().to_owned();
224 if rate.is_empty() {
225 self.set_error("PHY rate must not be empty");
226 } else {
227 self.submit_device_post(
228 &id,
229 "set_rate",
230 "config/rate",
231 Some(json!({ "rate": rate })),
232 );
233 }
234 }
235 DeviceAction::SetIoTasks(form) => {
236 self.submit_device_post(
237 &id,
238 "set_io_tasks",
239 "config/io-tasks",
240 Some(json!({ "tx": form.tx, "rx": form.rx })),
241 );
242 }
243 DeviceAction::SetCsiDelivery(form) => {
244 self.submit_device_post(
245 &id,
246 "set_csi_delivery",
247 "config/csi-delivery",
248 Some(json!({
249 "mode": form.mode.as_api_value(),
250 "logging": form.logging,
251 })),
252 );
253 }
254 DeviceAction::StartCollection { duration_seconds } => {
255 let duration = parse_optional_u64(&duration_seconds);
256 if duration_seconds.trim().is_empty() || duration.is_some() {
257 self.submit_device_post(
258 &id,
259 "start_collection",
260 "control/start",
261 duration.map(|d| json!({ "duration": d })),
262 );
263 } else {
264 self.set_error("Duration must be a valid number of seconds");
265 }
266 }
267 DeviceAction::StopCollection => {
268 self.submit_device_post(&id, "stop_collection", "control/stop", None);
269 }
270 DeviceAction::ShowStats => {
271 self.submit_device_post(&id, "show_stats", "control/stats", None);
272 }
273 DeviceAction::ResetDevice => {
274 self.submit_device_post(&id, "reset_device", "control/reset", None);
275 }
276 DeviceAction::ConnectWebSocket => {
277 self.core.submit(CoreCommand::ConnectWebSocket {
278 url: self.state.device_ws_url(&id),
279 device_id: id,
280 });
281 }
282 DeviceAction::DisconnectWebSocket => {
283 self.core
284 .submit(CoreCommand::DisconnectWebSocket { device_id: id });
285 }
286 DeviceAction::ClearFrames => {
287 if let Some(device) = self.state.device_mut_by_id(&id) {
288 device.clear_frames();
289 }
290 }
291 DeviceAction::StartRecording => self.start_recording(&id),
292 DeviceAction::StopRecording => self.stop_recording(&id),
293 DeviceAction::ApplyPairingPreset {
294 preset,
295 device_ids,
296 channel,
297 } => self.apply_pairing_preset(preset, device_ids, channel),
298 }
299 }
300
301 fn start_recording(&mut self, id: &str) {
307 if self.recorders.contains_key(id) {
308 return;
309 }
310 let Some(device) = self.state.device_mut_by_id(id) else {
311 return;
312 };
313 let Some(chip) = device.latest_info.as_ref().and_then(|i| i.chip.clone()) else {
314 self.set_error("Device chip unknown — Fetch Info before recording");
315 return;
316 };
317
318 let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
319 let dir = self.state.export_dir.trim();
320 let dir = if dir.is_empty() { "." } else { dir };
321 let path = format!("{dir}/csi_export_{id}_{stamp}.parquet");
322
323 match Recorder::start(&path, &chip) {
324 Ok(recorder) => {
325 let path = recorder.path().to_owned();
326 self.recorders.insert(id.to_owned(), recorder);
327 if let Some(device) = self.state.device_mut_by_id(id) {
328 device.recording = true;
329 device.record_path = Some(path.clone());
330 device.recorded_frames = 0;
331 device.record_decode_errors = 0;
332 }
333 self.state.transient.error_message.clear();
334 self.state.transient.status_message = format!("Recording {id} → {path}");
335 self.state.push_event(format!("[{id}] recording to {path}"));
336 }
337 Err(message) => self.set_error(format!("Could not start recording: {message}")),
338 }
339 }
340
341 fn finalize_recording(&mut self, id: &str) {
343 if let Some(recorder) = self.recorders.remove(id) {
344 let frames = recorder.frames_written;
345 let path = recorder.path().to_owned();
346 if let Err(e) = recorder.finish() {
347 self.set_error(format!("Failed to finalize {path}: {e}"));
348 } else {
349 self.state.transient.status_message =
350 format!("Saved {frames} frame(s) → {path}");
351 self.state.push_event(format!("[{id}] recording saved: {path}"));
352 }
353 }
354 if let Some(device) = self.state.device_mut_by_id(id) {
355 device.recording = false;
356 }
357 }
358
359 fn stop_recording(&mut self, id: &str) {
361 self.finalize_recording(id);
362 }
363
364 fn submit_get(&self, label: &str, device_id: Option<String>, path: String) {
365 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
366 label: label.to_owned(),
367 device_id,
368 method: HttpMethod::Get,
369 base_url: self.state.base_http_url(),
370 path,
371 body: None,
372 }));
373 }
374
375 fn submit_post(&self, label: &str, device_id: Option<String>, path: String, body: Option<Value>) {
376 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
377 label: label.to_owned(),
378 device_id,
379 method: HttpMethod::Post,
380 base_url: self.state.base_http_url(),
381 path,
382 body,
383 }));
384 }
385
386 fn submit_device_get(&self, id: &str, label: &str, suffix: &str) {
388 self.submit_get(label, Some(id.to_owned()), format!("/api/devices/{id}/{suffix}"));
389 }
390
391 fn submit_device_post(&self, id: &str, label: &str, suffix: &str, body: Option<Value>) {
393 self.submit_post(
394 label,
395 Some(id.to_owned()),
396 format!("/api/devices/{id}/{suffix}"),
397 body,
398 );
399 }
400
401 fn set_error(&mut self, message: impl Into<String>) {
402 self.state.transient.error_message = message.into();
403 }
404
405 fn submit_set_wifi(&mut self, id: &str, wifi: WiFiForm) {
406 if wifi.mode.requires_v07() {
407 if let Some(device) = self.state.device_mut_by_id(id) {
408 if !device.supports_v07_modes() {
409 self.set_error(format!(
410 "Mode '{}' requires esp-csi-cli-rs ≥ 0.7.0 on device {id}",
411 wifi.mode.as_api_value()
412 ));
413 return;
414 }
415 }
416 }
417
418 if matches!(wifi.mode, WiFiMode::Station) {
419 if let Err(message) = validate_sta_field("STA SSID", &wifi.sta_ssid) {
420 self.set_error(message);
421 return;
422 }
423 if let Err(message) = validate_sta_field("STA password", &wifi.sta_password) {
424 self.set_error(message);
425 return;
426 }
427 }
428
429 if matches!(wifi.mode, WiFiMode::WifiAp) {
430 if let Err(message) = validate_sta_field("AP SSID", &wifi.ap_ssid) {
431 self.set_error(message);
432 return;
433 }
434 if let Err(message) = validate_sta_field("AP password", &wifi.ap_password) {
435 self.set_error(message);
436 return;
437 }
438 }
439
440 let channel = if wifi.mode.allows_channel() {
441 let channel = parse_optional_u16(&wifi.channel);
442 if !wifi.channel.trim().is_empty() && channel.is_none() {
443 self.set_error("Wi-Fi channel must be a valid number");
444 return;
445 }
446 channel
447 } else {
448 None
449 };
450
451 let mut body = json!({ "mode": wifi.mode.as_api_value() });
452
453 if matches!(wifi.mode, WiFiMode::Station) {
454 if let Some(v) = empty_to_none(&wifi.sta_ssid) {
455 body["sta_ssid"] = json!(v);
456 }
457 if let Some(v) = empty_to_none(&wifi.sta_password) {
458 body["sta_password"] = json!(v);
459 }
460 }
461
462 if matches!(wifi.mode, WiFiMode::WifiAp) {
463 body["ap_ssid"] = json!(wifi.ap_ssid.trim());
464 body["ap_password"] = json!(wifi.ap_password.trim());
465 body["ap_dhcp"] = json!(wifi.ap_dhcp);
466 }
467
468 if let Some(ch) = channel {
469 body["channel"] = json!(ch);
470 }
471
472 if wifi.mode.is_esp_now() {
473 let peer_mac = wifi.peer_mac.trim();
474 if !peer_mac.is_empty() {
475 if let Err(message) = validate_peer_mac(peer_mac) {
476 self.set_error(message);
477 return;
478 }
479 }
480 body["peer_mac"] = json!(peer_mac);
481 body["ht40"] = json!(wifi.ht40.as_api_value());
482 }
483
484 if let Some(device) = self.state.device_mut_by_id(id) {
489 device.forms.wifi = wifi.clone();
490 }
491 self.submit_device_post(id, "set_wifi", "config/wifi", Some(body));
492 }
493
494 fn apply_pairing_preset(
495 &mut self,
496 preset: PairingPreset,
497 device_ids: [String; 2],
498 channel: u8,
499 ) {
500 if preset.requires_v07() {
501 for id in &device_ids {
502 let Some(device) = self.state.devices.iter().find(|d| d.id == *id) else {
503 self.set_error(format!("Device not found: {id}"));
504 return;
505 };
506 if device.firmware_verified != Some(true) {
507 self.set_error(format!("Device {id} firmware not verified"));
508 return;
509 }
510 if !device.supports_v07_modes() {
511 self.set_error(format!(
512 "Preset '{}' requires esp-csi-cli-rs ≥ 0.7.0 on both boards",
513 preset.label()
514 ));
515 return;
516 }
517 }
518 }
519
520 let ch = channel.to_string();
521 let ap_ssid = "esp-csi-ap".to_owned();
522
523 let (wifi_a, wifi_b, proto_a, proto_b, traffic_a, traffic_b) = match preset {
524 PairingPreset::SoftApLab => (
525 WiFiForm {
526 mode: WiFiMode::WifiAp,
527 ap_ssid: ap_ssid.clone(),
528 channel: ch.clone(),
529 ..WiFiForm::default()
530 },
531 WiFiForm {
532 mode: WiFiMode::Station,
533 sta_ssid: ap_ssid,
534 ..WiFiForm::default()
535 },
536 Some(WifiProtocol::N),
537 Some(WifiProtocol::N),
538 Some(TrafficForm {
544 frequency_hz: "1000".to_owned(),
545 unsolicited: true,
546 }),
547 Some(TrafficForm {
548 frequency_hz: "0".to_owned(),
549 unsolicited: false,
550 }),
551 ),
552 PairingPreset::EspNowFastSimplex => (
553 WiFiForm {
554 mode: WiFiMode::EspNowFastCollector,
555 channel: ch.clone(),
556 ..WiFiForm::default()
557 },
558 WiFiForm {
559 mode: WiFiMode::EspNowFastSource,
560 channel: ch,
561 ..WiFiForm::default()
562 },
563 None,
564 None,
565 None,
566 None,
567 ),
568 PairingPreset::EspNowBalanced => (
569 WiFiForm {
570 mode: WiFiMode::EspNowCentral,
571 channel: ch.clone(),
572 ..WiFiForm::default()
573 },
574 WiFiForm {
575 mode: WiFiMode::EspNowPeripheral,
576 channel: ch,
577 ..WiFiForm::default()
578 },
579 None,
580 None,
581 None,
582 None,
583 ),
584 };
585
586 let mut queue = Vec::new();
587 let push_device = |queue: &mut Vec<(String, DeviceAction)>,
588 id: &str,
589 wifi: WiFiForm,
590 protocol: Option<WifiProtocol>,
591 traffic: Option<TrafficForm>| {
592 queue.push((id.to_owned(), DeviceAction::ResetConfig));
593 queue.push((id.to_owned(), DeviceAction::SetWifi(wifi)));
594 if let Some(p) = protocol {
595 queue.push((id.to_owned(), DeviceAction::SetProtocol(p)));
596 }
597 if let Some(t) = traffic {
598 queue.push((id.to_owned(), DeviceAction::SetTraffic(t)));
599 }
600 };
601
602 push_device(
603 &mut queue,
604 &device_ids[0],
605 wifi_a,
606 proto_a,
607 traffic_a,
608 );
609 push_device(
610 &mut queue,
611 &device_ids[1],
612 wifi_b,
613 proto_b,
614 traffic_b,
615 );
616
617 self.state.push_event(format!(
618 "Applying preset '{}' to {} and {}",
619 preset.label(),
620 device_ids[0],
621 device_ids[1]
622 ));
623 self.preset_queue = queue;
624 self.run_next_preset_step();
625 }
626
627 fn run_next_preset_step(&mut self) {
628 if let Some((id, action)) = self.preset_queue.first().cloned() {
629 self.process_device_action(id, action);
630 }
631 }
632
633 fn advance_preset_queue(&mut self, success: bool) {
634 if self.preset_queue.is_empty() {
635 return;
636 }
637 if success {
638 self.preset_queue.remove(0);
639 if self.preset_queue.is_empty() {
640 self.state
641 .push_event("Pairing preset applied to both devices".to_owned());
642 self.state.transient.status_message = "Pairing preset complete".to_owned();
643 } else {
644 self.run_next_preset_step();
645 }
646 } else {
647 self.preset_queue.clear();
648 self.state.push_event("Pairing preset aborted due to error".to_owned());
649 }
650 }
651
652 fn maybe_poll_devices(&mut self, ctx: &egui::Context) {
654 let now = ctx.input(|i| i.time);
655 let due = match self.last_devices_poll {
656 None => true,
657 Some(last) => now - last >= DEVICE_POLL_INTERVAL_SECS,
658 };
659 if due {
660 self.last_devices_poll = Some(now);
661 self.state.push_intent(UserIntent::FetchDevices);
662 }
663 }
664
665 fn process_core_events(&mut self) {
667 let mut followups: Vec<UserIntent> = Vec::new();
668 while let Some(event) = self.core.try_recv() {
669 match event {
670 CoreEvent::ApiResponse(response) => {
671 self.handle_api_response(response, &mut followups);
672 }
673 CoreEvent::WebSocketConnected { device_id } => {
674 if let Some(device) = self.state.device_mut_by_id(&device_id) {
675 device.ws_connected = true;
676 }
677 self.state.transient.status_message =
678 format!("WebSocket connected ({device_id})");
679 self.state.transient.error_message.clear();
680 self.state.push_event(format!("[{device_id}] WebSocket connected"));
681 }
682 CoreEvent::WebSocketDisconnected { device_id, reason } => {
683 if let Some(device) = self.state.device_mut_by_id(&device_id) {
684 device.ws_connected = false;
685 }
686 self.finalize_recording(&device_id);
689 self.state
690 .push_event(format!("[{device_id}] WebSocket disconnected: {reason}"));
691 followups.push(UserIntent::FetchDevices);
693 }
694 CoreEvent::WebSocketFrame { device_id, bytes } => {
695 if let Some(recorder) = self.recorders.get_mut(&device_id) {
696 let now = chrono::Utc::now().timestamp_micros();
697 let _ = recorder.record_frame(&bytes, now);
698 if let Some(device) = self.state.device_mut_by_id(&device_id) {
699 device.recorded_frames = recorder.frames_written;
700 device.record_decode_errors = recorder.decode_errors;
701 }
702 }
703 if let Some(device) = self.state.device_mut_by_id(&device_id) {
704 device.push_frame(&bytes);
705 }
706 }
707 CoreEvent::Log(line) => {
708 self.state.push_event(line);
709 }
710 }
711 }
712 for followup in followups {
713 self.state.push_intent(followup);
714 }
715 }
716
717 fn handle_api_response(&mut self, response: ApiResponseEvent, followups: &mut Vec<UserIntent>) {
719 if response.label == "fetch_devices" {
720 self.handle_device_list(response, followups);
721 return;
722 }
723
724 let device_label = response.device_id.clone().unwrap_or_default();
725
726 if response.success {
727 self.state.transient.status_message = format!(
728 "[{}] {} (HTTP {}): {}",
729 device_label, response.label, response.status, response.message
730 );
731 self.state.transient.error_message.clear();
732 } else {
733 self.state.transient.error_message =
734 format_error(&response.label, response.status, &response.message);
735 }
736
737 self.state.push_event(format!(
738 "[{}] {} -> HTTP {}: {}",
739 device_label, response.label, response.status, response.message
740 ));
741
742 if response.status == 404 {
744 followups.push(UserIntent::FetchDevices);
745 }
746
747 let Some(id) = response.device_id.clone() else {
748 return;
749 };
750
751 if response.success {
752 if let Some(device) = self.state.device_mut_by_id(&id) {
753 match response.label.as_str() {
754 "fetch_config" => {
755 if let Some(config) =
756 response.data.clone().and_then(parse_envelope::<DeviceConfig>)
757 {
758 let applied = device.apply_device_config(config);
759 if applied == 0 && !device.auto_resetting_cache {
760 device.auto_resetting_cache = true;
761 followups.push(UserIntent::Device {
762 id: id.clone(),
763 action: DeviceAction::ResetConfig,
764 });
765 } else {
766 device.auto_resetting_cache = false;
767 }
768 } else {
769 device.auto_resetting_cache = false;
770 }
771 }
772 "fetch_info" => {
773 if let Some(info) =
774 response.data.clone().and_then(parse_envelope::<DeviceInfo>)
775 {
776 device.firmware_verified = Some(true);
777 device.latest_info = Some(info);
778 }
779 }
780 "fetch_status" => {
781 if let Some(status) =
782 response.data.clone().and_then(parse_envelope::<ControlStatus>)
783 {
784 device.apply_control_status(status);
785 }
786 }
787 "start_collection" => device.collection_running = Some(true),
788 "stop_collection" => device.collection_running = Some(false),
789 "reset_device" => {
790 device.collection_running = Some(false);
791 device.firmware_verified = None;
792 device.latest_info = None;
793 }
794 "reset_config" | "set_wifi" | "set_traffic" | "set_csi" | "set_csi_preset"
797 | "set_collection_mode" | "set_output_mode" | "set_protocol"
798 | "set_rate" | "set_io_tasks" | "set_csi_delivery" => {
799 followups.push(UserIntent::Device {
800 id: id.clone(),
801 action: DeviceAction::FetchConfig,
802 });
803 }
804 _ => {}
805 }
806 }
807 } else if response.label == "fetch_info" && response.status != 0 {
808 if let Some(device) = self.state.device_mut_by_id(&id) {
809 device.firmware_verified = Some(false);
810 }
811 } else if response.label == "reset_config" {
812 if let Some(device) = self.state.device_mut_by_id(&id) {
813 device.auto_resetting_cache = false;
814 }
815 }
816
817 self.maybe_advance_preset_queue(&response);
818 }
819
820 fn maybe_advance_preset_queue(&mut self, response: &ApiResponseEvent) {
821 if self.preset_queue.is_empty() {
822 return;
823 }
824 let Some(resp_id) = response.device_id.as_deref() else {
825 return;
826 };
827 if !self.preset_queue.iter().any(|(id, _)| id == resp_id) {
828 return;
829 }
830 if !response.success {
831 self.advance_preset_queue(false);
832 return;
833 }
834 let Some((head_id, head_action)) = self.preset_queue.first() else {
835 return;
836 };
837 if head_id != resp_id {
838 return;
839 }
840 if response.label == preset_action_label(head_action) {
841 self.advance_preset_queue(true);
842 }
843 }
844
845 fn handle_device_list(&mut self, response: ApiResponseEvent, followups: &mut Vec<UserIntent>) {
847 if !response.success {
848 self.state.transient.server_status = ServerStatus::Disconnected;
849 self.state.transient.error_message =
850 format_error("fetch_devices", response.status, &response.message);
851 return;
852 }
853
854 self.state.transient.server_status = ServerStatus::Connected;
855
856 let Some(entries) = response.data.and_then(parse_device_list) else {
857 return;
858 };
859
860 let outcome = self.state.reconcile_devices(entries);
861
862 for id in &outcome.removed_ids {
863 self.core.submit(CoreCommand::DisconnectWebSocket {
864 device_id: id.clone(),
865 });
866 self.finalize_recording(id);
867 self.state.push_event(format!("[{id}] device removed"));
868 }
869
870 for id in &outcome.new_ids {
871 if let Some(device) = self.state.device_mut_by_id(id) {
872 device.details_loaded = true;
873 }
874 followups.push(UserIntent::Device {
875 id: id.clone(),
876 action: DeviceAction::FetchInfo,
877 });
878 followups.push(UserIntent::Device {
879 id: id.clone(),
880 action: DeviceAction::FetchConfig,
881 });
882 followups.push(UserIntent::Device {
883 id: id.clone(),
884 action: DeviceAction::FetchStatus,
885 });
886 }
887
888 if outcome.changed {
889 let count = self.state.devices.len();
890 self.state.transient.status_message = format!("{count} device(s) attached");
891 self.state
892 .push_event(format!("Device set changed: {count} attached"));
893 }
894 }
895
896 fn apply_selector_intent(&mut self, intent: UserIntent) {
898 match intent {
899 UserIntent::ToggleDeviceSelection(id) => self.state.toggle_selection(id),
900 UserIntent::SelectAllDevices => {
901 self.state.selected_device_ids = self.device_ids();
902 }
903 UserIntent::ClearDeviceSelection => {
904 self.state.selected_device_ids.clear();
905 }
906 other => self.state.push_intent(other),
907 }
908 }
909
910 fn render_top_bar(&mut self, ctx: &egui::Context) {
912 egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
913 ui.horizontal_wrapped(|ui| {
914 ui.label("Host");
915 ui.add(
916 egui::TextEdit::singleline(&mut self.state.server_host).desired_width(140.0),
917 );
918 ui.label("Port");
919 ui.add(
920 egui::TextEdit::singleline(&mut self.state.server_port).desired_width(60.0),
921 );
922
923 if ui.button("Connect").clicked() {
924 self.state.transient.server_status = ServerStatus::Connecting;
927 self.state.push_intent(UserIntent::FetchDevices);
928 }
929
930 server_status_indicator(ui, self.state.transient.server_status);
931
932 ui.separator();
933 ui.label("Devices");
934
935 let selected_text = ui::selector::selection_label(&self.state);
936 let mut selector_intents: Vec<UserIntent> = Vec::new();
937 egui::ComboBox::from_id_salt("device_selector")
938 .selected_text(selected_text)
939 .width(220.0)
940 .show_ui(ui, |ui| {
941 ui::selector::render_popup(ui, &self.state, &mut selector_intents);
942 });
943 for intent in selector_intents {
944 self.apply_selector_intent(intent);
945 }
946
947 if ui.button("Refresh Devices").clicked() {
948 self.state.push_intent(UserIntent::FetchDevices);
949 }
950 });
951
952 let selected = self.state.selected_device_ids.clone();
953 ui.horizontal_wrapped(|ui| {
954 let has_selection = !selected.is_empty();
955 if ui.add_enabled(has_selection, egui::Button::new("Fetch Info")).clicked() {
956 for id in &selected {
957 self.state.push_device_action(id.clone(), DeviceAction::FetchInfo);
958 }
959 }
960 if ui.add_enabled(has_selection, egui::Button::new("Fetch Config")).clicked() {
961 for id in &selected {
962 self.state.push_device_action(id.clone(), DeviceAction::FetchConfig);
963 }
964 }
965 if ui.add_enabled(has_selection, egui::Button::new("Fetch Status")).clicked() {
966 for id in &selected {
967 self.state.push_device_action(id.clone(), DeviceAction::FetchStatus);
968 }
969 }
970 });
971
972 ui.horizontal_wrapped(|ui| {
973 tab_button(ui, &mut self.state, Tab::Devices, "Devices");
974 tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
975 tab_button(ui, &mut self.state, Tab::Config, "Config");
976 tab_button(ui, &mut self.state, Tab::Control, "Control");
977 tab_button(ui, &mut self.state, Tab::Stream, "Stream");
978 });
979
980 if !self.state.transient.status_message.is_empty() {
981 ui.add(
982 egui::Label::new(format!("Status: {}", self.state.transient.status_message))
983 .wrap(),
984 );
985 }
986
987 if !self.state.transient.error_message.is_empty() {
988 ui.add(
989 egui::Label::new(
990 egui::RichText::new(format!(
991 "Error: {}",
992 self.state.transient.error_message
993 ))
994 .color(egui::Color32::from_rgb(220, 80, 80)),
995 )
996 .wrap(),
997 );
998 }
999 });
1000 }
1001}
1002
1003impl eframe::App for CsiClientApp {
1004 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
1012 self.process_core_events();
1013 self.maybe_poll_devices(ctx);
1014 self.process_intents();
1015
1016 self.render_top_bar(ctx);
1017
1018 let active_tab = self.state.transient.active_tab;
1019 let mut intents: Vec<UserIntent> = Vec::new();
1020 let mut tagged_actions: Vec<(String, DeviceAction)> = Vec::new();
1023 let mut export_dir = std::mem::take(&mut self.state.export_dir);
1026
1027 egui::CentralPanel::default().show(ctx, |ui| match active_tab {
1028 Tab::Devices => ui::devices::render(ui, &mut self.state, &mut intents),
1029 _ => {
1030 let selected_ids = self.state.selected_device_ids.clone();
1031 if selected_ids.is_empty() {
1032 ui.heading("No device selected");
1033 ui.label("Select one or more devices from the Devices tab or the top bar.");
1034 return;
1035 }
1036
1037 if matches!(active_tab, Tab::Control | Tab::Stream) {
1038 ui.horizontal_wrapped(|ui| {
1039 ui.strong(format!("{} selected", selected_ids.len()));
1040 ui.separator();
1041 if ui.button("Start Selected").clicked() {
1042 intents.push(UserIntent::StartSelectedCollections {
1043 duration_seconds: String::new(),
1044 });
1045 }
1046 if ui.button("Stop Selected").clicked() {
1047 intents.push(UserIntent::StopSelectedCollections);
1048 }
1049 if active_tab == Tab::Stream {
1050 ui.separator();
1051 if ui.button("Start Recording (Selected)").clicked() {
1052 intents.push(UserIntent::StartSelectedRecording);
1053 }
1054 if ui.button("Stop Recording (Selected)").clicked() {
1055 intents.push(UserIntent::StopSelectedRecording);
1056 }
1057 }
1058 });
1059 ui.separator();
1060 }
1061
1062 if active_tab == Tab::Stream {
1063 ui::stream::render_export_dir(ui, &mut export_dir);
1064 ui.separator();
1065 }
1066
1067 ui::detail::render(
1068 ui,
1069 active_tab,
1070 &mut self.state,
1071 &selected_ids,
1072 &mut tagged_actions,
1073 );
1074 }
1075 });
1076
1077 self.state.export_dir = export_dir;
1078
1079 for intent in intents {
1080 self.state.push_intent(intent);
1081 }
1082 for (id, action) in tagged_actions {
1083 self.state.push_device_action(id, action);
1084 }
1085
1086 ctx.request_repaint_after(std::time::Duration::from_millis(16));
1087 }
1088}
1089
1090fn server_status_indicator(ui: &mut egui::Ui, status: ServerStatus) {
1092 let color = match status {
1093 ServerStatus::Connected => egui::Color32::from_rgb(60, 180, 75),
1094 ServerStatus::Connecting => egui::Color32::from_rgb(230, 180, 40),
1095 ServerStatus::Disconnected => egui::Color32::from_rgb(220, 80, 80),
1096 ServerStatus::Unknown => egui::Color32::GRAY,
1097 };
1098 ui.colored_label(color, format!("● {}", status.label()));
1099}
1100
1101fn parse_device_list(data: Value) -> Option<Vec<DeviceListEntry>> {
1103 if let Ok(list) = serde_json::from_value::<Vec<DeviceListEntry>>(data.clone()) {
1104 return Some(list);
1105 }
1106 if let Some(inner) = data.get("data") {
1107 return serde_json::from_value::<Vec<DeviceListEntry>>(inner.clone()).ok();
1108 }
1109 None
1110}
1111
1112fn parse_envelope<T: serde::de::DeserializeOwned>(data: serde_json::Value) -> Option<T> {
1114 if let Ok(value) = serde_json::from_value::<T>(data.clone()) {
1115 return Some(value);
1116 }
1117 if let Some(inner) = data.get("data") {
1118 return serde_json::from_value::<T>(inner.clone()).ok();
1119 }
1120 None
1121}
1122
1123fn preset_action_label(action: &DeviceAction) -> &str {
1125 match action {
1126 DeviceAction::ResetConfig => "reset_config",
1127 DeviceAction::SetWifi(_) => "set_wifi",
1128 DeviceAction::SetTraffic(_) => "set_traffic",
1129 DeviceAction::SetProtocol(_) => "set_protocol",
1130 _ => "",
1131 }
1132}
1133
1134fn validate_peer_mac(mac: &str) -> Result<(), String> {
1136 let sep = if mac.contains(':') {
1137 ':'
1138 } else if mac.contains('-') {
1139 '-'
1140 } else {
1141 return Err("Peer MAC must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_owned());
1142 };
1143 let octets: Vec<&str> = mac.split(sep).collect();
1144 if octets.len() != 6
1145 || octets
1146 .iter()
1147 .any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit()))
1148 {
1149 return Err(format!("Invalid peer MAC '{mac}' (use aa:bb:cc:dd:ee:ff)"));
1150 }
1151 Ok(())
1152}
1153
1154fn validate_sta_field(label: &str, value: &str) -> Result<(), String> {
1159 if value.is_empty() {
1160 return Ok(());
1161 }
1162 if value.len() > STA_FIELD_MAX_BYTES {
1163 return Err(format!("{label} exceeds 32-byte firmware limit"));
1164 }
1165 if value.contains('\r') || value.contains('\n') {
1166 return Err(format!("{label} must not contain newlines"));
1167 }
1168 if value.contains('\'') && value.contains('"') {
1169 return Err(format!(
1170 "{label} cannot contain both ' and \" — firmware tokenizer cannot disambiguate"
1171 ));
1172 }
1173 Ok(())
1174}
1175
1176fn format_error(label: &str, status: u16, message: &str) -> String {
1178 let hint = match status {
1179 404 => Some("device not found — it may have been unplugged"),
1180 412 => Some("firmware not verified — try Fetch Info or Reset Device"),
1181 503 => Some("ESP32 not connected, or operation not valid for current state"),
1182 502 => Some("device responded but the info block was malformed"),
1183 504 => Some("info block timed out — firmware may not be esp-csi-cli-rs"),
1184 403 => Some("output mode is dump — switch to stream/both before opening WebSocket"),
1185 _ => None,
1186 };
1187 match hint {
1188 Some(h) => format!("{label} failed (HTTP {status}): {message} — {h}"),
1189 None => format!("{label} failed (HTTP {status}): {message}"),
1190 }
1191}
1192
1193fn parse_optional_u16(input: &str) -> Option<u16> {
1195 let trimmed = input.trim();
1196 if trimmed.is_empty() {
1197 return None;
1198 }
1199 trimmed.parse::<u16>().ok()
1200}
1201
1202fn parse_required_u64(input: &str) -> Option<u64> {
1204 input.trim().parse::<u64>().ok()
1205}
1206
1207fn parse_required_u32(input: &str) -> Option<u32> {
1209 input.trim().parse::<u32>().ok()
1210}
1211
1212fn parse_optional_u64(input: &str) -> Option<u64> {
1214 let trimmed = input.trim();
1215 if trimmed.is_empty() {
1216 return None;
1217 }
1218 trimmed.parse::<u64>().ok()
1219}
1220
1221fn empty_to_none(input: &str) -> Option<String> {
1223 if input.trim().is_empty() {
1224 None
1225 } else {
1226 Some(input.to_owned())
1227 }
1228}
1229
1230fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
1232 let selected = state.transient.active_tab == tab;
1233 if ui.selectable_label(selected, label).clicked() {
1234 state.transient.active_tab = tab;
1235 }
1236}