1use crate::profile::ClientProfile;
2use crate::state::{
3 CollectionMode, CsiDeliveryMode, CsiForm, DeviceAction, DeviceState, Ht40Mode, OutputMode,
4 PHY_RATES, WiFiMode, WifiProtocol,
5};
6
7pub fn render(
14 ui: &mut egui::Ui,
15 device: &mut DeviceState,
16 all_ids: &[String],
17 actions: &mut Vec<DeviceAction>,
18 profile: &dyn ClientProfile,
19) {
20 ui.add(
21 egui::Label::new(format!("Configuration — {}", device.id))
22 .wrap(),
23 );
24 ui.add_space(8.0);
25 render_body(ui, device, actions, profile);
26 render_persistence(ui, device, all_ids, actions);
27}
28
29fn render_body(
30 ui: &mut egui::Ui,
31 device: &mut DeviceState,
32 actions: &mut Vec<DeviceAction>,
33 profile: &dyn ClientProfile,
34) {
35 let device_id = device.id.clone();
36 let supports_v07 = device.supports_v07_modes();
37 let supports_unsolicited = device.supports_unsolicited();
38 let forms = &mut device.forms;
39 let field_width = text_field_width(ui, 140.0);
40
41 section_header(ui, "Wi-Fi", |ui| {
42 form_row(ui, "Mode", |ui| {
43 wifi_mode_picker(ui, &device_id, supports_v07, &mut forms.wifi.mode);
44 });
45
46 if forms.wifi.mode.requires_v07() && !supports_v07 {
47 ui.colored_label(
48 egui::Color32::YELLOW,
49 "Selected mode requires esp-csi-cli-rs ≥ 0.7.0 on this device.",
50 );
51 }
52
53 if matches!(forms.wifi.mode, WiFiMode::Station) {
54 form_row(ui, "STA SSID", |ui| {
55 ui.add(
56 egui::TextEdit::singleline(&mut forms.wifi.sta_ssid).desired_width(field_width),
57 );
58 });
59
60 form_row(ui, "STA Password", |ui| {
61 ui.add(
62 egui::TextEdit::singleline(&mut forms.wifi.sta_password)
63 .password(true)
64 .desired_width(field_width),
65 );
66 });
67 }
68
69 if matches!(forms.wifi.mode, WiFiMode::WifiAp) {
70 form_row(ui, "AP SSID", |ui| {
71 ui.add(
72 egui::TextEdit::singleline(&mut forms.wifi.ap_ssid).desired_width(field_width),
73 );
74 });
75
76 form_row(ui, "AP Password", |ui| {
77 ui.add(
78 egui::TextEdit::singleline(&mut forms.wifi.ap_password)
79 .password(true)
80 .desired_width(field_width),
81 );
82 });
83
84 ui.checkbox(&mut forms.wifi.ap_dhcp, "AP DHCP (built-in server)");
85
86 form_row(ui, "AP leases", |ui| {
87 ui.add(egui::DragValue::new(&mut forms.wifi.ap_leases).range(1..=8))
88 .on_hover_text(
89 "DHCP lease pool size (1-8). With more than one lease the \
90 ICMP flood targets every associated station; 1 restores \
91 the legacy single-target flood.",
92 );
93 });
94
95 ui.checkbox(&mut forms.wifi.ap_burst, "Sync burst flood")
96 .on_hover_text(
97 "Every flood tick sends one frame back-to-back to every \
98 associated station (time-aligned multi-receiver CSI). \
99 Each station then sees the full traffic rate, so total \
100 airtime = frequency × leases — lower the traffic rate if \
101 the channel saturates. Off = round-robin one station per \
102 tick.",
103 );
104 }
105
106 form_row(ui, "Channel", |ui| {
107 ui.add(egui::TextEdit::singleline(&mut forms.wifi.channel).desired_width(80.0));
108 });
109 if forms.wifi.mode.channel_is_hint() {
110 ui.add(
111 egui::Label::new(
112 "Optional pre-association band-selection hint (e.g. 6 on 2.4 GHz, or a \
113 5 GHz channel on the C5). Leave blank to inherit the channel from the \
114 associated AP.",
115 )
116 .wrap(),
117 );
118 ui.add_space(6.0);
119 }
120
121 if forms.wifi.mode.is_esp_now() {
122 form_row(ui, "Peer MAC", |ui| {
123 ui.add(
124 egui::TextEdit::singleline(&mut forms.wifi.peer_mac)
125 .hint_text("auto")
126 .desired_width(field_width),
127 );
128 });
129
130 form_row(ui, "HT40 secondary", |ui| {
131 ht40_picker(ui, &mut forms.wifi.ht40);
132 });
133
134 ui.add_space(4.0);
135 ui.add(
136 egui::Label::new(
137 "Peer MAC / HT40 apply to all ESP-NOW modes; empty MAC = auto.",
138 )
139 .wrap(),
140 );
141 }
142
143 ui.add_space(8.0);
144 if ui.button("Apply Wi-Fi Config").clicked() {
145 actions.push(DeviceAction::SetWifi(forms.wifi.clone()));
146 }
147
148 ui.add_space(12.0);
149 let extra_protocols = profile.extra_protocols();
150 form_row(ui, "PHY Protocol", |ui| {
151 protocol_picker(ui, &mut forms.protocol, extra_protocols);
152 if ui.button("Apply Protocol").clicked() {
153 actions.push(DeviceAction::SetProtocol(forms.protocol));
154 }
155 });
156 ui.add(
157 egui::Label::new(
158 "Applied at the start of each run. Default lr (Long-Range) is proprietary; \
159 use n to associate with a standard AP in station mode.",
160 )
161 .wrap(),
162 );
163 });
164
165 section_header(ui, "Traffic", |ui| {
166 form_row(ui, "Frequency (Hz)", |ui| {
167 ui.add(
168 egui::TextEdit::singleline(&mut forms.traffic.frequency_hz).desired_width(120.0),
169 );
170 if ui.button("Apply Traffic Config").clicked() {
171 actions.push(DeviceAction::SetTraffic(forms.traffic.clone()));
172 }
173 });
174 ui.add(
175 egui::Label::new(
176 "For AP + station lab pairs: AP floods at 1000 Hz, station stays \
177 receive-only (frequency 0).",
178 )
179 .wrap(),
180 );
181
182 if matches!(forms.wifi.mode, WiFiMode::WifiAp | WiFiMode::Station) {
185 let traffic_on = forms
186 .traffic
187 .frequency_hz
188 .trim()
189 .parse::<u64>()
190 .is_ok_and(|hz| hz > 0);
191 ui.add_space(4.0);
192 ui.add_enabled_ui(traffic_on && supports_unsolicited, |ui| {
195 ui.checkbox(
196 &mut forms.traffic.unsolicited,
197 "Unsolicited echo-reply flood (one-directional)",
198 );
199 });
200 if !supports_unsolicited {
201 ui.colored_label(
202 egui::Color32::YELLOW,
203 "Unsolicited flood requires esp-csi-cli-rs ≥ 0.7.0 on this device — \
204 reflash to use it (the flag is not sent to older firmware).",
205 );
206 }
207 ui.add(
208 egui::Label::new(
209 "Floods unsolicited echo replies the peer silently ignores: no reply \
210 contention, stable offered rate — but this device captures no CSI \
211 back from the peer; collect on the peer instead.",
212 )
213 .wrap(),
214 );
215 if traffic_on
216 && forms.traffic.unsolicited
217 && matches!(forms.collection_mode, CollectionMode::Collector)
218 {
219 ui.colored_label(
220 egui::Color32::YELLOW,
221 "Unsolicited flood + Collector: this node will capture ~no CSI. \
222 Set Collector on the peer, or disable the unsolicited flood.",
223 );
224 }
225 }
226 });
227
228 section_header(ui, "CSI Flags", |ui| {
229 render_csi_flags(ui, &mut forms.csi);
230
231 ui.add_space(8.0);
232 form_row(ui, "val_scale_cfg (u32)", |ui| {
233 ui.add(
234 egui::TextEdit::singleline(&mut forms.csi.val_scale_cfg).desired_width(80.0),
235 );
236 });
237
238 ui.add_space(8.0);
239 if ui.button("Apply CSI Config").clicked() {
240 actions.push(DeviceAction::SetCsi(forms.csi.clone()));
241 }
242 profile.extra_preset_buttons(ui, actions);
244 });
245
246 section_header(ui, "PHY Rate", |ui| {
247 form_row(ui, "Rate", |ui| {
248 phy_rate_picker(ui, &mut forms.phy_rate.rate);
249 if ui.button("Apply Rate").clicked() {
250 actions.push(DeviceAction::SetPhyRate(forms.phy_rate.clone()));
251 }
252 });
253 ui.add(
254 egui::Label::new(
255 "Honored by all ESP-NOW modes (incl. fast simplex) and wifi-ap/sniffer; \
256 ignored by station.",
257 )
258 .wrap(),
259 );
260 });
261
262 section_header(ui, "IO Tasks", |ui| {
263 ui.checkbox(&mut forms.io_tasks.tx, "TX (traffic generation)");
264 ui.add_space(4.0);
265 ui.checkbox(&mut forms.io_tasks.rx, "RX (CSI capture)");
266 ui.add_space(8.0);
267 if ui.button("Apply IO Tasks").clicked() {
268 actions.push(DeviceAction::SetIoTasks(forms.io_tasks.clone()));
269 }
270 });
271
272 section_header(ui, "CSI Delivery", |ui| {
273 form_row(ui, "Mode", |ui| {
274 csi_delivery_picker(ui, &mut forms.csi_delivery.mode);
275 });
276 ui.checkbox(&mut forms.csi_delivery.logging, "inline UART logging");
277 ui.add_space(8.0);
278 if ui.button("Apply CSI Delivery").clicked() {
279 actions.push(DeviceAction::SetCsiDelivery(forms.csi_delivery.clone()));
280 }
281 });
282
283 section_header(ui, "Collection & Output", |ui| {
284 form_row(ui, "Collection Mode", |ui| {
285 collection_mode_picker(ui, &mut forms.collection_mode);
286 if ui.button("Apply").clicked() {
287 actions.push(DeviceAction::SetCollectionMode(forms.collection_mode));
288 }
289 });
290
291 form_row(ui, "Output Mode", |ui| {
292 output_mode_picker(ui, &mut forms.output_mode);
293 if ui.button("Apply").clicked() {
294 actions.push(DeviceAction::SetOutputMode(forms.output_mode));
295 }
296 });
297 });
298
299 ui.add_space(12.0);
300 ui.horizontal_wrapped(|ui| {
301 if ui.button("Reset Config Defaults").clicked() {
302 actions.push(DeviceAction::ResetConfig);
303 }
304 if ui.button("Refresh Config").clicked() {
305 actions.push(DeviceAction::FetchConfig);
306 }
307 });
308}
309
310fn render_persistence(
312 ui: &mut egui::Ui,
313 device: &mut DeviceState,
314 all_ids: &[String],
315 actions: &mut Vec<DeviceAction>,
316) {
317 let device_id = device.id.clone();
318 let field_width = text_field_width(ui, 140.0);
319
320 section_header(ui, "Save / Load / Copy", |ui| {
321 form_row(ui, "Config file", |ui| {
322 ui.add(
323 egui::TextEdit::singleline(&mut device.config_path)
324 .hint_text("auto-named in export dir")
325 .desired_width(field_width),
326 );
327 });
328 ui.horizontal_wrapped(|ui| {
329 if ui.button("Save to File").clicked() {
330 actions.push(DeviceAction::SaveConfigFile {
331 path: device.config_path.clone(),
332 });
333 }
334 if ui.button("Load & Apply").clicked() {
335 actions.push(DeviceAction::LoadConfigFile {
336 path: device.config_path.clone(),
337 });
338 }
339 if ui.button("Apply All to Device").clicked() {
340 actions.push(DeviceAction::ApplyFullConfig);
341 }
342 });
343 ui.add_space(4.0);
344 ui.add(
345 egui::Label::new(
346 "Save writes the form values above as JSON (including Wi-Fi passwords, in \
347 plain text). Load fills the form and pushes every section to the device; \
348 Apply All re-pushes the current form after manual edits.",
349 )
350 .wrap(),
351 );
352
353 let others: Vec<&String> = all_ids.iter().filter(|id| **id != device_id).collect();
354 if !others.is_empty() {
355 ui.add_space(8.0);
356 form_row(ui, "Copy from", |ui| {
357 if !others.iter().any(|id| **id == device.copy_source) {
358 device.copy_source = (*others[0]).clone();
359 }
360 egui::ComboBox::from_id_salt(format!("copy_source_combo_{device_id}"))
361 .selected_text(device.copy_source.clone())
362 .show_ui(ui, |ui| {
363 for id in &others {
364 ui.selectable_value(&mut device.copy_source, (*id).clone(), *id);
365 }
366 });
367 if ui.button("Copy & Apply").clicked() {
368 actions.push(DeviceAction::CopyConfigFrom {
369 source_id: device.copy_source.clone(),
370 });
371 }
372 });
373 ui.add(
374 egui::Label::new(
375 "Copies the source device's form values onto this device and applies \
376 them, including the ESP-NOW peer MAC — clear it afterwards if the \
377 pair should differ.",
378 )
379 .wrap(),
380 );
381 }
382 });
383}
384
385fn section_header(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) {
387 egui::CollapsingHeader::new(title)
388 .default_open(true)
389 .show(ui, |ui| {
390 ui.add_space(6.0);
391 body(ui);
392 });
393 ui.add_space(12.0);
394}
395
396fn form_row(ui: &mut egui::Ui, label: &str, widgets: impl FnOnce(&mut egui::Ui)) {
398 ui.horizontal_wrapped(|ui| {
399 ui.label(label);
400 ui.add_space(8.0);
401 widgets(ui);
402 });
403 ui.add_space(6.0);
404}
405
406fn text_field_width(ui: &egui::Ui, label_reserve: f32) -> f32 {
408 (ui.available_width() - label_reserve - 16.0)
409 .clamp(80.0, 480.0)
410}
411
412fn render_csi_flags(ui: &mut egui::Ui, csi: &mut CsiForm) {
414 let panel_w = ui.available_width();
415 let two_cols = panel_w >= 360.0;
416
417 if two_cols {
418 let col_w = ((panel_w - 12.0) / 2.0).max(120.0);
419 ui.horizontal_top(|ui| {
420 ui.vertical(|ui| {
421 ui.set_max_width(col_w);
422 csi_flag_column_a(ui, csi);
423 });
424 ui.add_space(12.0);
425 ui.vertical(|ui| {
426 ui.set_max_width(col_w);
427 csi_flag_column_b(ui, csi);
428 });
429 });
430 } else {
431 ui.vertical(|ui| {
432 ui.set_max_width(panel_w);
433 csi_flag_column_a(ui, csi);
434 csi_flag_column_b(ui, csi);
435 });
436 }
437}
438
439fn csi_flag_column_a(ui: &mut egui::Ui, csi: &mut CsiForm) {
440 ui.checkbox(&mut csi.lltf, "lltf");
441 ui.checkbox(&mut csi.htltf, "htltf");
442 ui.checkbox(&mut csi.stbc_htltf, "stbc_htltf");
443 ui.checkbox(&mut csi.ltf_merge, "ltf_merge");
444 ui.checkbox(&mut csi.csi, "csi");
445 ui.checkbox(&mut csi.csi_legacy, "csi_legacy");
446}
447
448fn csi_flag_column_b(ui: &mut egui::Ui, csi: &mut CsiForm) {
449 ui.checkbox(&mut csi.csi_ht20, "csi_ht20");
450 ui.checkbox(&mut csi.csi_ht40, "csi_ht40");
451 ui.checkbox(&mut csi.dump_ack, "dump_ack");
452 ui.checkbox(&mut csi.csi_force_lltf, "csi_force_lltf (C5)");
453 ui.checkbox(&mut csi.csi_vht, "csi_vht (C5)");
454}
455
456fn wifi_mode_picker(ui: &mut egui::Ui, device_id: &str, supports_v07: bool, mode: &mut WiFiMode) {
457 egui::ComboBox::from_id_salt(format!("wifi_mode_combo_{device_id}"))
458 .selected_text(mode.as_api_value())
459 .show_ui(ui, |ui| {
460 ui.selectable_value(mode, WiFiMode::Station, "station");
461 ui.selectable_value(mode, WiFiMode::Sniffer, "sniffer");
462 ui.add_enabled_ui(supports_v07, |ui| {
463 ui.selectable_value(mode, WiFiMode::WifiAp, "wifi-ap (≥ 0.7.0)");
464 });
465 ui.selectable_value(mode, WiFiMode::EspNowCentral, "esp-now-central");
466 ui.selectable_value(mode, WiFiMode::EspNowPeripheral, "esp-now-peripheral");
467 ui.add_enabled_ui(supports_v07, |ui| {
468 ui.selectable_value(
469 mode,
470 WiFiMode::EspNowFastCollector,
471 "esp-now-fast-collector (≥ 0.7.0)",
472 );
473 ui.selectable_value(
474 mode,
475 WiFiMode::EspNowFastSource,
476 "esp-now-fast-source (≥ 0.7.0)",
477 );
478 });
479 });
480}
481
482fn collection_mode_picker(ui: &mut egui::Ui, mode: &mut CollectionMode) {
483 egui::ComboBox::from_id_salt("collection_mode_combo")
484 .selected_text(mode.as_api_value())
485 .show_ui(ui, |ui| {
486 ui.selectable_value(mode, CollectionMode::Collector, "collector");
487 ui.selectable_value(mode, CollectionMode::Listener, "listener");
488 });
489}
490
491fn ht40_picker(ui: &mut egui::Ui, mode: &mut Ht40Mode) {
492 egui::ComboBox::from_id_salt("ht40_combo")
493 .selected_text(mode.as_api_value())
494 .show_ui(ui, |ui| {
495 ui.selectable_value(mode, Ht40Mode::None, "none");
496 ui.selectable_value(mode, Ht40Mode::Above, "above");
497 ui.selectable_value(mode, Ht40Mode::Below, "below");
498 });
499}
500
501fn output_mode_picker(ui: &mut egui::Ui, mode: &mut OutputMode) {
502 egui::ComboBox::from_id_salt("output_mode_combo")
503 .selected_text(mode.as_api_value())
504 .show_ui(ui, |ui| {
505 ui.selectable_value(mode, OutputMode::Stream, "stream");
506 ui.selectable_value(mode, OutputMode::Dump, "dump");
507 ui.selectable_value(mode, OutputMode::Both, "both");
508 });
509}
510
511fn csi_delivery_picker(ui: &mut egui::Ui, mode: &mut CsiDeliveryMode) {
512 egui::ComboBox::from_id_salt("csi_delivery_combo")
513 .selected_text(mode.as_api_value())
514 .show_ui(ui, |ui| {
515 ui.selectable_value(mode, CsiDeliveryMode::Off, "off");
516 ui.selectable_value(mode, CsiDeliveryMode::Callback, "callback");
517 ui.selectable_value(mode, CsiDeliveryMode::Async, "async");
518 ui.selectable_value(mode, CsiDeliveryMode::Raw, "raw");
519 });
520}
521
522fn protocol_picker(
523 ui: &mut egui::Ui,
524 protocol: &mut WifiProtocol,
525 extra_protocols: &[&'static str],
526) {
527 egui::ComboBox::from_id_salt("protocol_combo")
528 .selected_text(protocol.as_api_value())
529 .show_ui(ui, |ui| {
530 ui.selectable_value(protocol, WifiProtocol::B, "b");
531 ui.selectable_value(protocol, WifiProtocol::G, "g");
532 ui.selectable_value(protocol, WifiProtocol::N, "n");
533 ui.selectable_value(protocol, WifiProtocol::Lr, "lr");
534 ui.selectable_value(protocol, WifiProtocol::A, "a");
535 ui.selectable_value(protocol, WifiProtocol::Ac, "ac");
536 for extra in extra_protocols {
537 ui.selectable_value(protocol, WifiProtocol::Ext(extra), *extra);
538 }
539 });
540}
541
542fn phy_rate_picker(ui: &mut egui::Ui, rate: &mut String) {
543 egui::ComboBox::from_id_salt("phy_rate_combo")
544 .selected_text(rate.as_str())
545 .show_ui(ui, |ui| {
546 for option in PHY_RATES {
547 ui.selectable_value(rate, (*option).to_owned(), *option);
548 }
549 });
550}