1use crate::state::{
2 CollectionMode, CsiDeliveryMode, CsiForm, DeviceAction, DeviceState, Ht40Mode, OutputMode,
3 PHY_RATES, WiFiMode, WifiProtocol,
4};
5
6pub fn render(ui: &mut egui::Ui, device: &mut DeviceState, actions: &mut Vec<DeviceAction>) {
11 ui.add(
12 egui::Label::new(format!("Configuration — {}", device.id))
13 .wrap(),
14 );
15 ui.add_space(8.0);
16 render_body(ui, device, actions);
17}
18
19fn render_body(ui: &mut egui::Ui, device: &mut DeviceState, actions: &mut Vec<DeviceAction>) {
20 let device_id = device.id.clone();
21 let supports_v07 = device.supports_v07_modes();
22 let supports_unsolicited = device.supports_unsolicited();
23 let forms = &mut device.forms;
24 let field_width = text_field_width(ui, 140.0);
25
26 section_header(ui, "Wi-Fi", |ui| {
27 form_row(ui, "Mode", |ui| {
28 wifi_mode_picker(ui, &device_id, supports_v07, &mut forms.wifi.mode);
29 });
30
31 if forms.wifi.mode.requires_v07() && !supports_v07 {
32 ui.colored_label(
33 egui::Color32::YELLOW,
34 "Selected mode requires esp-csi-cli-rs ≥ 0.7.0 on this device.",
35 );
36 }
37
38 if matches!(forms.wifi.mode, WiFiMode::Station) {
39 form_row(ui, "STA SSID", |ui| {
40 ui.add(
41 egui::TextEdit::singleline(&mut forms.wifi.sta_ssid).desired_width(field_width),
42 );
43 });
44
45 form_row(ui, "STA Password", |ui| {
46 ui.add(
47 egui::TextEdit::singleline(&mut forms.wifi.sta_password)
48 .password(true)
49 .desired_width(field_width),
50 );
51 });
52 }
53
54 if matches!(forms.wifi.mode, WiFiMode::WifiAp) {
55 form_row(ui, "AP SSID", |ui| {
56 ui.add(
57 egui::TextEdit::singleline(&mut forms.wifi.ap_ssid).desired_width(field_width),
58 );
59 });
60
61 form_row(ui, "AP Password", |ui| {
62 ui.add(
63 egui::TextEdit::singleline(&mut forms.wifi.ap_password)
64 .password(true)
65 .desired_width(field_width),
66 );
67 });
68
69 ui.checkbox(&mut forms.wifi.ap_dhcp, "AP DHCP (built-in single-lease server)");
70 }
71
72 if forms.wifi.mode.allows_channel() {
73 form_row(ui, "Channel", |ui| {
74 ui.add(egui::TextEdit::singleline(&mut forms.wifi.channel).desired_width(80.0));
75 });
76 } else {
77 ui.add(
78 egui::Label::new(
79 "Channel — inherited from the associated AP (not configurable in station mode).",
80 )
81 .wrap(),
82 );
83 ui.add_space(6.0);
84 }
85
86 if forms.wifi.mode.is_esp_now() {
87 form_row(ui, "Peer MAC", |ui| {
88 ui.add(
89 egui::TextEdit::singleline(&mut forms.wifi.peer_mac)
90 .hint_text("auto")
91 .desired_width(field_width),
92 );
93 });
94
95 form_row(ui, "HT40 secondary", |ui| {
96 ht40_picker(ui, &mut forms.wifi.ht40);
97 });
98
99 ui.add_space(4.0);
100 ui.add(
101 egui::Label::new(
102 "Peer MAC / HT40 apply to all ESP-NOW modes; empty MAC = auto.",
103 )
104 .wrap(),
105 );
106 }
107
108 ui.add_space(8.0);
109 if ui.button("Apply Wi-Fi Config").clicked() {
110 actions.push(DeviceAction::SetWifi(forms.wifi.clone()));
111 }
112
113 ui.add_space(12.0);
114 form_row(ui, "PHY Protocol", |ui| {
115 protocol_picker(ui, &mut forms.protocol);
116 if ui.button("Apply Protocol").clicked() {
117 actions.push(DeviceAction::SetProtocol(forms.protocol));
118 }
119 });
120 ui.add(
121 egui::Label::new(
122 "Applied at the start of each run. Default lr (Long-Range) is proprietary; \
123 use n / ax to associate with a standard AP in station mode.",
124 )
125 .wrap(),
126 );
127 });
128
129 section_header(ui, "Traffic", |ui| {
130 form_row(ui, "Frequency (Hz)", |ui| {
131 ui.add(
132 egui::TextEdit::singleline(&mut forms.traffic.frequency_hz).desired_width(120.0),
133 );
134 if ui.button("Apply Traffic Config").clicked() {
135 actions.push(DeviceAction::SetTraffic(forms.traffic.clone()));
136 }
137 });
138 ui.add(
139 egui::Label::new(
140 "For AP + station lab pairs: AP floods at 1000 Hz, station stays \
141 receive-only (frequency 0).",
142 )
143 .wrap(),
144 );
145
146 if matches!(forms.wifi.mode, WiFiMode::WifiAp | WiFiMode::Station) {
149 let traffic_on = forms
150 .traffic
151 .frequency_hz
152 .trim()
153 .parse::<u64>()
154 .is_ok_and(|hz| hz > 0);
155 ui.add_space(4.0);
156 ui.add_enabled_ui(traffic_on && supports_unsolicited, |ui| {
159 ui.checkbox(
160 &mut forms.traffic.unsolicited,
161 "Unsolicited echo-reply flood (one-directional)",
162 );
163 });
164 if !supports_unsolicited {
165 ui.colored_label(
166 egui::Color32::YELLOW,
167 "Unsolicited flood requires esp-csi-cli-rs ≥ 0.7.1 on this device — \
168 reflash to use it (the flag is not sent to older firmware).",
169 );
170 }
171 ui.add(
172 egui::Label::new(
173 "Floods unsolicited echo replies the peer silently ignores: no reply \
174 contention, stable offered rate — but this device captures no CSI \
175 back from the peer; collect on the peer instead.",
176 )
177 .wrap(),
178 );
179 if traffic_on
180 && forms.traffic.unsolicited
181 && matches!(forms.collection_mode, CollectionMode::Collector)
182 {
183 ui.colored_label(
184 egui::Color32::YELLOW,
185 "Unsolicited flood + Collector: this node will capture ~no CSI. \
186 Set Collector on the peer, or disable the unsolicited flood.",
187 );
188 }
189 }
190 });
191
192 section_header(ui, "CSI Flags", |ui| {
193 render_csi_flags(ui, &mut forms.csi);
194
195 ui.add_space(8.0);
196 form_row(ui, "csi_he_stbc (u32)", |ui| {
197 ui.add(egui::TextEdit::singleline(&mut forms.csi.csi_he_stbc).desired_width(80.0));
198 });
199 form_row(ui, "val_scale_cfg (u32)", |ui| {
200 ui.add(
201 egui::TextEdit::singleline(&mut forms.csi.val_scale_cfg).desired_width(80.0),
202 );
203 });
204
205 ui.add_space(8.0);
206 if ui.button("Apply CSI Config").clicked() {
207 actions.push(DeviceAction::SetCsi(forms.csi.clone()));
208 }
209 if ui.button("Apply HE20 preset").clicked() {
210 actions.push(DeviceAction::SetCsiPreset("he20"));
211 }
212 });
213
214 section_header(ui, "PHY Rate", |ui| {
215 form_row(ui, "Rate", |ui| {
216 phy_rate_picker(ui, &mut forms.phy_rate.rate);
217 if ui.button("Apply Rate").clicked() {
218 actions.push(DeviceAction::SetPhyRate(forms.phy_rate.clone()));
219 }
220 });
221 ui.add(
222 egui::Label::new(
223 "Honored by all ESP-NOW modes (incl. fast simplex) and wifi-ap/sniffer; \
224 ignored by station.",
225 )
226 .wrap(),
227 );
228 });
229
230 section_header(ui, "IO Tasks", |ui| {
231 ui.checkbox(&mut forms.io_tasks.tx, "TX (traffic generation)");
232 ui.add_space(4.0);
233 ui.checkbox(&mut forms.io_tasks.rx, "RX (CSI capture)");
234 ui.add_space(8.0);
235 if ui.button("Apply IO Tasks").clicked() {
236 actions.push(DeviceAction::SetIoTasks(forms.io_tasks.clone()));
237 }
238 });
239
240 section_header(ui, "CSI Delivery", |ui| {
241 form_row(ui, "Mode", |ui| {
242 csi_delivery_picker(ui, &mut forms.csi_delivery.mode);
243 });
244 ui.checkbox(&mut forms.csi_delivery.logging, "inline UART logging");
245 ui.add_space(8.0);
246 if ui.button("Apply CSI Delivery").clicked() {
247 actions.push(DeviceAction::SetCsiDelivery(forms.csi_delivery.clone()));
248 }
249 });
250
251 section_header(ui, "Collection & Output", |ui| {
252 form_row(ui, "Collection Mode", |ui| {
253 collection_mode_picker(ui, &mut forms.collection_mode);
254 if ui.button("Apply").clicked() {
255 actions.push(DeviceAction::SetCollectionMode(forms.collection_mode));
256 }
257 });
258
259 form_row(ui, "Output Mode", |ui| {
260 output_mode_picker(ui, &mut forms.output_mode);
261 if ui.button("Apply").clicked() {
262 actions.push(DeviceAction::SetOutputMode(forms.output_mode));
263 }
264 });
265 });
266
267 ui.add_space(12.0);
268 ui.horizontal_wrapped(|ui| {
269 if ui.button("Reset Config Defaults").clicked() {
270 actions.push(DeviceAction::ResetConfig);
271 }
272 if ui.button("Refresh Config").clicked() {
273 actions.push(DeviceAction::FetchConfig);
274 }
275 });
276}
277
278fn section_header(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) {
280 egui::CollapsingHeader::new(title)
281 .default_open(true)
282 .show(ui, |ui| {
283 ui.add_space(6.0);
284 body(ui);
285 });
286 ui.add_space(12.0);
287}
288
289fn form_row(ui: &mut egui::Ui, label: &str, widgets: impl FnOnce(&mut egui::Ui)) {
291 ui.horizontal_wrapped(|ui| {
292 ui.label(label);
293 ui.add_space(8.0);
294 widgets(ui);
295 });
296 ui.add_space(6.0);
297}
298
299fn text_field_width(ui: &egui::Ui, label_reserve: f32) -> f32 {
301 (ui.available_width() - label_reserve - 16.0)
302 .clamp(80.0, 480.0)
303}
304
305fn render_csi_flags(ui: &mut egui::Ui, csi: &mut CsiForm) {
307 let panel_w = ui.available_width();
308 let two_cols = panel_w >= 360.0;
309
310 if two_cols {
311 let col_w = ((panel_w - 12.0) / 2.0).max(120.0);
312 ui.horizontal_top(|ui| {
313 ui.vertical(|ui| {
314 ui.set_max_width(col_w);
315 csi_flag_column_a(ui, csi);
316 });
317 ui.add_space(12.0);
318 ui.vertical(|ui| {
319 ui.set_max_width(col_w);
320 csi_flag_column_b(ui, csi);
321 });
322 });
323 } else {
324 ui.vertical(|ui| {
325 ui.set_max_width(panel_w);
326 csi_flag_column_a(ui, csi);
327 csi_flag_column_b(ui, csi);
328 });
329 }
330}
331
332fn csi_flag_column_a(ui: &mut egui::Ui, csi: &mut CsiForm) {
333 ui.checkbox(&mut csi.lltf, "lltf");
334 ui.checkbox(&mut csi.htltf, "htltf");
335 ui.checkbox(&mut csi.stbc_htltf, "stbc_htltf");
336 ui.checkbox(&mut csi.ltf_merge, "ltf_merge");
337 ui.checkbox(&mut csi.csi, "csi");
338 ui.checkbox(&mut csi.csi_legacy, "csi_legacy");
339}
340
341fn csi_flag_column_b(ui: &mut egui::Ui, csi: &mut CsiForm) {
342 ui.checkbox(&mut csi.csi_ht20, "csi_ht20");
343 ui.checkbox(&mut csi.csi_ht40, "csi_ht40");
344 ui.checkbox(&mut csi.csi_su, "csi_su");
345 ui.checkbox(&mut csi.csi_mu, "csi_mu");
346 ui.checkbox(&mut csi.csi_dcm, "csi_dcm");
347 ui.checkbox(&mut csi.csi_beamformed, "csi_beamformed");
348 ui.checkbox(&mut csi.dump_ack, "dump_ack");
349 ui.checkbox(&mut csi.csi_force_lltf, "csi_force_lltf (C5)");
350 ui.checkbox(&mut csi.csi_vht, "csi_vht (C5)");
351}
352
353fn wifi_mode_picker(ui: &mut egui::Ui, device_id: &str, supports_v07: bool, mode: &mut WiFiMode) {
354 egui::ComboBox::from_id_salt(format!("wifi_mode_combo_{device_id}"))
355 .selected_text(mode.as_api_value())
356 .show_ui(ui, |ui| {
357 ui.selectable_value(mode, WiFiMode::Station, "station");
358 ui.selectable_value(mode, WiFiMode::Sniffer, "sniffer");
359 ui.add_enabled_ui(supports_v07, |ui| {
360 ui.selectable_value(mode, WiFiMode::WifiAp, "wifi-ap (≥ 0.7.0)");
361 });
362 ui.selectable_value(mode, WiFiMode::EspNowCentral, "esp-now-central");
363 ui.selectable_value(mode, WiFiMode::EspNowPeripheral, "esp-now-peripheral");
364 ui.add_enabled_ui(supports_v07, |ui| {
365 ui.selectable_value(
366 mode,
367 WiFiMode::EspNowFastCollector,
368 "esp-now-fast-collector (≥ 0.7.0)",
369 );
370 ui.selectable_value(
371 mode,
372 WiFiMode::EspNowFastSource,
373 "esp-now-fast-source (≥ 0.7.0)",
374 );
375 });
376 });
377}
378
379fn collection_mode_picker(ui: &mut egui::Ui, mode: &mut CollectionMode) {
380 egui::ComboBox::from_id_salt("collection_mode_combo")
381 .selected_text(mode.as_api_value())
382 .show_ui(ui, |ui| {
383 ui.selectable_value(mode, CollectionMode::Collector, "collector");
384 ui.selectable_value(mode, CollectionMode::Listener, "listener");
385 });
386}
387
388fn ht40_picker(ui: &mut egui::Ui, mode: &mut Ht40Mode) {
389 egui::ComboBox::from_id_salt("ht40_combo")
390 .selected_text(mode.as_api_value())
391 .show_ui(ui, |ui| {
392 ui.selectable_value(mode, Ht40Mode::None, "none");
393 ui.selectable_value(mode, Ht40Mode::Above, "above");
394 ui.selectable_value(mode, Ht40Mode::Below, "below");
395 });
396}
397
398fn output_mode_picker(ui: &mut egui::Ui, mode: &mut OutputMode) {
399 egui::ComboBox::from_id_salt("output_mode_combo")
400 .selected_text(mode.as_api_value())
401 .show_ui(ui, |ui| {
402 ui.selectable_value(mode, OutputMode::Stream, "stream");
403 ui.selectable_value(mode, OutputMode::Dump, "dump");
404 ui.selectable_value(mode, OutputMode::Both, "both");
405 });
406}
407
408fn csi_delivery_picker(ui: &mut egui::Ui, mode: &mut CsiDeliveryMode) {
409 egui::ComboBox::from_id_salt("csi_delivery_combo")
410 .selected_text(mode.as_api_value())
411 .show_ui(ui, |ui| {
412 ui.selectable_value(mode, CsiDeliveryMode::Off, "off");
413 ui.selectable_value(mode, CsiDeliveryMode::Callback, "callback");
414 ui.selectable_value(mode, CsiDeliveryMode::Async, "async");
415 ui.selectable_value(mode, CsiDeliveryMode::Raw, "raw");
416 });
417}
418
419fn protocol_picker(ui: &mut egui::Ui, protocol: &mut WifiProtocol) {
420 egui::ComboBox::from_id_salt("protocol_combo")
421 .selected_text(protocol.as_api_value())
422 .show_ui(ui, |ui| {
423 ui.selectable_value(protocol, WifiProtocol::B, "b");
424 ui.selectable_value(protocol, WifiProtocol::G, "g");
425 ui.selectable_value(protocol, WifiProtocol::N, "n");
426 ui.selectable_value(protocol, WifiProtocol::Lr, "lr");
427 ui.selectable_value(protocol, WifiProtocol::A, "a");
428 ui.selectable_value(protocol, WifiProtocol::Ac, "ac");
429 ui.selectable_value(protocol, WifiProtocol::Ax, "ax");
430 });
431}
432
433fn phy_rate_picker(ui: &mut egui::Ui, rate: &mut String) {
434 egui::ComboBox::from_id_salt("phy_rate_combo")
435 .selected_text(rate.as_str())
436 .show_ui(ui, |ui| {
437 for option in PHY_RATES {
438 ui.selectable_value(rate, (*option).to_owned(), *option);
439 }
440 });
441}