1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum Tab {
6 #[default]
7 Dashboard,
8 Config,
9 Control,
10 Stream,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WiFiMode {
16 Station,
17 Sniffer,
18 EspNowCentral,
19 EspNowPeripheral,
20}
21
22impl WiFiMode {
23 pub fn as_api_value(self) -> &'static str {
25 match self {
26 Self::Station => "station",
27 Self::Sniffer => "sniffer",
28 Self::EspNowCentral => "esp-now-central",
29 Self::EspNowPeripheral => "esp-now-peripheral",
30 }
31 }
32
33 pub fn from_api_value(value: &str) -> Option<Self> {
35 match value {
36 "station" => Some(Self::Station),
37 "sniffer" => Some(Self::Sniffer),
38 "esp-now-central" => Some(Self::EspNowCentral),
39 "esp-now-peripheral" => Some(Self::EspNowPeripheral),
40 _ => None,
41 }
42 }
43}
44
45impl Default for WiFiMode {
46 fn default() -> Self {
47 Self::Station
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CollectionMode {
54 Collector,
55 Listener,
56}
57
58impl CollectionMode {
59 pub fn as_api_value(self) -> &'static str {
61 match self {
62 Self::Collector => "collector",
63 Self::Listener => "listener",
64 }
65 }
66}
67
68impl Default for CollectionMode {
69 fn default() -> Self {
70 Self::Collector
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum LogMode {
77 Text,
78 ArrayList,
79 Serialized,
80 EspCsiTool,
81}
82
83impl LogMode {
84 pub fn as_api_value(self) -> &'static str {
86 match self {
87 Self::Text => "text",
88 Self::ArrayList => "array-list",
89 Self::Serialized => "serialized",
90 Self::EspCsiTool => "esp-csi-tool",
91 }
92 }
93}
94
95impl Default for LogMode {
96 fn default() -> Self {
97 Self::ArrayList
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum OutputMode {
104 Stream,
105 Dump,
106 Both,
107}
108
109impl OutputMode {
110 pub fn as_api_value(self) -> &'static str {
112 match self {
113 Self::Stream => "stream",
114 Self::Dump => "dump",
115 Self::Both => "both",
116 }
117 }
118}
119
120impl Default for OutputMode {
121 fn default() -> Self {
122 Self::Stream
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum CsiDeliveryMode {
129 Off,
130 Callback,
131 Async,
132}
133
134impl CsiDeliveryMode {
135 pub fn as_api_value(self) -> &'static str {
136 match self {
137 Self::Off => "off",
138 Self::Callback => "callback",
139 Self::Async => "async",
140 }
141 }
142}
143
144impl Default for CsiDeliveryMode {
145 fn default() -> Self {
146 Self::Async
147 }
148}
149
150pub const PHY_RATES: &[&str] = &[
154 "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
155 "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
156 "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
157];
158
159#[derive(Debug, Clone, Default)]
161pub struct WiFiForm {
162 pub mode: WiFiMode,
163 pub sta_ssid: String,
164 pub sta_password: String,
165 pub channel: String,
166}
167
168#[derive(Debug, Clone)]
170pub struct TrafficForm {
171 pub frequency_hz: String,
172}
173
174impl Default for TrafficForm {
175 fn default() -> Self {
176 Self {
177 frequency_hz: "100".to_owned(),
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
184pub struct CsiForm {
185 pub disable_lltf: bool,
186 pub disable_htltf: bool,
187 pub disable_stbc_htltf: bool,
188 pub disable_ltf_merge: bool,
189 pub disable_csi: bool,
190 pub disable_csi_legacy: bool,
191 pub disable_csi_ht20: bool,
192 pub disable_csi_ht40: bool,
193 pub disable_csi_su: bool,
194 pub disable_csi_mu: bool,
195 pub disable_csi_dcm: bool,
196 pub disable_csi_beamformed: bool,
197 pub csi_he_stbc: String,
198 pub val_scale_cfg: String,
199}
200
201impl Default for CsiForm {
202 fn default() -> Self {
203 Self {
204 disable_lltf: false,
205 disable_htltf: false,
206 disable_stbc_htltf: false,
207 disable_ltf_merge: false,
208 disable_csi: false,
209 disable_csi_legacy: false,
210 disable_csi_ht20: false,
211 disable_csi_ht40: false,
212 disable_csi_su: false,
213 disable_csi_mu: false,
214 disable_csi_dcm: false,
215 disable_csi_beamformed: false,
216 csi_he_stbc: "2".to_owned(),
217 val_scale_cfg: "2".to_owned(),
218 }
219 }
220}
221
222#[derive(Debug, Clone)]
224pub struct PhyRateForm {
225 pub rate: String,
226}
227
228impl Default for PhyRateForm {
229 fn default() -> Self {
230 Self {
231 rate: "mcs0-lgi".to_owned(),
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct IoTasksForm {
239 pub tx: bool,
240 pub rx: bool,
241}
242
243impl Default for IoTasksForm {
244 fn default() -> Self {
245 Self { tx: true, rx: true }
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct CsiDeliveryForm {
252 pub mode: CsiDeliveryMode,
253 pub logging: bool,
254}
255
256impl Default for CsiDeliveryForm {
257 fn default() -> Self {
258 Self {
259 mode: CsiDeliveryMode::Async,
260 logging: true,
261 }
262 }
263}
264
265#[derive(Debug, Clone, Default)]
267pub struct PersistentState {
268 pub server_host: String,
269 pub server_port: String,
270 pub wifi: WiFiForm,
271 pub traffic: TrafficForm,
272 pub csi: CsiForm,
273 pub collection_mode: CollectionMode,
274 pub log_mode: LogMode,
275 pub output_mode: OutputMode,
276 pub phy_rate: PhyRateForm,
277 pub io_tasks: IoTasksForm,
278 pub csi_delivery: CsiDeliveryForm,
279 pub start_duration_seconds: String,
280}
281
282#[derive(Debug, Clone)]
284pub struct TransientUiState {
285 pub active_tab: Tab,
286 pub status_message: String,
287 pub error_message: String,
288 pub auto_scroll_stream: bool,
289}
290
291impl Default for TransientUiState {
292 fn default() -> Self {
293 Self {
294 active_tab: Tab::Dashboard,
295 status_message: "Ready".to_owned(),
296 error_message: String::new(),
297 auto_scroll_stream: true,
298 }
299 }
300}
301
302#[derive(Debug, Clone, Default)]
304pub struct FrameSummary {
305 pub timestamp: String,
306 pub length: usize,
307 pub preview_hex: String,
308}
309
310#[derive(Debug, Clone, Default)]
312pub struct RuntimeState {
313 pub ws_connected: bool,
314 pub serial_connected: Option<bool>,
315 pub collection_running: Option<bool>,
316 pub port_path: Option<String>,
317 pub firmware_verified: Option<bool>,
318 pub frames_received: u64,
319 pub bytes_received: u64,
320 pub recent_frames: Vec<FrameSummary>,
321 pub events: Vec<String>,
322 pub last_http_status: Option<u16>,
323 pub latest_config: Option<DeviceConfig>,
324 pub latest_info: Option<DeviceInfo>,
325 pub auto_resetting_cache: bool,
328}
329
330#[derive(Debug, Clone)]
332pub enum UserIntent {
333 FetchConfig,
334 FetchInfo,
335 FetchStatus,
336 ResetConfig,
337 SetWifi(WiFiForm),
338 SetTraffic(TrafficForm),
339 SetCsi(CsiForm),
340 SetCollectionMode(CollectionMode),
341 SetLogMode(LogMode),
342 SetOutputMode(OutputMode),
343 SetPhyRate(PhyRateForm),
344 SetIoTasks(IoTasksForm),
345 SetCsiDelivery(CsiDeliveryForm),
346 StartCollection { duration_seconds: String },
347 StopCollection,
348 ShowStats,
349 ResetDevice,
350 ConnectWebSocket,
351 DisconnectWebSocket,
352 ClearFrames,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, Default)]
357pub struct DeviceWifiConfig {
358 pub mode: Option<String>,
359 pub channel: Option<u16>,
360 pub sta_ssid: Option<String>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct DeviceCollectionConfig {
366 pub mode: Option<String>,
367 pub traffic_hz: Option<u64>,
368 pub phy_rate: Option<String>,
369 pub io_tx_enabled: Option<bool>,
370 pub io_rx_enabled: Option<bool>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, Default)]
379pub struct DeviceCsiConfig {
380 pub lltf_enabled: Option<bool>,
381 pub htltf_enabled: Option<bool>,
382 pub stbc_htltf_enabled: Option<bool>,
383 pub ltf_merge_enabled: Option<bool>,
384 pub channel_filter_enabled: Option<bool>,
385 pub manual_scale: Option<bool>,
386 pub shift: Option<i32>,
387 pub dump_ack_enabled: Option<bool>,
388 pub acquire_csi: Option<u32>,
389 pub acquire_csi_legacy: Option<u32>,
390 pub acquire_csi_ht20: Option<u32>,
391 pub acquire_csi_ht40: Option<u32>,
392 pub acquire_csi_su: Option<u32>,
393 pub acquire_csi_mu: Option<u32>,
394 pub acquire_csi_dcm: Option<u32>,
395 pub acquire_csi_beamformed: Option<u32>,
396 pub csi_he_stbc: Option<u32>,
397 pub val_scale_cfg: Option<u32>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize, Default)]
406pub struct DeviceConfig {
407 #[serde(default)]
408 pub wifi: Option<DeviceWifiConfig>,
409 #[serde(default)]
410 pub collection: Option<DeviceCollectionConfig>,
411 #[serde(default)]
412 pub csi_config: Option<DeviceCsiConfig>,
413 pub log_mode: Option<String>,
414 pub csi_delivery_mode: Option<String>,
415 pub csi_logging_enabled: Option<bool>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, Default)]
420pub struct DeviceInfo {
421 pub banner_version: Option<String>,
422 pub name: Option<String>,
423 pub version: Option<String>,
424 pub chip: Option<String>,
425 pub protocol: Option<u32>,
426 #[serde(default)]
427 pub features: Vec<String>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, Default)]
432pub struct ControlStatus {
433 pub serial_connected: Option<bool>,
434 pub collection_running: Option<bool>,
435 pub port_path: Option<String>,
436}
437
438#[derive(Debug, Clone, Default)]
442pub struct AppState {
443 pub persistent: PersistentState,
444 pub transient: TransientUiState,
445 pub runtime: RuntimeState,
446 intent_queue: Vec<UserIntent>,
447}
448
449impl AppState {
450 pub fn with_defaults() -> Self {
452 let mut state = Self::default();
453 state.persistent.server_host = "127.0.0.1".to_owned();
454 state.persistent.server_port = "3000".to_owned();
455 state
456 }
457
458 pub fn push_intent(&mut self, intent: UserIntent) {
460 self.intent_queue.push(intent);
461 }
462
463 pub fn drain_intents(&mut self) -> Vec<UserIntent> {
465 std::mem::take(&mut self.intent_queue)
466 }
467
468 pub fn push_event(&mut self, message: impl Into<String>) {
470 self.runtime.events.push(message.into());
471 if self.runtime.events.len() > 300 {
472 let drain_to = self.runtime.events.len() - 300;
473 self.runtime.events.drain(0..drain_to);
474 }
475 }
476
477 pub fn push_frame(&mut self, bytes: &[u8]) {
479 self.runtime.frames_received = self.runtime.frames_received.saturating_add(1);
480 self.runtime.bytes_received = self.runtime.bytes_received.saturating_add(bytes.len() as u64);
481
482 let preview = bytes
483 .iter()
484 .take(24)
485 .map(|b| format!("{b:02X}"))
486 .collect::<Vec<_>>()
487 .join(" ");
488
489 self.runtime.recent_frames.push(FrameSummary {
490 timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
491 length: bytes.len(),
492 preview_hex: preview,
493 });
494
495 if self.runtime.recent_frames.len() > 300 {
496 let drain_to = self.runtime.recent_frames.len() - 300;
497 self.runtime.recent_frames.drain(0..drain_to);
498 }
499 }
500
501 pub fn base_http_url(&self) -> String {
503 format!(
504 "http://{}:{}",
505 self.persistent.server_host.trim(),
506 self.persistent.server_port.trim()
507 )
508 }
509
510 pub fn base_ws_url(&self) -> String {
512 format!(
513 "ws://{}:{}/api/ws",
514 self.persistent.server_host.trim(),
515 self.persistent.server_port.trim()
516 )
517 }
518
519 pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
524 let mut applied = 0;
525
526 if let Some(wifi) = config.wifi.as_ref() {
527 if let Some(mode) = wifi.mode.as_deref() {
528 if let Some(parsed) = WiFiMode::from_api_value(mode) {
529 self.persistent.wifi.mode = parsed;
530 applied += 1;
531 }
532 }
533 if let Some(channel) = wifi.channel {
534 self.persistent.wifi.channel = channel.to_string();
535 applied += 1;
536 }
537 if let Some(ssid) = &wifi.sta_ssid {
538 self.persistent.wifi.sta_ssid = ssid.clone();
539 applied += 1;
540 }
541 }
542
543 if let Some(collection) = config.collection.as_ref() {
544 if let Some(traffic_hz) = collection.traffic_hz {
545 self.persistent.traffic.frequency_hz = traffic_hz.to_string();
546 applied += 1;
547 }
548 if let Some(mode) = collection.mode.as_deref() {
549 self.persistent.collection_mode = if mode == "listener" {
550 CollectionMode::Listener
551 } else {
552 CollectionMode::Collector
553 };
554 applied += 1;
555 }
556 if let Some(rate) = &collection.phy_rate {
557 self.persistent.phy_rate.rate = rate.clone();
558 applied += 1;
559 }
560 if let Some(tx) = collection.io_tx_enabled {
561 self.persistent.io_tasks.tx = tx;
562 applied += 1;
563 }
564 if let Some(rx) = collection.io_rx_enabled {
565 self.persistent.io_tasks.rx = rx;
566 applied += 1;
567 }
568 }
569
570 if let Some(csi_cfg) = config.csi_config.as_ref() {
571 if let Some(v) = csi_cfg.lltf_enabled {
572 self.persistent.csi.disable_lltf = !v;
573 applied += 1;
574 }
575 if let Some(v) = csi_cfg.htltf_enabled {
576 self.persistent.csi.disable_htltf = !v;
577 applied += 1;
578 }
579 if let Some(v) = csi_cfg.stbc_htltf_enabled {
580 self.persistent.csi.disable_stbc_htltf = !v;
581 applied += 1;
582 }
583 if let Some(v) = csi_cfg.ltf_merge_enabled {
584 self.persistent.csi.disable_ltf_merge = !v;
585 applied += 1;
586 }
587 if let Some(v) = csi_cfg.acquire_csi {
588 self.persistent.csi.disable_csi = v == 0;
589 applied += 1;
590 }
591 if let Some(v) = csi_cfg.acquire_csi_legacy {
592 self.persistent.csi.disable_csi_legacy = v == 0;
593 applied += 1;
594 }
595 if let Some(v) = csi_cfg.acquire_csi_ht20 {
596 self.persistent.csi.disable_csi_ht20 = v == 0;
597 applied += 1;
598 }
599 if let Some(v) = csi_cfg.acquire_csi_ht40 {
600 self.persistent.csi.disable_csi_ht40 = v == 0;
601 applied += 1;
602 }
603 if let Some(v) = csi_cfg.acquire_csi_su {
604 self.persistent.csi.disable_csi_su = v == 0;
605 applied += 1;
606 }
607 if let Some(v) = csi_cfg.acquire_csi_mu {
608 self.persistent.csi.disable_csi_mu = v == 0;
609 applied += 1;
610 }
611 if let Some(v) = csi_cfg.acquire_csi_dcm {
612 self.persistent.csi.disable_csi_dcm = v == 0;
613 applied += 1;
614 }
615 if let Some(v) = csi_cfg.acquire_csi_beamformed {
616 self.persistent.csi.disable_csi_beamformed = v == 0;
617 applied += 1;
618 }
619 if let Some(v) = csi_cfg.csi_he_stbc {
620 self.persistent.csi.csi_he_stbc = v.to_string();
621 applied += 1;
622 }
623 if let Some(v) = csi_cfg.val_scale_cfg {
624 self.persistent.csi.val_scale_cfg = v.to_string();
625 applied += 1;
626 }
627 }
628
629 if let Some(mode) = config.log_mode.as_deref() {
630 self.persistent.log_mode = match mode {
631 "text" => LogMode::Text,
632 "serialized" => LogMode::Serialized,
633 "esp-csi-tool" => LogMode::EspCsiTool,
634 _ => LogMode::ArrayList,
635 };
636 applied += 1;
637 }
638
639 if let Some(mode) = config.csi_delivery_mode.as_deref() {
640 self.persistent.csi_delivery.mode = match mode {
641 "off" => CsiDeliveryMode::Off,
642 "callback" => CsiDeliveryMode::Callback,
643 _ => CsiDeliveryMode::Async,
644 };
645 applied += 1;
646 }
647 if let Some(logging) = config.csi_logging_enabled {
648 self.persistent.csi_delivery.logging = logging;
649 applied += 1;
650 }
651
652 self.runtime.latest_config = Some(config);
653 applied
654 }
655
656 pub fn apply_control_status(&mut self, status: ControlStatus) {
658 self.runtime.serial_connected = status.serial_connected;
659 self.runtime.collection_running = status.collection_running;
660 self.runtime.port_path = status.port_path;
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn device_config_parses_full_nested_response() {
670 let json = r#"{
671 "wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
672 "collection": {
673 "mode": "collector", "traffic_hz": 100, "phy_rate": "mcs0-lgi",
674 "io_tx_enabled": true, "io_rx_enabled": true
675 },
676 "csi_config": {
677 "lltf_enabled": true, "htltf_enabled": true,
678 "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
679 "csi_he_stbc": 2, "val_scale_cfg": 2,
680 "acquire_csi": 1, "acquire_csi_legacy": 0
681 },
682 "log_mode": "array-list",
683 "csi_delivery_mode": "async",
684 "csi_logging_enabled": true
685 }"#;
686 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
687 let mut state = AppState::with_defaults();
688 let applied = state.apply_device_config(cfg);
689 assert!(applied > 0);
690 assert_eq!(state.persistent.wifi.mode, WiFiMode::Sniffer);
691 assert_eq!(state.persistent.wifi.channel, "6");
692 assert_eq!(state.persistent.traffic.frequency_hz, "100");
693 assert!(!state.persistent.csi.disable_csi);
694 assert!(state.persistent.csi.disable_csi_legacy);
695 }
696
697 #[test]
698 fn device_config_tolerates_null_sub_objects() {
699 let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
700 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
701 let mut state = AppState::with_defaults();
702 assert_eq!(state.apply_device_config(cfg), 0);
703 }
704
705 #[test]
706 fn device_config_tolerates_missing_sub_objects() {
707 let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
708 let mut state = AppState::with_defaults();
709 assert_eq!(state.apply_device_config(cfg), 0);
710 }
711}