Skip to main content

csi_webclient/ui/
config.rs

1use crate::profile::ClientProfile;
2use crate::state::{
3    CollectionMode, CsiDeliveryMode, CsiForm, DeviceAction, DeviceState, Ht40Mode, OutputMode,
4    PHY_RATES, WiFiMode, WifiProtocol,
5};
6
7/// Render the configuration view for one device.
8///
9/// Pushes per-device actions into `actions`; the caller addresses them to the
10/// selected device id after rendering. `all_ids` is the full attached-device
11/// list, used by the "Copy from" picker. `profile` supplies any extra
12/// protocol options and CSI-preset buttons.
13pub 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        // The flood-kind toggle only exists for the WiFi infra modes: ESP-NOW
183        // modes transmit their own frames and sniffers generate no traffic.
184        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            // Moot while traffic generation is off (frequency 0/blank), and
193            // unsupported (whole set-traffic command rejected!) below 0.7.0.
194            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-supplied CSI-preset buttons (none in the standard build).
243        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
310/// Save/load config snapshots and copy configuration between devices.
311fn 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
385/// Collapsing section, open by default, with spacing below.
386fn 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
396/// One labeled form row; wraps when the panel is narrower than label + controls.
397fn 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
406/// Width for text inputs: fill remaining panel space without forcing overflow.
407fn 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
412/// CSI disable flags — explicit columns sized to the panel (Grid overflows here).
413fn 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}