Skip to main content

csi_webclient/ui/
config.rs

1use crate::state::{
2    CollectionMode, CsiDeliveryMode, CsiForm, DeviceAction, DeviceState, Ht40Mode, OutputMode,
3    PHY_RATES, WiFiMode, WifiProtocol,
4};
5
6/// Render the configuration view for one device.
7///
8/// Pushes per-device actions into `actions`; the caller addresses them to the
9/// selected device id after rendering.
10pub 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        // The flood-kind toggle only exists for the WiFi infra modes: ESP-NOW
147        // modes transmit their own frames and sniffers generate no traffic.
148        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            // Moot while traffic generation is off (frequency 0/blank), and
157            // unsupported (whole set-traffic command rejected!) below 0.7.1.
158            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
278/// Collapsing section, open by default, with spacing below.
279fn 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
289/// One labeled form row; wraps when the panel is narrower than label + controls.
290fn 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
299/// Width for text inputs: fill remaining panel space without forcing overflow.
300fn 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
305/// CSI disable flags — explicit columns sized to the panel (Grid overflows here).
306fn 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}