Skip to main content

csi_webclient/ui/
devices.rs

1use crate::state::{AppState, DeviceAction, PairingPreset, UserIntent};
2
3/// Render the fleet overview: every attached device, per-device collection
4/// control, fleet-wide Start/Stop All, pairing presets, and the global event log.
5pub fn render(ui: &mut egui::Ui, state: &mut AppState, intents: &mut Vec<UserIntent>) {
6    ui.heading("Devices");
7    ui.separator();
8
9    ui.horizontal_wrapped(|ui| {
10        if ui.button("Refresh Devices").clicked() {
11            intents.push(UserIntent::FetchDevices);
12        }
13        ui.separator();
14        if ui.button("Select All").clicked() {
15            intents.push(UserIntent::SelectAllDevices);
16        }
17        if ui.button("Clear Selection").clicked() {
18            intents.push(UserIntent::ClearDeviceSelection);
19        }
20        ui.separator();
21        if ui.button("Start All").clicked() {
22            intents.push(UserIntent::StartAllCollections {
23                duration_seconds: String::new(),
24            });
25        }
26        if ui.button("Stop All").clicked() {
27            intents.push(UserIntent::StopAllCollections);
28        }
29        ui.separator();
30        let has_selection = !state.selected_device_ids.is_empty();
31        if ui
32            .add_enabled(has_selection, egui::Button::new("Start Selected"))
33            .clicked()
34        {
35            intents.push(UserIntent::StartSelectedCollections {
36                duration_seconds: String::new(),
37            });
38        }
39        if ui
40            .add_enabled(has_selection, egui::Button::new("Stop Selected"))
41            .clicked()
42        {
43            intents.push(UserIntent::StopSelectedCollections);
44        }
45    });
46
47    render_pairing_presets(ui, state, intents);
48
49    ui.separator();
50
51    if state.devices.is_empty() {
52        ui.label("No devices attached. Plug in an ESP32 — discovery polls every ~2s.");
53    } else {
54        let content_width = ui.available_width();
55        let table_height = (ui.available_height() * 0.55).max(120.0);
56        egui::ScrollArea::vertical()
57            .id_salt("devices_table_scroll")
58            .auto_shrink([false, false])
59            .max_height(table_height)
60            .show(ui, |ui| {
61                ui.set_width(content_width);
62                render_table(ui, state, intents);
63            });
64    }
65
66    ui.separator();
67    ui.label("Recent events");
68    egui::ScrollArea::vertical()
69        .id_salt("devices_events_scroll")
70        .auto_shrink([false, false])
71        .max_height(ui.available_height().max(80.0))
72        .show(ui, |ui| {
73            for line in state.events.iter().rev().take(80) {
74                ui.add(egui::Label::new(line).wrap());
75            }
76        });
77}
78
79/// Fixed width for the device id cell (checkbox + MAC / alias).
80const ID_COL_WIDTH: f32 = 156.0;
81
82fn render_table(ui: &mut egui::Ui, state: &AppState, intents: &mut Vec<UserIntent>) {
83    egui::Grid::new("devices_grid")
84        .num_columns(8)
85        .striped(true)
86        .spacing([10.0, 4.0])
87        .min_col_width(36.0)
88        .show(ui, |ui| {
89            ui.strong("Device");
90            ui.strong("Port");
91            ui.strong("Serial");
92            ui.strong("Firmware");
93            ui.strong("Collection");
94            ui.strong("WS");
95            ui.strong("Frames");
96            ui.strong("Actions");
97            ui.end_row();
98
99            for device in &state.devices {
100                render_device_cell(ui, state, device, intents);
101
102                ui.label(port_basename(device.port_path.as_deref()));
103                ui.label(tristate(device.serial_connected, "up", "down"));
104                if let Some(fault) = &device.fault {
105                    ui.colored_label(egui::Color32::RED, "FAULT")
106                        .on_hover_text(fault);
107                } else {
108                    ui.label(tristate(device.firmware_verified, "ok", "no"));
109                }
110                ui.label(tristate(device.collection_running, "running", "idle"));
111                ui.label(if device.ws_connected { "on" } else { "off" });
112                ui.label(device.frames_received.to_string());
113
114                ui.push_id(format!("actions_{}", device.id), |ui| {
115                    ui.horizontal(|ui| {
116                        if ui.button("Start").clicked() {
117                            intents.push(UserIntent::Device {
118                                id: device.id.clone(),
119                                action: DeviceAction::StartCollection {
120                                    duration_seconds: device.forms.start_duration_seconds.clone(),
121                                },
122                            });
123                        }
124                        if ui.button("Stop").clicked() {
125                            intents.push(UserIntent::Device {
126                                id: device.id.clone(),
127                                action: DeviceAction::StopCollection,
128                            });
129                        }
130                    });
131                });
132
133                ui.end_row();
134            }
135        });
136
137    // Full-width fault banners — the FAULT cell above is easy to miss and its
138    // hover text invisible until pointed at; a wedged chip needs a human
139    // action (reset button / replug), so spell out the recovery per device.
140    for device in &state.devices {
141        if let Some(fault) = &device.fault {
142            ui.add_space(6.0);
143            ui.colored_label(
144                egui::Color32::RED,
145                format!("⚠ {}: {}", device.id, fault),
146            );
147        }
148    }
149}
150
151/// Checkbox + device id in one grid cell, width-capped so the row stays aligned.
152fn render_device_cell(
153    ui: &mut egui::Ui,
154    state: &AppState,
155    device: &crate::state::DeviceState,
156    intents: &mut Vec<UserIntent>,
157) {
158    ui.allocate_ui_with_layout(
159        egui::vec2(ID_COL_WIDTH, 0.0),
160        egui::Layout::left_to_right(egui::Align::Center),
161        |ui| {
162            ui.set_max_width(ID_COL_WIDTH);
163
164            let mut selected = state.is_selected(&device.id);
165            if ui.checkbox(&mut selected, "").changed() {
166                intents.push(UserIntent::ToggleDeviceSelection(device.id.clone()));
167            }
168
169            let id_text = if let Some(mac) = &device.mac {
170                format!("{}\n{}", device.id, mac)
171            } else {
172                device.id.clone()
173            };
174            let id_label = egui::Label::new(id_text)
175                .wrap()
176                .selectable(selected)
177                .sense(egui::Sense::click());
178            let response = ui.add(id_label).on_hover_text(&device.id);
179            if response.clicked() {
180                intents.push(UserIntent::ToggleDeviceSelection(device.id.clone()));
181            }
182        },
183    );
184}
185
186fn port_basename(port: Option<&str>) -> String {
187    port.map(|p| {
188        p.rsplit(['/', '\\'])
189            .next()
190            .unwrap_or(p)
191            .to_owned()
192    })
193    .unwrap_or_else(|| "?".to_owned())
194}
195
196fn tristate(value: Option<bool>, true_label: &str, false_label: &str) -> String {
197    match value {
198        Some(true) => true_label.to_owned(),
199        Some(false) => false_label.to_owned(),
200        None => "?".to_owned(),
201    }
202}
203
204fn render_pairing_presets(
205    ui: &mut egui::Ui,
206    state: &mut AppState,
207    intents: &mut Vec<UserIntent>,
208) {
209    if state.selected_device_ids.len() != 2 {
210        return;
211    }
212
213    ui.collapsing("Pairing presets (2 devices selected)", |ui| {
214        ui.add(
215            egui::Label::new(format!(
216                "Device 1: {} — Device 2: {}",
217                state.selected_device_ids[0], state.selected_device_ids[1]
218            ))
219            .wrap(),
220        );
221        ui.horizontal(|ui| {
222            ui.label("Channel");
223            ui.add(
224                egui::TextEdit::singleline(&mut state.transient.preset_channel)
225                    .desired_width(48.0),
226            );
227        });
228        ui.add_space(4.0);
229
230        let channel = state.transient.preset_channel.trim().parse::<u8>().unwrap_or(6);
231        let ids = [
232            state.selected_device_ids[0].clone(),
233            state.selected_device_ids[1].clone(),
234        ];
235
236        ui.horizontal_wrapped(|ui| {
237            for preset in [
238                PairingPreset::SoftApLab,
239                PairingPreset::EspNowFastSimplex,
240                PairingPreset::EspNowBalanced,
241            ] {
242                if ui.button(preset.label()).clicked() {
243                    intents.push(UserIntent::Device {
244                        id: ids[0].clone(),
245                        action: DeviceAction::ApplyPairingPreset {
246                            preset,
247                            device_ids: ids.clone(),
248                            channel,
249                        },
250                    });
251                }
252            }
253        });
254        ui.add(
255            egui::Label::new(
256                "Applies reset + Wi-Fi (+ protocol/traffic where needed) to both boards in \
257                 order. SoftAP lab pair: device 1 becomes the AP flooding one-directional \
258                 (unsolicited echo-reply) traffic at 1000 Hz; device 2 becomes a \
259                 receive-only station — collect CSI on device 2.",
260            )
261            .wrap(),
262        );
263    });
264}