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