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 Raw,
134}
135
136impl CsiDeliveryMode {
137 pub fn as_api_value(self) -> &'static str {
138 match self {
139 Self::Off => "off",
140 Self::Callback => "callback",
141 Self::Async => "async",
142 Self::Raw => "raw",
143 }
144 }
145}
146
147impl Default for CsiDeliveryMode {
148 fn default() -> Self {
149 Self::Async
150 }
151}
152
153pub const PHY_RATES: &[&str] = &[
157 "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
158 "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
159 "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
160];
161
162pub const HT40_OPTIONS: &[&str] = &["none", "above", "below"];
164
165#[derive(Debug, Clone)]
167pub struct WiFiForm {
168 pub mode: WiFiMode,
169 pub sta_ssid: String,
170 pub sta_password: String,
171 pub channel: String,
172 pub peer_mac: String,
174 pub ht40: String,
176}
177
178impl Default for WiFiForm {
179 fn default() -> Self {
180 Self {
181 mode: WiFiMode::default(),
182 sta_ssid: String::new(),
183 sta_password: String::new(),
184 channel: String::new(),
185 peer_mac: String::new(),
186 ht40: "none".to_owned(),
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct TrafficForm {
194 pub frequency_hz: String,
195}
196
197impl Default for TrafficForm {
198 fn default() -> Self {
199 Self {
200 frequency_hz: "100".to_owned(),
201 }
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct CsiForm {
208 pub disable_lltf: bool,
209 pub disable_htltf: bool,
210 pub disable_stbc_htltf: bool,
211 pub disable_ltf_merge: bool,
212 pub disable_csi: bool,
213 pub disable_csi_legacy: bool,
214 pub disable_csi_ht20: bool,
215 pub disable_csi_ht40: bool,
216 pub disable_csi_su: bool,
217 pub disable_csi_mu: bool,
218 pub disable_csi_dcm: bool,
219 pub disable_csi_beamformed: bool,
220 pub csi_he_stbc: String,
221 pub val_scale_cfg: String,
222}
223
224impl Default for CsiForm {
225 fn default() -> Self {
226 Self {
227 disable_lltf: false,
228 disable_htltf: false,
229 disable_stbc_htltf: false,
230 disable_ltf_merge: false,
231 disable_csi: false,
232 disable_csi_legacy: false,
233 disable_csi_ht20: false,
234 disable_csi_ht40: false,
235 disable_csi_su: false,
236 disable_csi_mu: false,
237 disable_csi_dcm: false,
238 disable_csi_beamformed: false,
239 csi_he_stbc: "2".to_owned(),
240 val_scale_cfg: "2".to_owned(),
241 }
242 }
243}
244
245#[derive(Debug, Clone)]
247pub struct PhyRateForm {
248 pub rate: String,
249}
250
251impl Default for PhyRateForm {
252 fn default() -> Self {
253 Self {
254 rate: "mcs0-lgi".to_owned(),
255 }
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct IoTasksForm {
262 pub tx: bool,
263 pub rx: bool,
264}
265
266impl Default for IoTasksForm {
267 fn default() -> Self {
268 Self { tx: true, rx: true }
269 }
270}
271
272#[derive(Debug, Clone)]
274pub struct CsiDeliveryForm {
275 pub mode: CsiDeliveryMode,
276 pub logging: bool,
277}
278
279impl Default for CsiDeliveryForm {
280 fn default() -> Self {
281 Self {
282 mode: CsiDeliveryMode::Async,
283 logging: true,
284 }
285 }
286}
287
288#[derive(Debug, Clone, Default)]
290pub struct PersistentState {
291 pub server_host: String,
292 pub server_port: String,
293 pub wifi: WiFiForm,
294 pub traffic: TrafficForm,
295 pub csi: CsiForm,
296 pub collection_mode: CollectionMode,
297 pub log_mode: LogMode,
298 pub output_mode: OutputMode,
299 pub phy_rate: PhyRateForm,
300 pub io_tasks: IoTasksForm,
301 pub csi_delivery: CsiDeliveryForm,
302 pub start_duration_seconds: String,
303}
304
305#[derive(Debug, Clone)]
307pub struct TransientUiState {
308 pub active_tab: Tab,
309 pub status_message: String,
310 pub error_message: String,
311 pub auto_scroll_stream: bool,
312}
313
314impl Default for TransientUiState {
315 fn default() -> Self {
316 Self {
317 active_tab: Tab::Dashboard,
318 status_message: "Ready".to_owned(),
319 error_message: String::new(),
320 auto_scroll_stream: true,
321 }
322 }
323}
324
325#[derive(Debug, Clone, Default)]
327pub struct FrameSummary {
328 pub timestamp: String,
329 pub length: usize,
330 pub preview_hex: String,
331}
332
333#[derive(Debug, Clone, Default)]
335pub struct RuntimeState {
336 pub ws_connected: bool,
337 pub serial_connected: Option<bool>,
338 pub collection_running: Option<bool>,
339 pub port_path: Option<String>,
340 pub firmware_verified: Option<bool>,
341 pub frames_received: u64,
342 pub bytes_received: u64,
343 pub recent_frames: Vec<FrameSummary>,
344 pub events: Vec<String>,
345 pub last_http_status: Option<u16>,
346 pub latest_config: Option<DeviceConfig>,
347 pub latest_info: Option<DeviceInfo>,
348 pub auto_resetting_cache: bool,
351}
352
353#[derive(Debug, Clone)]
355pub enum UserIntent {
356 FetchConfig,
357 FetchInfo,
358 FetchStatus,
359 ResetConfig,
360 SetWifi(WiFiForm),
361 SetTraffic(TrafficForm),
362 SetCsi(CsiForm),
363 SetCollectionMode(CollectionMode),
364 SetLogMode(LogMode),
365 SetOutputMode(OutputMode),
366 SetPhyRate(PhyRateForm),
367 SetIoTasks(IoTasksForm),
368 SetCsiDelivery(CsiDeliveryForm),
369 StartCollection { duration_seconds: String },
370 StopCollection,
371 ShowStats,
372 ResetDevice,
373 ConnectWebSocket,
374 DisconnectWebSocket,
375 ClearFrames,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, Default)]
380pub struct DeviceWifiConfig {
381 pub mode: Option<String>,
382 pub channel: Option<u16>,
383 pub sta_ssid: Option<String>,
384 pub peer_mac: Option<String>,
385 pub ht40: Option<String>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct DeviceCollectionConfig {
391 pub mode: Option<String>,
392 pub traffic_hz: Option<u64>,
393 pub phy_rate: Option<String>,
394 pub io_tx_enabled: Option<bool>,
395 pub io_rx_enabled: Option<bool>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, Default)]
404pub struct DeviceCsiConfig {
405 pub lltf_enabled: Option<bool>,
406 pub htltf_enabled: Option<bool>,
407 pub stbc_htltf_enabled: Option<bool>,
408 pub ltf_merge_enabled: Option<bool>,
409 pub channel_filter_enabled: Option<bool>,
410 pub manual_scale: Option<bool>,
411 pub shift: Option<i32>,
412 pub dump_ack_enabled: Option<bool>,
413 pub acquire_csi: Option<u32>,
414 pub acquire_csi_legacy: Option<u32>,
415 pub acquire_csi_ht20: Option<u32>,
416 pub acquire_csi_ht40: Option<u32>,
417 pub acquire_csi_su: Option<u32>,
418 pub acquire_csi_mu: Option<u32>,
419 pub acquire_csi_dcm: Option<u32>,
420 pub acquire_csi_beamformed: Option<u32>,
421 pub csi_he_stbc: Option<u32>,
422 pub val_scale_cfg: Option<u32>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, Default)]
431pub struct DeviceConfig {
432 #[serde(default)]
433 pub wifi: Option<DeviceWifiConfig>,
434 #[serde(default)]
435 pub collection: Option<DeviceCollectionConfig>,
436 #[serde(default)]
437 pub csi_config: Option<DeviceCsiConfig>,
438 pub log_mode: Option<String>,
439 pub csi_delivery_mode: Option<String>,
440 pub csi_logging_enabled: Option<bool>,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, Default)]
445pub struct DeviceInfo {
446 pub banner_version: Option<String>,
447 pub name: Option<String>,
448 pub version: Option<String>,
449 pub chip: Option<String>,
450 pub protocol: Option<u32>,
451 #[serde(default)]
452 pub features: Vec<String>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, Default)]
457pub struct ControlStatus {
458 pub serial_connected: Option<bool>,
459 pub collection_running: Option<bool>,
460 pub port_path: Option<String>,
461}
462
463#[derive(Debug, Clone, Default)]
467pub struct AppState {
468 pub persistent: PersistentState,
469 pub transient: TransientUiState,
470 pub runtime: RuntimeState,
471 intent_queue: Vec<UserIntent>,
472}
473
474impl AppState {
475 pub fn with_defaults() -> Self {
477 let mut state = Self::default();
478 state.persistent.server_host = "127.0.0.1".to_owned();
479 state.persistent.server_port = "3000".to_owned();
480 state
481 }
482
483 pub fn push_intent(&mut self, intent: UserIntent) {
485 self.intent_queue.push(intent);
486 }
487
488 pub fn drain_intents(&mut self) -> Vec<UserIntent> {
490 std::mem::take(&mut self.intent_queue)
491 }
492
493 pub fn push_event(&mut self, message: impl Into<String>) {
495 self.runtime.events.push(message.into());
496 if self.runtime.events.len() > 300 {
497 let drain_to = self.runtime.events.len() - 300;
498 self.runtime.events.drain(0..drain_to);
499 }
500 }
501
502 pub fn push_frame(&mut self, bytes: &[u8]) {
504 self.runtime.frames_received = self.runtime.frames_received.saturating_add(1);
505 self.runtime.bytes_received = self.runtime.bytes_received.saturating_add(bytes.len() as u64);
506
507 let preview = bytes
508 .iter()
509 .take(24)
510 .map(|b| format!("{b:02X}"))
511 .collect::<Vec<_>>()
512 .join(" ");
513
514 self.runtime.recent_frames.push(FrameSummary {
515 timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
516 length: bytes.len(),
517 preview_hex: preview,
518 });
519
520 if self.runtime.recent_frames.len() > 300 {
521 let drain_to = self.runtime.recent_frames.len() - 300;
522 self.runtime.recent_frames.drain(0..drain_to);
523 }
524 }
525
526 pub fn base_http_url(&self) -> String {
528 format!(
529 "http://{}:{}",
530 self.persistent.server_host.trim(),
531 self.persistent.server_port.trim()
532 )
533 }
534
535 pub fn base_ws_url(&self) -> String {
537 format!(
538 "ws://{}:{}/api/ws",
539 self.persistent.server_host.trim(),
540 self.persistent.server_port.trim()
541 )
542 }
543
544 pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
549 let mut applied = 0;
550
551 if let Some(wifi) = config.wifi.as_ref() {
552 if let Some(mode) = wifi.mode.as_deref() {
553 if let Some(parsed) = WiFiMode::from_api_value(mode) {
554 self.persistent.wifi.mode = parsed;
555 applied += 1;
556 }
557 }
558 if let Some(channel) = wifi.channel {
559 self.persistent.wifi.channel = channel.to_string();
560 applied += 1;
561 }
562 if let Some(ssid) = &wifi.sta_ssid {
563 self.persistent.wifi.sta_ssid = ssid.clone();
564 applied += 1;
565 }
566 if let Some(mac) = &wifi.peer_mac {
567 self.persistent.wifi.peer_mac =
570 if mac == "auto" { String::new() } else { mac.clone() };
571 applied += 1;
572 }
573 if let Some(ht40) = &wifi.ht40 {
574 self.persistent.wifi.ht40 = ht40.clone();
575 applied += 1;
576 }
577 }
578
579 if let Some(collection) = config.collection.as_ref() {
580 if let Some(traffic_hz) = collection.traffic_hz {
581 self.persistent.traffic.frequency_hz = traffic_hz.to_string();
582 applied += 1;
583 }
584 if let Some(mode) = collection.mode.as_deref() {
585 self.persistent.collection_mode = if mode == "listener" {
586 CollectionMode::Listener
587 } else {
588 CollectionMode::Collector
589 };
590 applied += 1;
591 }
592 if let Some(rate) = &collection.phy_rate {
593 self.persistent.phy_rate.rate = rate.clone();
594 applied += 1;
595 }
596 if let Some(tx) = collection.io_tx_enabled {
597 self.persistent.io_tasks.tx = tx;
598 applied += 1;
599 }
600 if let Some(rx) = collection.io_rx_enabled {
601 self.persistent.io_tasks.rx = rx;
602 applied += 1;
603 }
604 }
605
606 if let Some(csi_cfg) = config.csi_config.as_ref() {
607 if let Some(v) = csi_cfg.lltf_enabled {
608 self.persistent.csi.disable_lltf = !v;
609 applied += 1;
610 }
611 if let Some(v) = csi_cfg.htltf_enabled {
612 self.persistent.csi.disable_htltf = !v;
613 applied += 1;
614 }
615 if let Some(v) = csi_cfg.stbc_htltf_enabled {
616 self.persistent.csi.disable_stbc_htltf = !v;
617 applied += 1;
618 }
619 if let Some(v) = csi_cfg.ltf_merge_enabled {
620 self.persistent.csi.disable_ltf_merge = !v;
621 applied += 1;
622 }
623 if let Some(v) = csi_cfg.acquire_csi {
624 self.persistent.csi.disable_csi = v == 0;
625 applied += 1;
626 }
627 if let Some(v) = csi_cfg.acquire_csi_legacy {
628 self.persistent.csi.disable_csi_legacy = v == 0;
629 applied += 1;
630 }
631 if let Some(v) = csi_cfg.acquire_csi_ht20 {
632 self.persistent.csi.disable_csi_ht20 = v == 0;
633 applied += 1;
634 }
635 if let Some(v) = csi_cfg.acquire_csi_ht40 {
636 self.persistent.csi.disable_csi_ht40 = v == 0;
637 applied += 1;
638 }
639 if let Some(v) = csi_cfg.acquire_csi_su {
640 self.persistent.csi.disable_csi_su = v == 0;
641 applied += 1;
642 }
643 if let Some(v) = csi_cfg.acquire_csi_mu {
644 self.persistent.csi.disable_csi_mu = v == 0;
645 applied += 1;
646 }
647 if let Some(v) = csi_cfg.acquire_csi_dcm {
648 self.persistent.csi.disable_csi_dcm = v == 0;
649 applied += 1;
650 }
651 if let Some(v) = csi_cfg.acquire_csi_beamformed {
652 self.persistent.csi.disable_csi_beamformed = v == 0;
653 applied += 1;
654 }
655 if let Some(v) = csi_cfg.csi_he_stbc {
656 self.persistent.csi.csi_he_stbc = v.to_string();
657 applied += 1;
658 }
659 if let Some(v) = csi_cfg.val_scale_cfg {
660 self.persistent.csi.val_scale_cfg = v.to_string();
661 applied += 1;
662 }
663 }
664
665 if let Some(mode) = config.log_mode.as_deref() {
666 self.persistent.log_mode = match mode {
667 "text" => LogMode::Text,
668 "serialized" => LogMode::Serialized,
669 "esp-csi-tool" => LogMode::EspCsiTool,
670 _ => LogMode::ArrayList,
671 };
672 applied += 1;
673 }
674
675 if let Some(mode) = config.csi_delivery_mode.as_deref() {
676 self.persistent.csi_delivery.mode = match mode {
677 "off" => CsiDeliveryMode::Off,
678 "callback" => CsiDeliveryMode::Callback,
679 "raw" => CsiDeliveryMode::Raw,
680 _ => CsiDeliveryMode::Async,
681 };
682 applied += 1;
683 }
684 if let Some(logging) = config.csi_logging_enabled {
685 self.persistent.csi_delivery.logging = logging;
686 applied += 1;
687 }
688
689 self.runtime.latest_config = Some(config);
690 applied
691 }
692
693 pub fn apply_control_status(&mut self, status: ControlStatus) {
695 self.runtime.serial_connected = status.serial_connected;
696 self.runtime.collection_running = status.collection_running;
697 self.runtime.port_path = status.port_path;
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn device_config_parses_full_nested_response() {
707 let json = r#"{
708 "wifi": {
709 "mode": "esp-now-central", "channel": 6, "sta_ssid": "MyNetwork",
710 "peer_mac": "aa:bb:cc:dd:ee:ff", "ht40": "above"
711 },
712 "collection": {
713 "mode": "collector", "traffic_hz": 100, "phy_rate": "mcs0-lgi",
714 "io_tx_enabled": true, "io_rx_enabled": true
715 },
716 "csi_config": {
717 "lltf_enabled": true, "htltf_enabled": true,
718 "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
719 "csi_he_stbc": 2, "val_scale_cfg": 2,
720 "acquire_csi": 1, "acquire_csi_legacy": 0
721 },
722 "log_mode": "array-list",
723 "csi_delivery_mode": "raw",
724 "csi_logging_enabled": true
725 }"#;
726 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
727 let mut state = AppState::with_defaults();
728 let applied = state.apply_device_config(cfg);
729 assert!(applied > 0);
730 assert_eq!(state.persistent.wifi.mode, WiFiMode::EspNowCentral);
731 assert_eq!(state.persistent.wifi.channel, "6");
732 assert_eq!(state.persistent.wifi.peer_mac, "aa:bb:cc:dd:ee:ff");
733 assert_eq!(state.persistent.wifi.ht40, "above");
734 assert_eq!(state.persistent.traffic.frequency_hz, "100");
735 assert!(!state.persistent.csi.disable_csi);
736 assert!(state.persistent.csi.disable_csi_legacy);
737 assert_eq!(state.persistent.csi_delivery.mode, CsiDeliveryMode::Raw);
738 }
739
740 #[test]
741 fn device_config_maps_auto_peer_mac_to_empty_field() {
742 let json = r#"{ "wifi": { "mode": "sniffer", "peer_mac": "auto", "ht40": "none" } }"#;
743 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
744 let mut state = AppState::with_defaults();
745 state.apply_device_config(cfg);
746 assert_eq!(state.persistent.wifi.peer_mac, "");
747 assert_eq!(state.persistent.wifi.ht40, "none");
748 }
749
750 #[test]
751 fn device_config_tolerates_null_sub_objects() {
752 let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
753 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
754 let mut state = AppState::with_defaults();
755 assert_eq!(state.apply_device_config(cfg), 0);
756 }
757
758 #[test]
759 fn device_config_tolerates_missing_sub_objects() {
760 let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
761 let mut state = AppState::with_defaults();
762 assert_eq!(state.apply_device_config(cfg), 0);
763 }
764}