csi_webclient/ui/
devices.rs1use crate::state::{AppState, DeviceAction, PairingPreset, UserIntent};
2
3pub 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
79const 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 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
151fn 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}