1use crate::core::CoreHandle;
2use crate::core::messages::{ApiRequest, ApiResponseEvent, CoreCommand, CoreEvent, HttpMethod};
3use crate::export::Recorder;
4use crate::profile::{ClientProfile, StandardClientProfile};
5use crate::state::{
6 AppState, ConfigSnapshotFile, ControlStatus, DeviceAction, DeviceConfig, DeviceForms,
7 DeviceInfo, DeviceListEntry, DeviceState, PairingPreset, ServerStatus, Tab, TrafficForm,
8 UserIntent, WiFiForm, WiFiMode, WifiProtocol,
9};
10use crate::ui;
11use eframe::egui;
12use serde_json::{Value, json};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16const STA_FIELD_MAX_BYTES: usize = 32;
17const DEVICE_POLL_INTERVAL_SECS: f64 = 2.0;
19
20pub struct CsiClientApp {
28 state: AppState,
29 core: CoreHandle,
30 last_devices_poll: Option<f64>,
32 recorders: HashMap<String, Recorder>,
35 step_queue: Vec<(String, DeviceAction)>,
38 step_queue_done_message: String,
40 profile: Arc<dyn ClientProfile>,
42}
43
44impl CsiClientApp {
45 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
48 Self::with_profile(cc, Arc::new(StandardClientProfile))
49 }
50
51 pub fn with_profile(
57 _cc: &eframe::CreationContext<'_>,
58 profile: Arc<dyn ClientProfile>,
59 ) -> Self {
60 Self {
61 state: AppState::with_defaults(),
62 core: CoreHandle::new(),
63 last_devices_poll: None,
64 recorders: HashMap::new(),
65 step_queue: Vec::new(),
66 step_queue_done_message: String::new(),
67 profile,
68 }
69 }
70
71 fn process_intents(&mut self) {
73 for intent in self.state.drain_intents() {
74 match intent {
75 UserIntent::FetchDevices => {
76 self.submit_get("fetch_devices", None, "/api/devices".to_owned());
77 }
78 UserIntent::ToggleDeviceSelection(id) => {
79 self.state.toggle_selection(id);
80 }
81 UserIntent::SelectAllDevices => {
82 self.state.selected_device_ids = self.device_ids();
83 }
84 UserIntent::ClearDeviceSelection => {
85 self.state.selected_device_ids.clear();
86 }
87 UserIntent::StartAllCollections { duration_seconds } => {
88 for id in self.device_ids() {
89 self.process_device_action(
90 id,
91 DeviceAction::StartCollection {
92 duration_seconds: duration_seconds.clone(),
93 },
94 );
95 }
96 }
97 UserIntent::StopAllCollections => {
98 for id in self.device_ids() {
99 self.process_device_action(id, DeviceAction::StopCollection);
100 }
101 }
102 UserIntent::StartSelectedCollections { duration_seconds } => {
103 for id in self.state.selected_device_ids.clone() {
104 self.process_device_action(
105 id,
106 DeviceAction::StartCollection {
107 duration_seconds: duration_seconds.clone(),
108 },
109 );
110 }
111 }
112 UserIntent::StopSelectedCollections => {
113 for id in self.state.selected_device_ids.clone() {
114 self.process_device_action(id, DeviceAction::StopCollection);
115 }
116 }
117 UserIntent::StartSelectedRecording => {
118 for id in self.state.selected_device_ids.clone() {
119 self.start_recording(&id);
120 }
121 }
122 UserIntent::StopSelectedRecording => {
123 for id in self.state.selected_device_ids.clone() {
124 self.stop_recording(&id);
125 }
126 }
127 UserIntent::Device { id, action } => self.process_device_action(id, action),
128 }
129 }
130 }
131
132 fn device_ids(&self) -> Vec<String> {
134 self.state.devices.iter().map(|d| d.id.clone()).collect()
135 }
136
137 fn process_device_action(&mut self, id: String, action: DeviceAction) {
139 match action {
140 DeviceAction::FetchConfig => self.submit_device_get(&id, "fetch_config", "config"),
141 DeviceAction::FetchInfo => self.submit_device_get(&id, "fetch_info", "info"),
142 DeviceAction::FetchStatus => {
143 self.submit_device_get(&id, "fetch_status", "control/status")
144 }
145 DeviceAction::ResetConfig => {
146 self.submit_device_post(&id, "reset_config", "config/reset", None)
147 }
148 DeviceAction::SetWifi(wifi) => self.submit_set_wifi(&id, wifi),
149 DeviceAction::SetTraffic(traffic) => {
150 if let Some(frequency_hz) = parse_required_u64(&traffic.frequency_hz) {
151 let send_unsolicited = self
159 .state
160 .devices
161 .iter()
162 .find(|d| d.id == id)
163 .is_some_and(|d| {
164 matches!(d.forms.wifi.mode, WiFiMode::WifiAp | WiFiMode::Station)
165 && d.supports_unsolicited()
166 });
167 let body = if send_unsolicited {
168 json!({ "frequency_hz": frequency_hz, "unsolicited": traffic.unsolicited })
169 } else {
170 json!({ "frequency_hz": frequency_hz })
171 };
172 if let Some(device) = self.state.device_mut_by_id(&id) {
173 device.forms.traffic = traffic.clone();
174 }
175 self.submit_device_post(&id, "set_traffic", "config/traffic", Some(body));
176 } else {
177 self.set_error("Traffic frequency must be a non-negative integer");
178 }
179 }
180 DeviceAction::SetCsi(csi) => {
181 if let Some(val_scale_cfg) = parse_required_u32(&csi.val_scale_cfg) {
182 self.submit_device_post(
183 &id,
184 "set_csi",
185 "config/csi",
186 Some(json!({
187 "lltf": csi.lltf,
188 "htltf": csi.htltf,
189 "stbc_htltf": csi.stbc_htltf,
190 "ltf_merge": csi.ltf_merge,
191 "csi": csi.csi,
192 "csi_legacy": csi.csi_legacy,
193 "csi_ht20": csi.csi_ht20,
194 "csi_ht40": csi.csi_ht40,
195 "dump_ack": csi.dump_ack,
196 "csi_force_lltf": csi.csi_force_lltf,
197 "csi_vht": csi.csi_vht,
198 "val_scale_cfg": val_scale_cfg,
199 })),
200 );
201 } else {
202 self.set_error("val_scale_cfg must be a valid u32 number");
203 }
204 }
205 DeviceAction::SetCsiPreset(preset) => {
206 self.submit_device_post(
207 &id,
208 "set_csi_preset",
209 "config/csi",
210 Some(json!({ "preset": preset })),
211 );
212 }
213 DeviceAction::SetCollectionMode(mode) => {
214 self.submit_device_post(
215 &id,
216 "set_collection_mode",
217 "config/collection-mode",
218 Some(json!({ "mode": mode.as_api_value() })),
219 );
220 }
221 DeviceAction::SetOutputMode(mode) => {
222 self.submit_device_post(
223 &id,
224 "set_output_mode",
225 "config/output-mode",
226 Some(json!({ "mode": mode.as_api_value() })),
227 );
228 }
229 DeviceAction::SetProtocol(protocol) => {
230 self.submit_device_post(
231 &id,
232 "set_protocol",
233 "config/protocol",
234 Some(json!({ "protocol": protocol.as_api_value() })),
235 );
236 }
237 DeviceAction::SetPhyRate(form) => {
238 let rate = form.rate.trim().to_owned();
239 if rate.is_empty() {
240 self.set_error("PHY rate must not be empty");
241 } else {
242 self.submit_device_post(
243 &id,
244 "set_rate",
245 "config/rate",
246 Some(json!({ "rate": rate })),
247 );
248 }
249 }
250 DeviceAction::SetIoTasks(form) => {
251 self.submit_device_post(
252 &id,
253 "set_io_tasks",
254 "config/io-tasks",
255 Some(json!({ "tx": form.tx, "rx": form.rx })),
256 );
257 }
258 DeviceAction::SetCsiDelivery(form) => {
259 self.submit_device_post(
260 &id,
261 "set_csi_delivery",
262 "config/csi-delivery",
263 Some(json!({
264 "mode": form.mode.as_api_value(),
265 "logging": form.logging,
266 })),
267 );
268 }
269 DeviceAction::StartCollection { duration_seconds } => {
270 let duration = parse_optional_u64(&duration_seconds);
271 if duration_seconds.trim().is_empty() || duration.is_some() {
272 self.submit_device_post(
273 &id,
274 "start_collection",
275 "control/start",
276 duration.map(|d| json!({ "duration": d })),
277 );
278 } else {
279 self.set_error("Duration must be a valid number of seconds");
280 }
281 }
282 DeviceAction::StopCollection => {
283 self.submit_device_post(&id, "stop_collection", "control/stop", None);
284 }
285 DeviceAction::ShowStats => {
286 self.submit_device_post(&id, "show_stats", "control/stats", None);
287 }
288 DeviceAction::ResetDevice => {
289 self.submit_device_post(&id, "reset_device", "control/reset", None);
290 }
291 DeviceAction::ConnectWebSocket => {
292 self.core.submit(CoreCommand::ConnectWebSocket {
293 url: self.state.device_ws_url(&id),
294 device_id: id,
295 });
296 }
297 DeviceAction::DisconnectWebSocket => {
298 self.core
299 .submit(CoreCommand::DisconnectWebSocket { device_id: id });
300 }
301 DeviceAction::ClearFrames => {
302 if let Some(device) = self.state.device_mut_by_id(&id) {
303 device.clear_frames();
304 }
305 }
306 DeviceAction::StartRecording => self.start_recording(&id),
307 DeviceAction::StopRecording => self.stop_recording(&id),
308 DeviceAction::ApplyPairingPreset {
309 preset,
310 device_ids,
311 channel,
312 } => self.apply_pairing_preset(preset, device_ids, channel),
313 DeviceAction::SaveConfigFile { path } => self.save_config_file(&id, &path),
314 DeviceAction::LoadConfigFile { path } => self.load_config_file(&id, &path),
315 DeviceAction::ApplyFullConfig => self.apply_full_config(&id),
316 DeviceAction::CopyConfigFrom { source_id } => self.copy_config_from(&id, &source_id),
317 }
318 }
319
320 fn save_config_file(&mut self, id: &str, path: &str) {
325 let Some(device) = self.state.device_mut_by_id(id) else {
326 return;
327 };
328 let snapshot = ConfigSnapshotFile {
329 format: 1,
330 device_id: Some(id.to_owned()),
331 saved_at: Some(chrono::Local::now().to_rfc3339()),
332 forms: device.forms.clone(),
333 };
334
335 let path = if path.trim().is_empty() {
336 let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
337 let dir = self.state.export_dir.trim();
338 let dir = if dir.is_empty() { "." } else { dir };
339 format!("{dir}/csi_config_{id}_{stamp}.json")
340 } else {
341 path.trim().to_owned()
342 };
343
344 let json = match serde_json::to_string_pretty(&snapshot) {
345 Ok(json) => json,
346 Err(e) => {
347 self.set_error(format!("Could not serialize config: {e}"));
348 return;
349 }
350 };
351 match std::fs::write(&path, json) {
352 Ok(()) => {
353 if let Some(device) = self.state.device_mut_by_id(id) {
354 device.config_path = path.clone();
355 }
356 self.state.transient.error_message.clear();
357 self.state.transient.status_message = format!("Config saved → {path}");
358 self.state.push_event(format!("[{id}] config saved: {path}"));
359 }
360 Err(e) => self.set_error(format!("Could not write {path}: {e}")),
361 }
362 }
363
364 fn load_config_file(&mut self, id: &str, path: &str) {
367 let path = path.trim();
368 if path.is_empty() {
369 self.set_error("Enter a config file path to load");
370 return;
371 }
372 let text = match std::fs::read_to_string(path) {
373 Ok(text) => text,
374 Err(e) => {
375 self.set_error(format!("Could not read {path}: {e}"));
376 return;
377 }
378 };
379 match serde_json::from_str::<ConfigSnapshotFile>(&text) {
380 Ok(snapshot) => {
381 if let Some(device) = self.state.device_mut_by_id(id) {
382 device.forms = snapshot.forms;
383 }
384 self.state.transient.error_message.clear();
385 self.state.push_event(format!("[{id}] config loaded from {path}"));
386 self.apply_full_config(id);
391 }
392 Err(e) => self.set_error(format!("Invalid config file {path}: {e}")),
393 }
394 }
395
396 fn apply_full_config(&mut self, id: &str) {
399 let Some(device) = self.state.devices.iter().find(|d| d.id == id) else {
400 return;
401 };
402 if let Err(message) = validate_full_config(device) {
403 self.set_error(format!("Cannot apply config to {id}: {message}"));
404 return;
405 }
406 let forms = device.forms.clone();
407 let queue = full_config_steps(id, &forms);
408 self.state
409 .push_event(format!("[{id}] applying full configuration ({} steps)", queue.len()));
410 self.start_step_queue(queue, format!("Full configuration applied to {id}"));
411 }
412
413 fn copy_config_from(&mut self, id: &str, source_id: &str) {
415 if id == source_id {
416 self.set_error("Source and target device are the same");
417 return;
418 }
419 let Some(source) = self.state.devices.iter().find(|d| d.id == source_id) else {
420 self.set_error(format!("Source device not found: {source_id}"));
421 return;
422 };
423 let forms = source.forms.clone();
424 let Some(target) = self.state.device_mut_by_id(id) else {
425 return;
426 };
427 target.forms = forms;
428 self.state
429 .push_event(format!("[{id}] configuration copied from {source_id}"));
430 self.apply_full_config(id);
431 }
432
433 fn start_recording(&mut self, id: &str) {
439 if self.recorders.contains_key(id) {
440 return;
441 }
442 let Some(device) = self.state.device_mut_by_id(id) else {
443 return;
444 };
445 let Some(chip) = device.latest_info.as_ref().and_then(|i| i.chip.clone()) else {
446 self.set_error("Device chip unknown — Fetch Info before recording");
447 return;
448 };
449
450 let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
451 let dir = self.state.export_dir.trim();
452 let dir = if dir.is_empty() { "." } else { dir };
453 let path = format!("{dir}/csi_export_{id}_{stamp}.parquet");
454
455 match Recorder::start(&path, &chip, Arc::clone(&self.profile)) {
456 Ok(recorder) => {
457 let path = recorder.path().to_owned();
458 self.recorders.insert(id.to_owned(), recorder);
459 if let Some(device) = self.state.device_mut_by_id(id) {
460 device.recording = true;
461 device.record_path = Some(path.clone());
462 device.recorded_frames = 0;
463 device.record_decode_errors = 0;
464 }
465 self.state.transient.error_message.clear();
466 self.state.transient.status_message = format!("Recording {id} → {path}");
467 self.state.push_event(format!("[{id}] recording to {path}"));
468 }
469 Err(message) => self.set_error(format!("Could not start recording: {message}")),
470 }
471 }
472
473 fn finalize_recording(&mut self, id: &str) {
475 if let Some(recorder) = self.recorders.remove(id) {
476 let frames = recorder.frames_written;
477 let path = recorder.path().to_owned();
478 if let Err(e) = recorder.finish() {
479 self.set_error(format!("Failed to finalize {path}: {e}"));
480 } else {
481 self.state.transient.status_message =
482 format!("Saved {frames} frame(s) → {path}");
483 self.state.push_event(format!("[{id}] recording saved: {path}"));
484 }
485 }
486 if let Some(device) = self.state.device_mut_by_id(id) {
487 device.recording = false;
488 }
489 }
490
491 fn stop_recording(&mut self, id: &str) {
493 self.finalize_recording(id);
494 }
495
496 fn submit_get(&self, label: &str, device_id: Option<String>, path: String) {
497 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
498 label: label.to_owned(),
499 device_id,
500 method: HttpMethod::Get,
501 base_url: self.state.base_http_url(),
502 path,
503 body: None,
504 }));
505 }
506
507 fn submit_post(&self, label: &str, device_id: Option<String>, path: String, body: Option<Value>) {
508 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
509 label: label.to_owned(),
510 device_id,
511 method: HttpMethod::Post,
512 base_url: self.state.base_http_url(),
513 path,
514 body,
515 }));
516 }
517
518 fn submit_device_get(&self, id: &str, label: &str, suffix: &str) {
520 self.submit_get(label, Some(id.to_owned()), format!("/api/devices/{id}/{suffix}"));
521 }
522
523 fn submit_device_post(&self, id: &str, label: &str, suffix: &str, body: Option<Value>) {
525 self.submit_post(
526 label,
527 Some(id.to_owned()),
528 format!("/api/devices/{id}/{suffix}"),
529 body,
530 );
531 }
532
533 fn set_error(&mut self, message: impl Into<String>) {
534 self.state.transient.error_message = message.into();
535 }
536
537 fn submit_set_wifi(&mut self, id: &str, wifi: WiFiForm) {
538 if wifi.mode.requires_v07() {
539 if let Some(device) = self.state.device_mut_by_id(id) {
540 if !device.supports_v07_modes() {
541 self.set_error(format!(
542 "Mode '{}' requires esp-csi-cli-rs ≥ 0.7.0 on device {id}",
543 wifi.mode.as_api_value()
544 ));
545 return;
546 }
547 }
548 }
549
550 if matches!(wifi.mode, WiFiMode::Station) {
551 if let Err(message) = validate_sta_field("STA SSID", &wifi.sta_ssid) {
552 self.set_error(message);
553 return;
554 }
555 if let Err(message) = validate_sta_field("STA password", &wifi.sta_password) {
556 self.set_error(message);
557 return;
558 }
559 }
560
561 if matches!(wifi.mode, WiFiMode::WifiAp) {
562 if let Err(message) = validate_sta_field("AP SSID", &wifi.ap_ssid) {
563 self.set_error(message);
564 return;
565 }
566 if let Err(message) = validate_sta_field("AP password", &wifi.ap_password) {
567 self.set_error(message);
568 return;
569 }
570 }
571
572 let channel = {
576 let channel = parse_optional_u16(&wifi.channel);
577 if !wifi.channel.trim().is_empty() && channel.is_none() {
578 self.set_error("Wi-Fi channel must be a valid number");
579 return;
580 }
581 channel
582 };
583
584 let mut body = json!({ "mode": wifi.mode.as_api_value() });
585
586 if matches!(wifi.mode, WiFiMode::Station) {
587 if let Some(v) = empty_to_none(&wifi.sta_ssid) {
588 body["sta_ssid"] = json!(v);
589 }
590 if let Some(v) = empty_to_none(&wifi.sta_password) {
591 body["sta_password"] = json!(v);
592 }
593 }
594
595 if matches!(wifi.mode, WiFiMode::WifiAp) {
596 body["ap_ssid"] = json!(wifi.ap_ssid.trim());
597 body["ap_password"] = json!(wifi.ap_password.trim());
598 body["ap_dhcp"] = json!(wifi.ap_dhcp);
599 body["ap_leases"] = json!(wifi.ap_leases);
600 body["ap_burst"] = json!(wifi.ap_burst);
601 }
602
603 if let Some(ch) = channel {
604 body["channel"] = json!(ch);
605 }
606
607 if wifi.mode.is_esp_now() {
608 let peer_mac = wifi.peer_mac.trim();
609 if !peer_mac.is_empty() {
610 if let Err(message) = validate_peer_mac(peer_mac) {
611 self.set_error(message);
612 return;
613 }
614 }
615 body["peer_mac"] = json!(peer_mac);
616 body["ht40"] = json!(wifi.ht40.as_api_value());
617 }
618
619 if let Some(device) = self.state.device_mut_by_id(id) {
624 device.forms.wifi = wifi.clone();
625 }
626 self.submit_device_post(id, "set_wifi", "config/wifi", Some(body));
627 }
628
629 fn apply_pairing_preset(
630 &mut self,
631 preset: PairingPreset,
632 device_ids: [String; 2],
633 channel: u8,
634 ) {
635 if preset.requires_v07() {
636 for id in &device_ids {
637 let Some(device) = self.state.devices.iter().find(|d| d.id == *id) else {
638 self.set_error(format!("Device not found: {id}"));
639 return;
640 };
641 if device.firmware_verified != Some(true) {
642 self.set_error(format!("Device {id} firmware not verified"));
643 return;
644 }
645 if !device.supports_v07_modes() {
646 self.set_error(format!(
647 "Preset '{}' requires esp-csi-cli-rs ≥ 0.7.0 on both boards",
648 preset.label()
649 ));
650 return;
651 }
652 }
653 }
654
655 let ch = channel.to_string();
656 let ap_ssid = "esp-csi-ap".to_owned();
657
658 let (wifi_a, wifi_b, proto_a, proto_b, traffic_a, traffic_b) = match preset {
659 PairingPreset::SoftApLab => (
660 WiFiForm {
661 mode: WiFiMode::WifiAp,
662 ap_ssid: ap_ssid.clone(),
663 channel: ch.clone(),
664 ..WiFiForm::default()
665 },
666 WiFiForm {
667 mode: WiFiMode::Station,
668 sta_ssid: ap_ssid,
669 ..WiFiForm::default()
670 },
671 Some(WifiProtocol::N),
672 Some(WifiProtocol::N),
673 Some(TrafficForm {
679 frequency_hz: "1000".to_owned(),
680 unsolicited: true,
681 }),
682 Some(TrafficForm {
683 frequency_hz: "0".to_owned(),
684 unsolicited: false,
685 }),
686 ),
687 PairingPreset::EspNowFastSimplex => (
688 WiFiForm {
689 mode: WiFiMode::EspNowFastCollector,
690 channel: ch.clone(),
691 ..WiFiForm::default()
692 },
693 WiFiForm {
694 mode: WiFiMode::EspNowFastSource,
695 channel: ch,
696 ..WiFiForm::default()
697 },
698 None,
699 None,
700 None,
701 None,
702 ),
703 PairingPreset::EspNowBalanced => (
704 WiFiForm {
705 mode: WiFiMode::EspNowCentral,
706 channel: ch.clone(),
707 ..WiFiForm::default()
708 },
709 WiFiForm {
710 mode: WiFiMode::EspNowPeripheral,
711 channel: ch,
712 ..WiFiForm::default()
713 },
714 None,
715 None,
716 None,
717 None,
718 ),
719 };
720
721 let mut queue = Vec::new();
722 let push_device = |queue: &mut Vec<(String, DeviceAction)>,
723 id: &str,
724 wifi: WiFiForm,
725 protocol: Option<WifiProtocol>,
726 traffic: Option<TrafficForm>| {
727 queue.push((id.to_owned(), DeviceAction::ResetConfig));
728 queue.push((id.to_owned(), DeviceAction::SetWifi(wifi)));
729 if let Some(p) = protocol {
730 queue.push((id.to_owned(), DeviceAction::SetProtocol(p)));
731 }
732 if let Some(t) = traffic {
733 queue.push((id.to_owned(), DeviceAction::SetTraffic(t)));
734 }
735 };
736
737 push_device(
738 &mut queue,
739 &device_ids[0],
740 wifi_a,
741 proto_a,
742 traffic_a,
743 );
744 push_device(
745 &mut queue,
746 &device_ids[1],
747 wifi_b,
748 proto_b,
749 traffic_b,
750 );
751
752 self.state.push_event(format!(
753 "Applying preset '{}' to {} and {}",
754 preset.label(),
755 device_ids[0],
756 device_ids[1]
757 ));
758 self.start_step_queue(queue, "Pairing preset applied to both devices".to_owned());
759 }
760
761 fn start_step_queue(&mut self, queue: Vec<(String, DeviceAction)>, done_message: String) {
764 self.step_queue = queue;
765 self.step_queue_done_message = done_message;
766 self.run_next_step();
767 }
768
769 fn run_next_step(&mut self) {
770 if let Some((id, action)) = self.step_queue.first().cloned() {
771 self.process_device_action(id, action);
772 }
773 }
774
775 fn advance_step_queue(&mut self, success: bool) {
776 if self.step_queue.is_empty() {
777 return;
778 }
779 if success {
780 self.step_queue.remove(0);
781 if self.step_queue.is_empty() {
782 let message = std::mem::take(&mut self.step_queue_done_message);
783 self.state.push_event(message.clone());
784 self.state.transient.status_message = message;
785 } else {
786 self.run_next_step();
787 }
788 } else {
789 self.step_queue.clear();
790 self.step_queue_done_message.clear();
791 self.state
792 .push_event("Config step sequence aborted due to error".to_owned());
793 }
794 }
795
796 fn maybe_poll_devices(&mut self, ctx: &egui::Context) {
798 let now = ctx.input(|i| i.time);
799 let due = match self.last_devices_poll {
800 None => true,
801 Some(last) => now - last >= DEVICE_POLL_INTERVAL_SECS,
802 };
803 if due {
804 self.last_devices_poll = Some(now);
805 self.state.push_intent(UserIntent::FetchDevices);
806 }
807 }
808
809 fn process_core_events(&mut self) {
811 let mut followups: Vec<UserIntent> = Vec::new();
812 while let Some(event) = self.core.try_recv() {
813 match event {
814 CoreEvent::ApiResponse(response) => {
815 self.handle_api_response(response, &mut followups);
816 }
817 CoreEvent::WebSocketConnected { device_id } => {
818 if let Some(device) = self.state.device_mut_by_id(&device_id) {
819 device.ws_connected = true;
820 }
821 self.state.transient.status_message =
822 format!("WebSocket connected ({device_id})");
823 self.state.transient.error_message.clear();
824 self.state.push_event(format!("[{device_id}] WebSocket connected"));
825 }
826 CoreEvent::WebSocketDisconnected { device_id, reason } => {
827 if let Some(device) = self.state.device_mut_by_id(&device_id) {
828 device.ws_connected = false;
829 }
830 self.finalize_recording(&device_id);
833 self.state
834 .push_event(format!("[{device_id}] WebSocket disconnected: {reason}"));
835 followups.push(UserIntent::FetchDevices);
837 }
838 CoreEvent::WebSocketFrame { device_id, bytes } => {
839 if let Some(recorder) = self.recorders.get_mut(&device_id) {
840 let now = chrono::Utc::now().timestamp_micros();
841 let _ = recorder.record_frame(&bytes, now);
842 if let Some(device) = self.state.device_mut_by_id(&device_id) {
843 device.recorded_frames = recorder.frames_written;
844 device.record_decode_errors = recorder.decode_errors;
845 }
846 }
847 if let Some(device) = self.state.device_mut_by_id(&device_id) {
848 device.push_frame(&bytes);
849 }
850 }
851 CoreEvent::Log(line) => {
852 self.state.push_event(line);
853 }
854 }
855 }
856 for followup in followups {
857 self.state.push_intent(followup);
858 }
859 }
860
861 fn handle_api_response(&mut self, response: ApiResponseEvent, followups: &mut Vec<UserIntent>) {
863 if response.label == "fetch_devices" {
864 self.handle_device_list(response, followups);
865 return;
866 }
867
868 let device_label = response.device_id.clone().unwrap_or_default();
869
870 if response.success {
871 self.state.transient.status_message = format!(
872 "[{}] {} (HTTP {}): {}",
873 device_label, response.label, response.status, response.message
874 );
875 self.state.transient.error_message.clear();
876 } else {
877 self.state.transient.error_message =
878 format_error(&response.label, response.status, &response.message);
879 }
880
881 self.state.push_event(format!(
882 "[{}] {} -> HTTP {}: {}",
883 device_label, response.label, response.status, response.message
884 ));
885
886 if response.status == 404 {
888 followups.push(UserIntent::FetchDevices);
889 }
890
891 let Some(id) = response.device_id.clone() else {
892 return;
893 };
894
895 if response.success {
896 if let Some(device) = self.state.device_mut_by_id(&id) {
897 match response.label.as_str() {
898 "fetch_config" => {
899 if let Some(config) =
900 response.data.clone().and_then(parse_envelope::<DeviceConfig>)
901 {
902 let applied = device.apply_device_config(config);
903 if applied == 0 && !device.auto_resetting_cache {
904 device.auto_resetting_cache = true;
905 followups.push(UserIntent::Device {
906 id: id.clone(),
907 action: DeviceAction::ResetConfig,
908 });
909 } else {
910 device.auto_resetting_cache = false;
911 }
912 } else {
913 device.auto_resetting_cache = false;
914 }
915 }
916 "fetch_info" => {
917 if let Some(info) =
918 response.data.clone().and_then(parse_envelope::<DeviceInfo>)
919 {
920 device.firmware_verified = Some(true);
921 device.latest_info = Some(info);
922 }
923 }
924 "fetch_status" => {
925 if let Some(status) =
926 response.data.clone().and_then(parse_envelope::<ControlStatus>)
927 {
928 device.apply_control_status(status);
929 }
930 }
931 "start_collection" => device.collection_running = Some(true),
932 "stop_collection" => device.collection_running = Some(false),
933 "reset_device" => {
934 device.collection_running = Some(false);
935 device.firmware_verified = None;
936 device.latest_info = None;
937 }
938 "reset_config" | "set_wifi" | "set_traffic" | "set_csi" | "set_csi_preset"
941 | "set_collection_mode" | "set_output_mode" | "set_protocol"
942 | "set_rate" | "set_io_tasks" | "set_csi_delivery" => {
943 followups.push(UserIntent::Device {
944 id: id.clone(),
945 action: DeviceAction::FetchConfig,
946 });
947 }
948 _ => {}
949 }
950 }
951 } else if response.label == "fetch_info" && response.status != 0 {
952 if let Some(device) = self.state.device_mut_by_id(&id) {
953 device.firmware_verified = Some(false);
954 }
955 } else if response.label == "reset_config" {
956 if let Some(device) = self.state.device_mut_by_id(&id) {
957 device.auto_resetting_cache = false;
958 }
959 }
960
961 self.maybe_advance_step_queue(&response);
962 }
963
964 fn maybe_advance_step_queue(&mut self, response: &ApiResponseEvent) {
965 if self.step_queue.is_empty() {
966 return;
967 }
968 let Some(resp_id) = response.device_id.as_deref() else {
969 return;
970 };
971 if !self.step_queue.iter().any(|(id, _)| id == resp_id) {
972 return;
973 }
974 if !response.success {
975 self.advance_step_queue(false);
976 return;
977 }
978 let Some((head_id, head_action)) = self.step_queue.first() else {
979 return;
980 };
981 if head_id != resp_id {
982 return;
983 }
984 if response.label == step_action_label(head_action) {
985 self.advance_step_queue(true);
986 }
987 }
988
989 fn handle_device_list(&mut self, response: ApiResponseEvent, followups: &mut Vec<UserIntent>) {
991 if !response.success {
992 self.state.transient.server_status = ServerStatus::Disconnected;
993 self.state.transient.error_message =
994 format_error("fetch_devices", response.status, &response.message);
995 return;
996 }
997
998 self.state.transient.server_status = ServerStatus::Connected;
999
1000 let Some(entries) = response.data.and_then(parse_device_list) else {
1001 return;
1002 };
1003
1004 let outcome = self.state.reconcile_devices(entries);
1005
1006 for id in &outcome.removed_ids {
1007 self.core.submit(CoreCommand::DisconnectWebSocket {
1008 device_id: id.clone(),
1009 });
1010 self.finalize_recording(id);
1011 self.state.push_event(format!("[{id}] device removed"));
1012 }
1013
1014 for id in &outcome.new_ids {
1015 if let Some(device) = self.state.device_mut_by_id(id) {
1016 device.details_loaded = true;
1017 }
1018 followups.push(UserIntent::Device {
1019 id: id.clone(),
1020 action: DeviceAction::FetchInfo,
1021 });
1022 followups.push(UserIntent::Device {
1023 id: id.clone(),
1024 action: DeviceAction::FetchConfig,
1025 });
1026 followups.push(UserIntent::Device {
1027 id: id.clone(),
1028 action: DeviceAction::FetchStatus,
1029 });
1030 }
1031
1032 if outcome.changed {
1033 let count = self.state.devices.len();
1034 self.state.transient.status_message = format!("{count} device(s) attached");
1035 self.state
1036 .push_event(format!("Device set changed: {count} attached"));
1037 }
1038 }
1039
1040 fn apply_selector_intent(&mut self, intent: UserIntent) {
1042 match intent {
1043 UserIntent::ToggleDeviceSelection(id) => self.state.toggle_selection(id),
1044 UserIntent::SelectAllDevices => {
1045 self.state.selected_device_ids = self.device_ids();
1046 }
1047 UserIntent::ClearDeviceSelection => {
1048 self.state.selected_device_ids.clear();
1049 }
1050 other => self.state.push_intent(other),
1051 }
1052 }
1053
1054 fn render_top_bar(&mut self, ctx: &egui::Context) {
1056 egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
1057 ui.horizontal_wrapped(|ui| {
1058 ui.label("Host");
1059 ui.add(
1060 egui::TextEdit::singleline(&mut self.state.server_host).desired_width(140.0),
1061 );
1062 ui.label("Port");
1063 ui.add(
1064 egui::TextEdit::singleline(&mut self.state.server_port).desired_width(60.0),
1065 );
1066
1067 if ui.button("Connect").clicked() {
1068 self.state.transient.server_status = ServerStatus::Connecting;
1071 self.state.push_intent(UserIntent::FetchDevices);
1072 }
1073
1074 server_status_indicator(ui, self.state.transient.server_status);
1075
1076 ui.separator();
1077 ui.label("Devices");
1078
1079 let selected_text = ui::selector::selection_label(&self.state);
1080 let mut selector_intents: Vec<UserIntent> = Vec::new();
1081 egui::ComboBox::from_id_salt("device_selector")
1082 .selected_text(selected_text)
1083 .width(220.0)
1084 .show_ui(ui, |ui| {
1085 ui::selector::render_popup(ui, &self.state, &mut selector_intents);
1086 });
1087 for intent in selector_intents {
1088 self.apply_selector_intent(intent);
1089 }
1090
1091 if ui.button("Refresh Devices").clicked() {
1092 self.state.push_intent(UserIntent::FetchDevices);
1093 }
1094 });
1095
1096 let selected = self.state.selected_device_ids.clone();
1097 ui.horizontal_wrapped(|ui| {
1098 let has_selection = !selected.is_empty();
1099 if ui.add_enabled(has_selection, egui::Button::new("Fetch Info")).clicked() {
1100 for id in &selected {
1101 self.state.push_device_action(id.clone(), DeviceAction::FetchInfo);
1102 }
1103 }
1104 if ui.add_enabled(has_selection, egui::Button::new("Fetch Config")).clicked() {
1105 for id in &selected {
1106 self.state.push_device_action(id.clone(), DeviceAction::FetchConfig);
1107 }
1108 }
1109 if ui.add_enabled(has_selection, egui::Button::new("Fetch Status")).clicked() {
1110 for id in &selected {
1111 self.state.push_device_action(id.clone(), DeviceAction::FetchStatus);
1112 }
1113 }
1114 });
1115
1116 ui.horizontal_wrapped(|ui| {
1117 tab_button(ui, &mut self.state, Tab::Devices, "Devices");
1118 tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
1119 tab_button(ui, &mut self.state, Tab::Config, "Config");
1120 tab_button(ui, &mut self.state, Tab::Control, "Control");
1121 tab_button(ui, &mut self.state, Tab::Stream, "Stream");
1122 });
1123
1124 if !self.state.transient.status_message.is_empty() {
1125 ui.add(
1126 egui::Label::new(format!("Status: {}", self.state.transient.status_message))
1127 .wrap(),
1128 );
1129 }
1130
1131 if !self.state.transient.error_message.is_empty() {
1132 ui.add(
1133 egui::Label::new(
1134 egui::RichText::new(format!(
1135 "Error: {}",
1136 self.state.transient.error_message
1137 ))
1138 .color(egui::Color32::from_rgb(220, 80, 80)),
1139 )
1140 .wrap(),
1141 );
1142 }
1143 });
1144 }
1145}
1146
1147impl eframe::App for CsiClientApp {
1148 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
1156 self.process_core_events();
1157 self.maybe_poll_devices(ctx);
1158 self.process_intents();
1159
1160 self.render_top_bar(ctx);
1161
1162 let active_tab = self.state.transient.active_tab;
1163 let mut intents: Vec<UserIntent> = Vec::new();
1164 let mut tagged_actions: Vec<(String, DeviceAction)> = Vec::new();
1167 let mut export_dir = std::mem::take(&mut self.state.export_dir);
1170
1171 egui::CentralPanel::default().show(ctx, |ui| match active_tab {
1172 Tab::Devices => ui::devices::render(ui, &mut self.state, &mut intents),
1173 _ => {
1174 let selected_ids = self.state.selected_device_ids.clone();
1175 if selected_ids.is_empty() {
1176 ui.heading("No device selected");
1177 ui.label("Select one or more devices from the Devices tab or the top bar.");
1178 return;
1179 }
1180
1181 if matches!(active_tab, Tab::Control | Tab::Stream) {
1182 ui.horizontal_wrapped(|ui| {
1183 ui.strong(format!("{} selected", selected_ids.len()));
1184 ui.separator();
1185 if ui.button("Start Selected").clicked() {
1186 intents.push(UserIntent::StartSelectedCollections {
1187 duration_seconds: String::new(),
1188 });
1189 }
1190 if ui.button("Stop Selected").clicked() {
1191 intents.push(UserIntent::StopSelectedCollections);
1192 }
1193 if active_tab == Tab::Stream {
1194 ui.separator();
1195 if ui.button("Start Recording (Selected)").clicked() {
1196 intents.push(UserIntent::StartSelectedRecording);
1197 }
1198 if ui.button("Stop Recording (Selected)").clicked() {
1199 intents.push(UserIntent::StopSelectedRecording);
1200 }
1201 }
1202 });
1203 ui.separator();
1204 }
1205
1206 if active_tab == Tab::Stream {
1207 ui::stream::render_export_dir(ui, &mut export_dir);
1208 ui.separator();
1209 }
1210
1211 ui::detail::render(
1212 ui,
1213 active_tab,
1214 &mut self.state,
1215 &selected_ids,
1216 &mut tagged_actions,
1217 self.profile.as_ref(),
1218 );
1219 }
1220 });
1221
1222 self.state.export_dir = export_dir;
1223
1224 for intent in intents {
1225 self.state.push_intent(intent);
1226 }
1227 for (id, action) in tagged_actions {
1228 self.state.push_device_action(id, action);
1229 }
1230
1231 ctx.request_repaint_after(std::time::Duration::from_millis(16));
1232 }
1233}
1234
1235fn server_status_indicator(ui: &mut egui::Ui, status: ServerStatus) {
1237 let color = match status {
1238 ServerStatus::Connected => egui::Color32::from_rgb(60, 180, 75),
1239 ServerStatus::Connecting => egui::Color32::from_rgb(230, 180, 40),
1240 ServerStatus::Disconnected => egui::Color32::from_rgb(220, 80, 80),
1241 ServerStatus::Unknown => egui::Color32::GRAY,
1242 };
1243 ui.colored_label(color, format!("● {}", status.label()));
1244}
1245
1246fn parse_device_list(data: Value) -> Option<Vec<DeviceListEntry>> {
1248 if let Ok(list) = serde_json::from_value::<Vec<DeviceListEntry>>(data.clone()) {
1249 return Some(list);
1250 }
1251 if let Some(inner) = data.get("data") {
1252 return serde_json::from_value::<Vec<DeviceListEntry>>(inner.clone()).ok();
1253 }
1254 None
1255}
1256
1257fn parse_envelope<T: serde::de::DeserializeOwned>(data: serde_json::Value) -> Option<T> {
1259 if let Ok(value) = serde_json::from_value::<T>(data.clone()) {
1260 return Some(value);
1261 }
1262 if let Some(inner) = data.get("data") {
1263 return serde_json::from_value::<T>(inner.clone()).ok();
1264 }
1265 None
1266}
1267
1268fn full_config_steps(id: &str, forms: &DeviceForms) -> Vec<(String, DeviceAction)> {
1273 [
1274 DeviceAction::SetWifi(forms.wifi.clone()),
1275 DeviceAction::SetProtocol(forms.protocol),
1276 DeviceAction::SetTraffic(forms.traffic.clone()),
1277 DeviceAction::SetCsi(forms.csi.clone()),
1278 DeviceAction::SetPhyRate(forms.phy_rate.clone()),
1279 DeviceAction::SetIoTasks(forms.io_tasks.clone()),
1280 DeviceAction::SetCsiDelivery(forms.csi_delivery.clone()),
1281 DeviceAction::SetCollectionMode(forms.collection_mode),
1282 DeviceAction::SetOutputMode(forms.output_mode),
1283 ]
1284 .into_iter()
1285 .map(|action| (id.to_owned(), action))
1286 .collect()
1287}
1288
1289fn validate_full_config(device: &DeviceState) -> Result<(), String> {
1296 let forms = &device.forms;
1297 let wifi = &forms.wifi;
1298
1299 if wifi.mode.requires_v07() && !device.supports_v07_modes() {
1300 return Err(format!(
1301 "mode '{}' requires esp-csi-cli-rs ≥ 0.7.0 on this device",
1302 wifi.mode.as_api_value()
1303 ));
1304 }
1305 if matches!(wifi.mode, WiFiMode::Station) {
1306 validate_sta_field("STA SSID", &wifi.sta_ssid)?;
1307 validate_sta_field("STA password", &wifi.sta_password)?;
1308 }
1309 if matches!(wifi.mode, WiFiMode::WifiAp) {
1310 validate_sta_field("AP SSID", &wifi.ap_ssid)?;
1311 validate_sta_field("AP password", &wifi.ap_password)?;
1312 }
1313 if !wifi.channel.trim().is_empty() && parse_optional_u16(&wifi.channel).is_none() {
1314 return Err("Wi-Fi channel must be a valid number".to_owned());
1315 }
1316 if wifi.mode.is_esp_now() && !wifi.peer_mac.trim().is_empty() {
1317 validate_peer_mac(wifi.peer_mac.trim())?;
1318 }
1319 if parse_required_u64(&forms.traffic.frequency_hz).is_none() {
1320 return Err("Traffic frequency must be a non-negative integer".to_owned());
1321 }
1322 if parse_required_u32(&forms.csi.val_scale_cfg).is_none() {
1323 return Err("val_scale_cfg must be a valid u32 number".to_owned());
1324 }
1325 if forms.phy_rate.rate.trim().is_empty() {
1326 return Err("PHY rate must not be empty".to_owned());
1327 }
1328 Ok(())
1329}
1330
1331fn step_action_label(action: &DeviceAction) -> &str {
1333 match action {
1334 DeviceAction::ResetConfig => "reset_config",
1335 DeviceAction::SetWifi(_) => "set_wifi",
1336 DeviceAction::SetTraffic(_) => "set_traffic",
1337 DeviceAction::SetProtocol(_) => "set_protocol",
1338 DeviceAction::SetCsi(_) => "set_csi",
1339 DeviceAction::SetCsiPreset(_) => "set_csi_preset",
1340 DeviceAction::SetPhyRate(_) => "set_rate",
1341 DeviceAction::SetIoTasks(_) => "set_io_tasks",
1342 DeviceAction::SetCsiDelivery(_) => "set_csi_delivery",
1343 DeviceAction::SetCollectionMode(_) => "set_collection_mode",
1344 DeviceAction::SetOutputMode(_) => "set_output_mode",
1345 _ => "",
1346 }
1347}
1348
1349fn validate_peer_mac(mac: &str) -> Result<(), String> {
1351 let sep = if mac.contains(':') {
1352 ':'
1353 } else if mac.contains('-') {
1354 '-'
1355 } else {
1356 return Err("Peer MAC must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_owned());
1357 };
1358 let octets: Vec<&str> = mac.split(sep).collect();
1359 if octets.len() != 6
1360 || octets
1361 .iter()
1362 .any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit()))
1363 {
1364 return Err(format!("Invalid peer MAC '{mac}' (use aa:bb:cc:dd:ee:ff)"));
1365 }
1366 Ok(())
1367}
1368
1369fn validate_sta_field(label: &str, value: &str) -> Result<(), String> {
1374 if value.is_empty() {
1375 return Ok(());
1376 }
1377 if value.len() > STA_FIELD_MAX_BYTES {
1378 return Err(format!("{label} exceeds 32-byte firmware limit"));
1379 }
1380 if value.contains('\r') || value.contains('\n') {
1381 return Err(format!("{label} must not contain newlines"));
1382 }
1383 if value.contains('\'') && value.contains('"') {
1384 return Err(format!(
1385 "{label} cannot contain both ' and \" — firmware tokenizer cannot disambiguate"
1386 ));
1387 }
1388 Ok(())
1389}
1390
1391fn format_error(label: &str, status: u16, message: &str) -> String {
1393 let hint = match status {
1394 404 => Some("device not found — it may have been unplugged"),
1395 412 => Some("firmware not verified — try Fetch Info or Reset Device"),
1396 503 => Some("ESP32 not connected, or operation not valid for current state"),
1397 502 => Some("device responded but the info block was malformed"),
1398 504 => Some("info block timed out — firmware may not be esp-csi-cli-rs"),
1399 403 => Some("output mode is dump — switch to stream/both before opening WebSocket"),
1400 _ => None,
1401 };
1402 match hint {
1403 Some(h) => format!("{label} failed (HTTP {status}): {message} — {h}"),
1404 None => format!("{label} failed (HTTP {status}): {message}"),
1405 }
1406}
1407
1408fn parse_optional_u16(input: &str) -> Option<u16> {
1410 let trimmed = input.trim();
1411 if trimmed.is_empty() {
1412 return None;
1413 }
1414 trimmed.parse::<u16>().ok()
1415}
1416
1417fn parse_required_u64(input: &str) -> Option<u64> {
1419 input.trim().parse::<u64>().ok()
1420}
1421
1422fn parse_required_u32(input: &str) -> Option<u32> {
1424 input.trim().parse::<u32>().ok()
1425}
1426
1427fn parse_optional_u64(input: &str) -> Option<u64> {
1429 let trimmed = input.trim();
1430 if trimmed.is_empty() {
1431 return None;
1432 }
1433 trimmed.parse::<u64>().ok()
1434}
1435
1436fn empty_to_none(input: &str) -> Option<String> {
1438 if input.trim().is_empty() {
1439 None
1440 } else {
1441 Some(input.to_owned())
1442 }
1443}
1444
1445fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
1447 let selected = state.transient.active_tab == tab;
1448 if ui.selectable_label(selected, label).clicked() {
1449 state.transient.active_tab = tab;
1450 }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456
1457 #[test]
1458 fn full_config_steps_cover_every_section_in_order() {
1459 let forms = DeviceForms::default();
1460 let steps = full_config_steps("dev-a", &forms);
1461 let labels: Vec<&str> = steps
1462 .iter()
1463 .map(|(id, action)| {
1464 assert_eq!(id, "dev-a");
1465 step_action_label(action)
1466 })
1467 .collect();
1468 assert_eq!(
1469 labels,
1470 vec![
1471 "set_wifi",
1472 "set_protocol",
1473 "set_traffic",
1474 "set_csi",
1475 "set_rate",
1476 "set_io_tasks",
1477 "set_csi_delivery",
1478 "set_collection_mode",
1479 "set_output_mode",
1480 ]
1481 );
1482 }
1483
1484 fn test_app() -> CsiClientApp {
1485 CsiClientApp {
1486 state: AppState::with_defaults(),
1487 core: CoreHandle::new(),
1488 last_devices_poll: None,
1489 recorders: HashMap::new(),
1490 step_queue: Vec::new(),
1491 step_queue_done_message: String::new(),
1492 profile: Arc::new(StandardClientProfile),
1493 }
1494 }
1495
1496 #[test]
1497 fn load_config_file_applies_to_device() {
1498 let mut app = test_app();
1499 app.state.devices.push(DeviceState::new("dev-a"));
1500
1501 let path = std::env::temp_dir().join("csi_webclient_load_apply_test.json");
1502 let snapshot = ConfigSnapshotFile {
1503 format: 1,
1504 device_id: Some("dev-a".to_owned()),
1505 saved_at: None,
1506 forms: DeviceForms::default(),
1507 };
1508 std::fs::write(&path, serde_json::to_string(&snapshot).unwrap()).unwrap();
1509
1510 app.load_config_file("dev-a", path.to_str().unwrap());
1511 let _ = std::fs::remove_file(&path);
1512
1513 assert_eq!(app.step_queue.len(), 9);
1516 assert!(app.state.transient.error_message.is_empty());
1517 }
1518
1519 #[test]
1520 fn load_config_file_with_invalid_values_fills_form_but_reports_error() {
1521 let mut app = test_app();
1522 app.state.devices.push(DeviceState::new("dev-a"));
1523
1524 let mut forms = DeviceForms::default();
1525 forms.traffic.frequency_hz = "fast".to_owned();
1526 let path = std::env::temp_dir().join("csi_webclient_load_invalid_test.json");
1527 let snapshot = ConfigSnapshotFile {
1528 format: 1,
1529 device_id: None,
1530 saved_at: None,
1531 forms,
1532 };
1533 std::fs::write(&path, serde_json::to_string(&snapshot).unwrap()).unwrap();
1534
1535 app.load_config_file("dev-a", path.to_str().unwrap());
1536 let _ = std::fs::remove_file(&path);
1537
1538 assert!(app.step_queue.is_empty());
1541 assert!(!app.state.transient.error_message.is_empty());
1542 let device = app.state.devices.iter().find(|d| d.id == "dev-a").unwrap();
1543 assert_eq!(device.forms.traffic.frequency_hz, "fast");
1544 }
1545
1546 #[test]
1547 fn validate_full_config_accepts_defaults() {
1548 let device = DeviceState::new("dev-a");
1549 assert!(validate_full_config(&device).is_ok());
1550 }
1551
1552 #[test]
1553 fn validate_full_config_rejects_bad_fields() {
1554 let mut device = DeviceState::new("dev-a");
1555 device.forms.traffic.frequency_hz = "fast".to_owned();
1556 assert!(validate_full_config(&device).is_err());
1557
1558 let mut device = DeviceState::new("dev-a");
1559 device.forms.wifi.channel = "not-a-channel".to_owned();
1560 assert!(validate_full_config(&device).is_err());
1561
1562 let mut device = DeviceState::new("dev-a");
1563 device.forms.wifi.mode = WiFiMode::EspNowCentral;
1564 device.forms.wifi.peer_mac = "zz:zz".to_owned();
1565 assert!(validate_full_config(&device).is_err());
1566
1567 let mut device = DeviceState::new("dev-a");
1569 device.forms.wifi.mode = WiFiMode::WifiAp;
1570 assert!(validate_full_config(&device).is_err());
1571 }
1572}