Skip to main content

csi_webclient/
app.rs

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;
17/// How often to poll `GET /api/devices` for hotplug discovery.
18const DEVICE_POLL_INTERVAL_SECS: f64 = 2.0;
19
20/// Top-level egui application.
21///
22/// This type orchestrates the intent-command-event flow:
23///
24/// - reads and drains user intents from [`crate::state::AppState`]
25/// - submits commands to [`crate::core::CoreHandle`]
26/// - applies resulting core events back into state
27pub struct CsiClientApp {
28    state: AppState,
29    core: CoreHandle,
30    /// Wall-clock (egui time) of the last device-discovery poll.
31    last_devices_poll: Option<f64>,
32    /// Active Parquet recordings, keyed by device id. Held here (not in
33    /// [`AppState`], which is `Clone`) because the file writer is not cloneable.
34    recorders: HashMap<String, Recorder>,
35    /// Sequential config steps (device id, action); drained on each 2xx
36    /// response. Used by pairing presets and full-config apply/copy.
37    step_queue: Vec<(String, DeviceAction)>,
38    /// Status/event message emitted when the step queue completes.
39    step_queue_done_message: String,
40    /// Injectable per-deployment behavior (protocols, presets, export labels).
41    profile: Arc<dyn ClientProfile>,
42}
43
44impl CsiClientApp {
45    /// Create a new app instance with default state, a running core worker, and
46    /// the no-op [`StandardClientProfile`].
47    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
48        Self::with_profile(cc, Arc::new(StandardClientProfile))
49    }
50
51    /// Create a new app instance with an injected [`ClientProfile`].
52    ///
53    /// A companion binary calls this to add chip- or standard-specific behavior
54    /// (extra PHY protocols, CSI presets, `data_format` labeling) without the
55    /// open library naming any of it.
56    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    /// Drain queued user intents and translate them into core commands.
72    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    /// All currently known device ids (snapshot).
133    fn device_ids(&self) -> Vec<String> {
134        self.state.devices.iter().map(|d| d.id.clone()).collect()
135    }
136
137    /// Translate one per-device action into an HTTP request or WebSocket command.
138    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                    // Send `unsolicited` only for the WiFi infra modes AND
152                    // firmware ≥ 0.7.0: the flag is meaningless elsewhere,
153                    // and older firmware's CLI rejects the whole set-traffic
154                    // command on an unknown flag — silently discarding the
155                    // frequency too. The stored form is kept in sync by
156                    // SetWifi/SetTraffic dispatch, so the mode is current
157                    // even mid-pairing-preset.
158                    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    /// Write `id`'s form values to a JSON snapshot file.
321    ///
322    /// An empty path auto-names the file in the export directory and writes
323    /// the chosen path back into the Config-tab field.
324    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    /// Load a JSON snapshot file into `id`'s form values and push every
365    /// section to the device (same step sequence as [`Self::apply_full_config`]).
366    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                // Push the loaded config to the device right away — a loaded
387                // snapshot that stays form-only silently leaves the device on
388                // its previous configuration. On validation failure the form
389                // keeps the loaded values so the user can correct and re-apply.
390                self.apply_full_config(id);
391            }
392            Err(e) => self.set_error(format!("Invalid config file {path}: {e}")),
393        }
394    }
395
396    /// Push every config section from `id`'s current forms to the device as
397    /// one sequential step run (each step submits after the previous 2xx).
398    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    /// Copy `source_id`'s form values onto `id` and apply them to the device.
414    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    /// Open a Parquet recording for `id`'s incoming CSI stream.
434    ///
435    /// Requires the device chip to be known (decode is chip-specific). Frames
436    /// are only captured while the WebSocket is connected, so the UI nudges the
437    /// user to connect first.
438    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    /// Stop and finalize `id`'s Parquet recording, if any.
474    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    /// User-initiated stop of `id`'s recording.
492    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    /// Submit a GET to `/api/devices/{id}/{suffix}`.
519    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    /// Submit a POST to `/api/devices/{id}/{suffix}`.
524    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        // Every mode accepts a channel. In station mode it is an optional
573        // pre-association hint (blank = inherit from the AP); other modes treat
574        // it as the operating channel.
575        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        // Keep the stored form in sync with what was submitted — a no-op for
620        // UI-driven edits (the form was the source), but required for the
621        // pairing-preset queue, whose later SetTraffic step consults
622        // forms.wifi.mode before the async config re-fetch lands.
623        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                // One-directional lab topology (hardware-verified): the AP
674                // floods unsolicited echo replies at a serial-sustainable
675                // 1000 Hz; the station is receive-only (0 = flood TX task
676                // off). Both boards flooding at once contend for airtime and
677                // collapse the delivered CSI rate.
678                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    /// Begin a sequential config-step run; each step is submitted only after
762    /// the previous one succeeded (2xx).
763    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    /// Push a device-discovery poll if the interval has elapsed.
797    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    /// Poll and apply core worker events without blocking the frame loop.
810    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                    // No more frames will arrive — finalize any recording so the
831                    // Parquet footer is written and the file is readable.
832                    self.finalize_recording(&device_id);
833                    self.state
834                        .push_event(format!("[{device_id}] WebSocket disconnected: {reason}"));
835                    // A close may mean the device was unplugged — re-discover.
836                    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    /// Handle one HTTP response, routing per-device payloads to the device.
862    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        // A 404 on a per-device route means the device went away — re-discover.
887        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                    // Any successful config-mutating POST repopulates a slot in the
939                    // server cache, so re-pull `config` to keep the form in sync.
940                    "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    /// Reconcile a `GET /api/devices` payload and load details for new devices.
990    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    /// Apply device-selection intents immediately (keeps the selector popup responsive).
1041    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    /// Render the shared top panel (host/port, device selector, tabs, status).
1055    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                    // Fire discovery immediately against the freshly-typed
1069                    // address rather than waiting for the next poll tick.
1070                    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    /// Main egui frame update callback.
1149    ///
1150    /// The update order is:
1151    /// 1. apply incoming core events
1152    /// 2. poll device discovery on an interval
1153    /// 3. process queued user intents
1154    /// 4. render UI panels (buffering UI-issued actions, then enqueuing them)
1155    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        // (device_id, action) pairs collected from the per-device columns, routed
1165        // back to the right device after rendering.
1166        let mut tagged_actions: Vec<(String, DeviceAction)> = Vec::new();
1167        // Lift `export_dir` out so the Stream view can edit it without a second
1168        // mutable borrow of `self.state` alongside the selected devices.
1169        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
1235/// Render a colored "● <label>" badge reflecting the server connection state.
1236fn 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
1246/// Parse the `GET /api/devices` payload from a bare array or `data` envelope.
1247fn 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
1257/// Parse a typed payload from a direct value or the standard `data` envelope.
1258fn 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
1268/// Ordered per-device steps that push every config section to the device.
1269///
1270/// Wi-Fi goes first so the mode is settled before the traffic step consults
1271/// it for the `unsolicited` gating (same ordering as the pairing presets).
1272fn 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
1289/// Validate every form field a full-config apply will submit.
1290///
1291/// Mirrors the per-action validation in [`CsiClientApp::process_device_action`]
1292/// / [`CsiClientApp::submit_set_wifi`]: a step that fails validation there is
1293/// never submitted, which would stall the sequential queue — so a loaded or
1294/// copied config is checked up front instead.
1295fn 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
1331/// HTTP label emitted by [`process_device_action`] for sequential queue steps.
1332fn 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
1349/// Validate an ESP-NOW peer MAC (`aa:bb:cc:dd:ee:ff` or `aa-bb-...`).
1350fn 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
1369/// Reject SSID/password values the firmware tokenizer cannot accept.
1370///
1371/// Mirrors the server-side rules from §1.4 of the webserver spec so users
1372/// see the failure inline rather than as a 400 round-trip.
1373fn 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
1391/// Map known status codes onto operator-friendly hints.
1392fn 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
1408/// Parse an optional `u16` where empty input means `None`.
1409fn 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
1417/// Parse a required `u64`.
1418fn parse_required_u64(input: &str) -> Option<u64> {
1419    input.trim().parse::<u64>().ok()
1420}
1421
1422/// Parse a required `u32`.
1423fn parse_required_u32(input: &str) -> Option<u32> {
1424    input.trim().parse::<u32>().ok()
1425}
1426
1427/// Parse an optional `u64` where empty input means `None`.
1428fn 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
1436/// Convert user text to optional string while preserving significant whitespace.
1437fn 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
1445/// Render one tab selector button and switch active tab on click.
1446fn 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        // Loading must push the config to the device, not just fill the form:
1514        // the sequential queue holds one step per config section.
1515        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        // Nothing is submitted, the error is surfaced, and the loaded values
1539        // stay in the form so the user can correct and re-apply.
1540        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        // v0.7-gated mode with no firmware info at all.
1568        let mut device = DeviceState::new("dev-a");
1569        device.forms.wifi.mode = WiFiMode::WifiAp;
1570        assert!(validate_full_config(&device).is_err());
1571    }
1572}