1use crate::core::messages::{ApiRequest, CoreCommand, CoreEvent, HttpMethod};
2use crate::core::CoreHandle;
3use crate::state::{AppState, DeviceConfig, Tab, UserIntent};
4use crate::ui;
5use eframe::egui;
6use serde_json::json;
7
8pub struct CsiClientApp {
16 state: AppState,
17 core: CoreHandle,
18}
19
20impl CsiClientApp {
21 pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
23 Self {
24 state: AppState::with_defaults(),
25 core: CoreHandle::new(),
26 }
27 }
28
29 fn process_intents(&mut self) {
33 for intent in self.state.drain_intents() {
34 match intent {
35 UserIntent::FetchConfig => {
36 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
37 label: "fetch_config".to_owned(),
38 method: HttpMethod::Get,
39 base_url: self.state.base_http_url(),
40 path: "/api/config".to_owned(),
41 body: None,
42 }));
43 }
44 UserIntent::ResetConfig => {
45 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
46 label: "reset_config".to_owned(),
47 method: HttpMethod::Post,
48 base_url: self.state.base_http_url(),
49 path: "/api/config/reset".to_owned(),
50 body: None,
51 }));
52 }
53 UserIntent::SetWifi(wifi) => {
54 let channel = parse_optional_u16(&wifi.channel);
55 if wifi.channel.trim().is_empty() || channel.is_some() {
56 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
57 label: "set_wifi".to_owned(),
58 method: HttpMethod::Post,
59 base_url: self.state.base_http_url(),
60 path: "/api/config/wifi".to_owned(),
61 body: Some(json!({
62 "mode": wifi.mode.as_api_value(),
63 "sta_ssid": empty_to_none(wifi.sta_ssid),
64 "sta_password": empty_to_none(wifi.sta_password),
65 "channel": channel,
66 })),
67 }));
68 } else {
69 self.state.transient.error_message =
70 "Wi-Fi channel must be a valid number".to_owned();
71 }
72 }
73 UserIntent::SetTraffic(traffic) => {
74 if let Some(frequency_hz) = parse_required_u16(&traffic.frequency_hz) {
75 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
76 label: "set_traffic".to_owned(),
77 method: HttpMethod::Post,
78 base_url: self.state.base_http_url(),
79 path: "/api/config/traffic".to_owned(),
80 body: Some(json!({ "frequency_hz": frequency_hz })),
81 }));
82 } else {
83 self.state.transient.error_message =
84 "Traffic frequency must be a valid number".to_owned();
85 }
86 }
87 UserIntent::SetCsi(csi) => {
88 let csi_he_stbc = parse_required_u8(&csi.csi_he_stbc);
89 let val_scale_cfg = parse_required_u8(&csi.val_scale_cfg);
90
91 if let (Some(csi_he_stbc), Some(val_scale_cfg)) = (csi_he_stbc, val_scale_cfg) {
92 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
93 label: "set_csi".to_owned(),
94 method: HttpMethod::Post,
95 base_url: self.state.base_http_url(),
96 path: "/api/config/csi".to_owned(),
97 body: Some(json!({
98 "disable_lltf": csi.disable_lltf,
99 "disable_htltf": csi.disable_htltf,
100 "disable_stbc_htltf": csi.disable_stbc_htltf,
101 "disable_ltf_merge": csi.disable_ltf_merge,
102 "disable_csi": csi.disable_csi,
103 "disable_csi_legacy": csi.disable_csi_legacy,
104 "disable_csi_ht20": csi.disable_csi_ht20,
105 "disable_csi_ht40": csi.disable_csi_ht40,
106 "disable_csi_su": csi.disable_csi_su,
107 "disable_csi_mu": csi.disable_csi_mu,
108 "disable_csi_dcm": csi.disable_csi_dcm,
109 "disable_csi_beamformed": csi.disable_csi_beamformed,
110 "csi_he_stbc": csi_he_stbc,
111 "val_scale_cfg": val_scale_cfg
112 })),
113 }));
114 } else {
115 self.state.transient.error_message =
116 "CSI u8 fields must be valid numbers in 0..255".to_owned();
117 }
118 }
119 UserIntent::SetCollectionMode(mode) => {
120 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
121 label: "set_collection_mode".to_owned(),
122 method: HttpMethod::Post,
123 base_url: self.state.base_http_url(),
124 path: "/api/config/collection-mode".to_owned(),
125 body: Some(json!({ "mode": mode.as_api_value() })),
126 }));
127 }
128 UserIntent::SetLogMode(mode) => {
129 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
130 label: "set_log_mode".to_owned(),
131 method: HttpMethod::Post,
132 base_url: self.state.base_http_url(),
133 path: "/api/config/log-mode".to_owned(),
134 body: Some(json!({ "mode": mode.as_api_value() })),
135 }));
136 }
137 UserIntent::SetOutputMode(mode) => {
138 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
139 label: "set_output_mode".to_owned(),
140 method: HttpMethod::Post,
141 base_url: self.state.base_http_url(),
142 path: "/api/config/output-mode".to_owned(),
143 body: Some(json!({ "mode": mode.as_api_value() })),
144 }));
145 }
146 UserIntent::StartCollection { duration_seconds } => {
147 let duration = parse_optional_u64(&duration_seconds);
148 if duration_seconds.trim().is_empty() || duration.is_some() {
149 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
150 label: "start_collection".to_owned(),
151 method: HttpMethod::Post,
152 base_url: self.state.base_http_url(),
153 path: "/api/control/start".to_owned(),
154 body: duration.map(|d| json!({ "duration": d })),
155 }));
156 } else {
157 self.state.transient.error_message =
158 "Duration must be a valid number of seconds".to_owned();
159 }
160 }
161 UserIntent::ResetDevice => {
162 self.core.submit(CoreCommand::ExecuteApi(ApiRequest {
163 label: "reset_device".to_owned(),
164 method: HttpMethod::Post,
165 base_url: self.state.base_http_url(),
166 path: "/api/control/reset".to_owned(),
167 body: None,
168 }));
169 }
170 UserIntent::ConnectWebSocket => {
171 self.core.submit(CoreCommand::ConnectWebSocket {
172 url: self.state.base_ws_url(),
173 });
174 }
175 UserIntent::DisconnectWebSocket => {
176 self.core.submit(CoreCommand::DisconnectWebSocket);
177 }
178 UserIntent::ClearFrames => {
179 self.state.runtime.recent_frames.clear();
180 self.state.runtime.frames_received = 0;
181 self.state.runtime.bytes_received = 0;
182 }
183 }
184 }
185 }
186
187 fn process_core_events(&mut self) {
189 while let Some(event) = self.core.try_recv() {
190 match event {
191 CoreEvent::ApiResponse(response) => {
192 if response.success {
193 match response.label.as_str() {
194 "start_collection" => {
195 self.state.runtime.collection_active_estimate = true;
196 }
197 "reset_device" => {
198 self.state.runtime.collection_active_estimate = false;
199 }
200 _ => {}
201 }
202 }
203
204 self.state.runtime.last_http_status = Some(response.status);
205
206 if response.success {
207 self.state.transient.status_message = format!(
208 "{} (HTTP {}): {}",
209 response.label, response.status, response.message
210 );
211 self.state.transient.error_message.clear();
212 } else {
213 self.state.transient.error_message = format!(
214 "{} failed (HTTP {}): {}",
215 response.label, response.status, response.message
216 );
217 }
218
219 self.state.push_event(format!(
220 "{} -> HTTP {}: {}",
221 response.label, response.status, response.message
222 ));
223
224 if response.label == "fetch_config" {
225 if let Some(data) = response.data {
226 if let Some(config) = parse_device_config(data) {
227 self.state.apply_device_config(config);
228 }
229 }
230 }
231 }
232 CoreEvent::WebSocketConnected => {
233 self.state.runtime.ws_connected = true;
234 self.state.transient.status_message = "WebSocket connected".to_owned();
235 self.state.transient.error_message.clear();
236 self.state.push_event("WebSocket connected");
237 }
238 CoreEvent::WebSocketDisconnected { reason } => {
239 self.state.runtime.ws_connected = false;
240 self.state.push_event(format!("WebSocket disconnected: {reason}"));
241 }
242 CoreEvent::WebSocketFrame(bytes) => {
243 self.state.push_frame(&bytes);
244 }
245 CoreEvent::Log(line) => {
246 self.state.push_event(line);
247 }
248 }
249 }
250 }
251
252 fn render_top_bar(&mut self, ctx: &egui::Context) {
254 egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
255 ui.horizontal(|ui| {
256 ui.label("Host");
257 ui.text_edit_singleline(&mut self.state.persistent.server_host);
258 ui.label("Port");
259 ui.text_edit_singleline(&mut self.state.persistent.server_port);
260 if ui.button("Fetch Config").clicked() {
261 self.state.push_intent(UserIntent::FetchConfig);
262 }
263 });
264
265 ui.horizontal(|ui| {
266 tab_button(ui, &mut self.state, Tab::Dashboard, "Dashboard");
267 tab_button(ui, &mut self.state, Tab::Config, "Config");
268 tab_button(ui, &mut self.state, Tab::Control, "Control");
269 tab_button(ui, &mut self.state, Tab::Stream, "Stream");
270 });
271
272 if !self.state.transient.status_message.is_empty() {
273 ui.label(format!("Status: {}", self.state.transient.status_message));
274 }
275
276 if !self.state.transient.error_message.is_empty() {
277 ui.colored_label(
278 egui::Color32::from_rgb(220, 80, 80),
279 format!("Error: {}", self.state.transient.error_message),
280 );
281 }
282 });
283 }
284}
285
286impl eframe::App for CsiClientApp {
287 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
294 self.process_core_events();
295 self.process_intents();
296
297 self.render_top_bar(ctx);
298
299 egui::CentralPanel::default().show(ctx, |ui| match self.state.transient.active_tab {
300 Tab::Dashboard => ui::dashboard::render(ui, &mut self.state),
301 Tab::Config => ui::config::render(ui, &mut self.state),
302 Tab::Control => ui::control::render(ui, &mut self.state),
303 Tab::Stream => ui::stream::render(ui, &mut self.state),
304 });
305
306 ctx.request_repaint_after(std::time::Duration::from_millis(16));
307 }
308}
309
310fn parse_device_config(data: serde_json::Value) -> Option<DeviceConfig> {
312 if let Ok(config) = serde_json::from_value::<DeviceConfig>(data.clone()) {
313 return Some(config);
314 }
315
316 if let Some(inner) = data.get("data") {
317 return serde_json::from_value::<DeviceConfig>(inner.clone()).ok();
318 }
319
320 None
321}
322
323fn parse_optional_u16(input: &str) -> Option<u16> {
325 let trimmed = input.trim();
326 if trimmed.is_empty() {
327 return None;
328 }
329 trimmed.parse::<u16>().ok()
330}
331
332fn parse_required_u16(input: &str) -> Option<u16> {
334 input.trim().parse::<u16>().ok()
335}
336
337fn parse_required_u8(input: &str) -> Option<u8> {
339 input.trim().parse::<u8>().ok()
340}
341
342fn parse_optional_u64(input: &str) -> Option<u64> {
344 let trimmed = input.trim();
345 if trimmed.is_empty() {
346 return None;
347 }
348 trimmed.parse::<u64>().ok()
349}
350
351fn empty_to_none(input: String) -> Option<String> {
355 if input.trim().is_empty() {
356 None
357 } else {
358 Some(input)
359 }
360}
361
362fn tab_button(ui: &mut egui::Ui, state: &mut AppState, tab: Tab, label: &str) {
364 let selected = state.transient.active_tab == tab;
365 if ui.selectable_label(selected, label).clicked() {
366 state.transient.active_tab = tab;
367 }
368}