1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum Tab {
6 #[default]
7 Devices,
8 Dashboard,
9 Config,
10 Control,
11 Stream,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum WiFiMode {
17 Station,
18 Sniffer,
19 WifiAp,
20 EspNowCentral,
21 EspNowPeripheral,
22 EspNowFastCollector,
23 EspNowFastSource,
24}
25
26impl WiFiMode {
27 pub fn as_api_value(self) -> &'static str {
29 match self {
30 Self::Station => "station",
31 Self::Sniffer => "sniffer",
32 Self::WifiAp => "wifi-ap",
33 Self::EspNowCentral => "esp-now-central",
34 Self::EspNowPeripheral => "esp-now-peripheral",
35 Self::EspNowFastCollector => "esp-now-fast-collector",
36 Self::EspNowFastSource => "esp-now-fast-source",
37 }
38 }
39
40 pub fn from_api_value(value: &str) -> Option<Self> {
42 match value {
43 "station" => Some(Self::Station),
44 "sniffer" => Some(Self::Sniffer),
45 "wifi-ap" => Some(Self::WifiAp),
46 "esp-now-central" => Some(Self::EspNowCentral),
47 "esp-now-peripheral" => Some(Self::EspNowPeripheral),
48 "esp-now-fast-collector" => Some(Self::EspNowFastCollector),
49 "esp-now-fast-source" => Some(Self::EspNowFastSource),
50 _ => None,
51 }
52 }
53
54 pub fn is_esp_now(self) -> bool {
56 matches!(
57 self,
58 Self::EspNowCentral
59 | Self::EspNowPeripheral
60 | Self::EspNowFastCollector
61 | Self::EspNowFastSource
62 )
63 }
64
65 pub fn requires_v07(self) -> bool {
67 matches!(
68 self,
69 Self::WifiAp | Self::EspNowFastCollector | Self::EspNowFastSource
70 )
71 }
72
73 pub fn allows_channel(self) -> bool {
78 !matches!(self, Self::Station)
79 }
80}
81
82impl Default for WiFiMode {
83 fn default() -> Self {
84 Self::Station
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum CollectionMode {
91 Collector,
92 Listener,
93}
94
95impl CollectionMode {
96 pub fn as_api_value(self) -> &'static str {
98 match self {
99 Self::Collector => "collector",
100 Self::Listener => "listener",
101 }
102 }
103}
104
105impl Default for CollectionMode {
106 fn default() -> Self {
107 Self::Collector
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum Ht40Mode {
116 #[default]
117 None,
118 Above,
119 Below,
120}
121
122impl Ht40Mode {
123 pub fn as_api_value(self) -> &'static str {
125 match self {
126 Self::None => "none",
127 Self::Above => "above",
128 Self::Below => "below",
129 }
130 }
131
132 pub fn from_api_value(value: &str) -> Option<Self> {
134 match value {
135 "none" | "off" => Some(Self::None),
136 "above" => Some(Self::Above),
137 "below" => Some(Self::Below),
138 _ => None,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum OutputMode {
146 Stream,
147 Dump,
148 Both,
149}
150
151impl OutputMode {
152 pub fn as_api_value(self) -> &'static str {
154 match self {
155 Self::Stream => "stream",
156 Self::Dump => "dump",
157 Self::Both => "both",
158 }
159 }
160}
161
162impl Default for OutputMode {
163 fn default() -> Self {
164 Self::Stream
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum CsiDeliveryMode {
171 Off,
172 Callback,
173 Async,
174 Raw,
177}
178
179impl CsiDeliveryMode {
180 pub fn as_api_value(self) -> &'static str {
181 match self {
182 Self::Off => "off",
183 Self::Callback => "callback",
184 Self::Async => "async",
185 Self::Raw => "raw",
186 }
187 }
188}
189
190impl Default for CsiDeliveryMode {
191 fn default() -> Self {
192 Self::Async
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum WifiProtocol {
204 B,
205 G,
206 N,
207 Lr,
208 A,
209 Ac,
210 Ax,
211}
212
213impl WifiProtocol {
214 pub fn as_api_value(self) -> &'static str {
216 match self {
217 Self::B => "b",
218 Self::G => "g",
219 Self::N => "n",
220 Self::Lr => "lr",
221 Self::A => "a",
222 Self::Ac => "ac",
223 Self::Ax => "ax",
224 }
225 }
226
227 pub fn from_api_value(value: &str) -> Option<Self> {
229 match value.to_ascii_lowercase().as_str() {
230 "b" => Some(Self::B),
231 "g" => Some(Self::G),
232 "n" => Some(Self::N),
233 "lr" => Some(Self::Lr),
234 "a" => Some(Self::A),
235 "ac" => Some(Self::Ac),
236 "ax" => Some(Self::Ax),
237 _ => None,
238 }
239 }
240}
241
242impl Default for WifiProtocol {
243 fn default() -> Self {
244 Self::Lr
245 }
246}
247
248pub const PHY_RATES: &[&str] = &[
252 "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
253 "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
254 "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
255];
256
257#[derive(Debug, Clone)]
259pub struct WiFiForm {
260 pub mode: WiFiMode,
261 pub sta_ssid: String,
262 pub sta_password: String,
263 pub ap_ssid: String,
264 pub ap_password: String,
265 pub ap_dhcp: bool,
266 pub channel: String,
267 pub peer_mac: String,
270 pub ht40: Ht40Mode,
272}
273
274impl Default for WiFiForm {
275 fn default() -> Self {
276 Self {
277 mode: WiFiMode::Station,
278 sta_ssid: String::new(),
279 sta_password: String::new(),
280 ap_ssid: "esp-csi-ap".to_owned(),
281 ap_password: String::new(),
282 ap_dhcp: true,
283 channel: String::new(),
284 peer_mac: String::new(),
285 ht40: Ht40Mode::None,
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum PairingPreset {
293 SoftApLab,
294 EspNowFastSimplex,
295 EspNowBalanced,
296}
297
298impl PairingPreset {
299 pub fn label(self) -> &'static str {
300 match self {
301 Self::SoftApLab => "SoftAP lab pair",
302 Self::EspNowFastSimplex => "ESP-NOW fast simplex",
303 Self::EspNowBalanced => "ESP-NOW balanced",
304 }
305 }
306
307 pub fn requires_v07(self) -> bool {
309 matches!(self, Self::SoftApLab | Self::EspNowFastSimplex)
310 }
311}
312#[derive(Debug, Clone)]
313pub struct TrafficForm {
314 pub frequency_hz: String,
315 pub unsolicited: bool,
320}
321
322impl Default for TrafficForm {
323 fn default() -> Self {
324 Self {
325 frequency_hz: "100".to_owned(),
326 unsolicited: false,
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
336pub struct CsiForm {
337 pub lltf: bool,
339 pub htltf: bool,
340 pub stbc_htltf: bool,
341 pub ltf_merge: bool,
342 pub csi: bool,
344 pub csi_legacy: bool,
345 pub csi_ht20: bool,
346 pub csi_ht40: bool,
347 pub csi_su: bool,
348 pub csi_mu: bool,
349 pub csi_dcm: bool,
350 pub csi_beamformed: bool,
351 pub dump_ack: bool,
352 pub csi_force_lltf: bool,
353 pub csi_vht: bool,
354 pub csi_he_stbc: String,
355 pub val_scale_cfg: String,
356}
357
358impl Default for CsiForm {
359 fn default() -> Self {
360 Self {
361 lltf: true,
362 htltf: true,
363 stbc_htltf: true,
364 ltf_merge: true,
365 csi: true,
366 csi_legacy: true,
367 csi_ht20: true,
368 csi_ht40: true,
369 csi_su: true,
370 csi_mu: true,
371 csi_dcm: true,
372 csi_beamformed: true,
373 dump_ack: true,
374 csi_force_lltf: true,
375 csi_vht: true,
376 csi_he_stbc: "2".to_owned(),
377 val_scale_cfg: "2".to_owned(),
378 }
379 }
380}
381
382#[derive(Debug, Clone)]
384pub struct PhyRateForm {
385 pub rate: String,
386}
387
388impl Default for PhyRateForm {
389 fn default() -> Self {
390 Self {
391 rate: "mcs0-lgi".to_owned(),
392 }
393 }
394}
395
396#[derive(Debug, Clone)]
398pub struct IoTasksForm {
399 pub tx: bool,
400 pub rx: bool,
401}
402
403impl Default for IoTasksForm {
404 fn default() -> Self {
405 Self { tx: true, rx: true }
406 }
407}
408
409#[derive(Debug, Clone)]
411pub struct CsiDeliveryForm {
412 pub mode: CsiDeliveryMode,
413 pub logging: bool,
414}
415
416impl Default for CsiDeliveryForm {
417 fn default() -> Self {
418 Self {
419 mode: CsiDeliveryMode::Async,
420 logging: true,
421 }
422 }
423}
424
425#[derive(Debug, Clone, Default)]
430pub struct DeviceForms {
431 pub wifi: WiFiForm,
432 pub traffic: TrafficForm,
433 pub csi: CsiForm,
434 pub collection_mode: CollectionMode,
435 pub output_mode: OutputMode,
436 pub protocol: WifiProtocol,
437 pub phy_rate: PhyRateForm,
438 pub io_tasks: IoTasksForm,
439 pub csi_delivery: CsiDeliveryForm,
440 pub start_duration_seconds: String,
441}
442
443#[derive(Debug, Clone, Default)]
445pub struct FrameSummary {
446 pub timestamp: String,
447 pub length: usize,
448 pub preview_hex: String,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
453pub enum ServerStatus {
454 #[default]
456 Unknown,
457 Connecting,
459 Connected,
461 Disconnected,
463}
464
465impl ServerStatus {
466 pub fn label(self) -> &'static str {
468 match self {
469 Self::Unknown => "Not connected",
470 Self::Connecting => "Connecting…",
471 Self::Connected => "Connected",
472 Self::Disconnected => "Disconnected",
473 }
474 }
475}
476
477#[derive(Debug, Clone)]
479pub struct TransientUiState {
480 pub active_tab: Tab,
481 pub status_message: String,
482 pub error_message: String,
483 pub server_status: ServerStatus,
484 pub preset_channel: String,
486}
487
488impl Default for TransientUiState {
489 fn default() -> Self {
490 Self {
491 active_tab: Tab::Devices,
492 status_message: "Ready".to_owned(),
493 error_message: String::new(),
494 server_status: ServerStatus::Unknown,
495 preset_channel: "6".to_owned(),
496 }
497 }
498}
499
500#[derive(Debug, Clone)]
505pub struct DeviceState {
506 pub id: String,
508 pub mac: Option<String>,
510 pub port_path: Option<String>,
512 pub baud_rate: Option<u32>,
513 pub serial_connected: Option<bool>,
514 pub collection_running: Option<bool>,
515 pub firmware_verified: Option<bool>,
516 pub fault: Option<String>,
519 pub latest_info: Option<DeviceInfo>,
520 pub forms: DeviceForms,
522 pub latest_config: Option<DeviceConfig>,
523 pub auto_resetting_cache: bool,
526 pub ws_connected: bool,
528 pub frames_received: u64,
529 pub bytes_received: u64,
530 pub recent_frames: Vec<FrameSummary>,
531 pub auto_scroll_stream: bool,
532 pub details_loaded: bool,
535 pub recording: bool,
538 pub record_path: Option<String>,
540 pub recorded_frames: u64,
542 pub record_decode_errors: u64,
544}
545
546impl DeviceState {
547 pub fn new(id: impl Into<String>) -> Self {
549 Self {
550 id: id.into(),
551 mac: None,
552 port_path: None,
553 baud_rate: None,
554 serial_connected: None,
555 collection_running: None,
556 firmware_verified: None,
557 fault: None,
558 latest_info: None,
559 forms: DeviceForms::default(),
560 latest_config: None,
561 auto_resetting_cache: false,
562 ws_connected: false,
563 frames_received: 0,
564 bytes_received: 0,
565 recent_frames: Vec::new(),
566 auto_scroll_stream: true,
567 details_loaded: false,
568 recording: false,
569 record_path: None,
570 recorded_frames: 0,
571 record_decode_errors: 0,
572 }
573 }
574
575 pub fn apply_list_entry(&mut self, entry: &DeviceListEntry) {
580 self.mac = entry.mac.clone();
581 self.port_path = entry.port_path.clone();
582 self.baud_rate = entry.baud_rate;
583 self.serial_connected = entry.serial_connected;
584 self.collection_running = entry.collection_running;
585 self.firmware_verified = entry.firmware_verified;
586 self.fault = entry.fault.clone();
587 if let Some(info) = &entry.device_info {
588 self.latest_info = Some(info.clone());
589 }
590 }
591
592 pub fn push_frame(&mut self, bytes: &[u8]) {
594 self.frames_received = self.frames_received.saturating_add(1);
595 self.bytes_received = self.bytes_received.saturating_add(bytes.len() as u64);
596
597 let preview = bytes
598 .iter()
599 .take(24)
600 .map(|b| format!("{b:02X}"))
601 .collect::<Vec<_>>()
602 .join(" ");
603
604 self.recent_frames.push(FrameSummary {
605 timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
606 length: bytes.len(),
607 preview_hex: preview,
608 });
609
610 if self.recent_frames.len() > 300 {
611 let drain_to = self.recent_frames.len() - 300;
612 self.recent_frames.drain(0..drain_to);
613 }
614 }
615
616 pub fn clear_frames(&mut self) {
618 self.recent_frames.clear();
619 self.frames_received = 0;
620 self.bytes_received = 0;
621 }
622
623 pub fn apply_control_status(&mut self, status: ControlStatus) {
625 self.serial_connected = status.serial_connected;
626 self.collection_running = status.collection_running;
627 self.port_path = status.port_path;
628 }
629
630 pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
635 let mut applied = 0;
636 let forms = &mut self.forms;
637
638 if let Some(wifi) = config.wifi.as_ref() {
639 if let Some(mode) = wifi.mode.as_deref() {
640 if let Some(parsed) = WiFiMode::from_api_value(mode) {
641 forms.wifi.mode = parsed;
642 applied += 1;
643 }
644 }
645 if let Some(channel) = wifi.channel {
646 forms.wifi.channel = channel.to_string();
647 applied += 1;
648 }
649 if let Some(ssid) = &wifi.sta_ssid {
650 forms.wifi.sta_ssid = ssid.clone();
651 applied += 1;
652 }
653 if let Some(ap_ssid) = &wifi.ap_ssid {
654 forms.wifi.ap_ssid = ap_ssid.clone();
655 applied += 1;
656 }
657 if let Some(ap_dhcp) = wifi.ap_dhcp {
658 forms.wifi.ap_dhcp = ap_dhcp;
659 applied += 1;
660 }
661 if let Some(peer_mac) = &wifi.peer_mac {
662 forms.wifi.peer_mac = if peer_mac == "auto" {
665 String::new()
666 } else {
667 peer_mac.clone()
668 };
669 applied += 1;
670 }
671 if let Some(ht40) = wifi.ht40.as_deref() {
672 if let Some(parsed) = Ht40Mode::from_api_value(ht40) {
673 forms.wifi.ht40 = parsed;
674 applied += 1;
675 }
676 }
677 }
678
679 if let Some(collection) = config.collection.as_ref() {
680 if let Some(traffic_hz) = collection.traffic_hz {
681 forms.traffic.frequency_hz = traffic_hz.to_string();
682 applied += 1;
683 }
684 if let Some(unsolicited) = collection.unsolicited {
685 forms.traffic.unsolicited = unsolicited;
686 applied += 1;
687 }
688 if let Some(mode) = collection.mode.as_deref() {
689 forms.collection_mode = if mode == "listener" {
690 CollectionMode::Listener
691 } else {
692 CollectionMode::Collector
693 };
694 applied += 1;
695 }
696 if let Some(rate) = &collection.phy_rate {
697 forms.phy_rate.rate = rate.clone();
698 applied += 1;
699 }
700 if let Some(protocol) = collection.protocol.as_deref() {
701 if let Some(parsed) = WifiProtocol::from_api_value(protocol) {
702 forms.protocol = parsed;
703 applied += 1;
704 }
705 }
706 if let Some(tx) = collection.io_tx_enabled {
707 forms.io_tasks.tx = tx;
708 applied += 1;
709 }
710 if let Some(rx) = collection.io_rx_enabled {
711 forms.io_tasks.rx = rx;
712 applied += 1;
713 }
714 }
715
716 if let Some(csi_cfg) = config.csi_config.as_ref() {
717 if let Some(v) = csi_cfg.lltf_enabled {
718 forms.csi.lltf = v;
719 applied += 1;
720 }
721 if let Some(v) = csi_cfg.htltf_enabled {
722 forms.csi.htltf = v;
723 applied += 1;
724 }
725 if let Some(v) = csi_cfg.stbc_htltf_enabled {
726 forms.csi.stbc_htltf = v;
727 applied += 1;
728 }
729 if let Some(v) = csi_cfg.ltf_merge_enabled {
730 forms.csi.ltf_merge = v;
731 applied += 1;
732 }
733 if let Some(v) = csi_cfg.acquire_csi {
734 forms.csi.csi = v != 0;
735 applied += 1;
736 }
737 if let Some(v) = csi_cfg.acquire_csi_legacy {
738 forms.csi.csi_legacy = v != 0;
739 applied += 1;
740 }
741 if let Some(v) = csi_cfg.acquire_csi_ht20 {
742 forms.csi.csi_ht20 = v != 0;
743 applied += 1;
744 }
745 if let Some(v) = csi_cfg.acquire_csi_ht40 {
746 forms.csi.csi_ht40 = v != 0;
747 applied += 1;
748 }
749 if let Some(v) = csi_cfg.acquire_csi_su {
750 forms.csi.csi_su = v != 0;
751 applied += 1;
752 }
753 if let Some(v) = csi_cfg.acquire_csi_mu {
754 forms.csi.csi_mu = v != 0;
755 applied += 1;
756 }
757 if let Some(v) = csi_cfg.acquire_csi_dcm {
758 forms.csi.csi_dcm = v != 0;
759 applied += 1;
760 }
761 if let Some(v) = csi_cfg.acquire_csi_beamformed {
762 forms.csi.csi_beamformed = v != 0;
763 applied += 1;
764 }
765 if let Some(v) = csi_cfg.dump_ack_enabled {
766 forms.csi.dump_ack = v;
767 applied += 1;
768 }
769 if let Some(v) = csi_cfg.acquire_csi_force_lltf {
770 forms.csi.csi_force_lltf = v;
771 applied += 1;
772 }
773 if let Some(v) = csi_cfg.acquire_csi_vht {
774 forms.csi.csi_vht = v;
775 applied += 1;
776 }
777 if let Some(v) = csi_cfg.csi_he_stbc {
778 forms.csi.csi_he_stbc = v.to_string();
779 applied += 1;
780 }
781 if let Some(v) = csi_cfg.val_scale_cfg {
782 forms.csi.val_scale_cfg = v.to_string();
783 applied += 1;
784 }
785 }
786
787 if let Some(mode) = config.csi_delivery_mode.as_deref() {
788 forms.csi_delivery.mode = match mode {
789 "off" => CsiDeliveryMode::Off,
790 "callback" => CsiDeliveryMode::Callback,
791 "raw" => CsiDeliveryMode::Raw,
792 _ => CsiDeliveryMode::Async,
793 };
794 applied += 1;
795 }
796 if let Some(logging) = config.csi_logging_enabled {
797 forms.csi_delivery.logging = logging;
798 applied += 1;
799 }
800
801 self.latest_config = Some(config);
802 applied
803 }
804}
805
806#[derive(Debug, Clone)]
808pub enum UserIntent {
809 FetchDevices,
811 ToggleDeviceSelection(String),
813 SelectAllDevices,
815 ClearDeviceSelection,
817 StartAllCollections { duration_seconds: String },
819 StopAllCollections,
821 StartSelectedCollections { duration_seconds: String },
823 StopSelectedCollections,
825 StartSelectedRecording,
827 StopSelectedRecording,
829 Device { id: String, action: DeviceAction },
831}
832
833#[derive(Debug, Clone)]
835pub enum DeviceAction {
836 FetchConfig,
837 FetchInfo,
838 FetchStatus,
839 ResetConfig,
840 SetWifi(WiFiForm),
841 SetTraffic(TrafficForm),
842 SetCsi(CsiForm),
843 SetCsiPreset(&'static str),
845 SetCollectionMode(CollectionMode),
846 SetOutputMode(OutputMode),
847 SetProtocol(WifiProtocol),
848 SetPhyRate(PhyRateForm),
849 SetIoTasks(IoTasksForm),
850 SetCsiDelivery(CsiDeliveryForm),
851 StartCollection { duration_seconds: String },
852 StopCollection,
853 ShowStats,
854 ResetDevice,
855 ConnectWebSocket,
856 DisconnectWebSocket,
857 ClearFrames,
858 StartRecording,
860 StopRecording,
862 ApplyPairingPreset {
864 preset: PairingPreset,
865 device_ids: [String; 2],
866 channel: u8,
867 },
868}
869
870#[derive(Debug, Clone, Serialize, Deserialize, Default)]
872pub struct DeviceListEntry {
873 pub id: String,
874 pub mac: Option<String>,
875 pub port_path: Option<String>,
876 pub baud_rate: Option<u32>,
877 pub serial_connected: Option<bool>,
878 pub collection_running: Option<bool>,
879 pub firmware_verified: Option<bool>,
880 #[serde(default)]
881 pub device_info: Option<DeviceInfo>,
882 #[serde(default)]
883 pub fault: Option<String>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, Default)]
888pub struct DeviceWifiConfig {
889 pub mode: Option<String>,
890 pub channel: Option<u16>,
891 pub sta_ssid: Option<String>,
892 pub ap_ssid: Option<String>,
893 pub ap_dhcp: Option<bool>,
894 pub peer_mac: Option<String>,
895 pub ht40: Option<String>,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, Default)]
900pub struct DeviceCollectionConfig {
901 pub mode: Option<String>,
902 pub traffic_hz: Option<u64>,
903 pub unsolicited: Option<bool>,
904 pub phy_rate: Option<String>,
905 pub protocol: Option<String>,
906 pub io_tx_enabled: Option<bool>,
907 pub io_rx_enabled: Option<bool>,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, Default)]
916pub struct DeviceCsiConfig {
917 pub lltf_enabled: Option<bool>,
918 pub htltf_enabled: Option<bool>,
919 pub stbc_htltf_enabled: Option<bool>,
920 pub ltf_merge_enabled: Option<bool>,
921 pub channel_filter_enabled: Option<bool>,
922 pub manual_scale: Option<bool>,
923 pub shift: Option<i32>,
924 pub dump_ack_enabled: Option<bool>,
925 pub acquire_csi_force_lltf: Option<bool>,
926 pub acquire_csi_vht: Option<bool>,
927 pub acquire_csi: Option<u32>,
928 pub acquire_csi_legacy: Option<u32>,
929 pub acquire_csi_ht20: Option<u32>,
930 pub acquire_csi_ht40: Option<u32>,
931 pub acquire_csi_su: Option<u32>,
932 pub acquire_csi_mu: Option<u32>,
933 pub acquire_csi_dcm: Option<u32>,
934 pub acquire_csi_beamformed: Option<u32>,
935 pub csi_he_stbc: Option<u32>,
936 pub val_scale_cfg: Option<u32>,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize, Default)]
945pub struct DeviceConfig {
946 #[serde(default)]
947 pub wifi: Option<DeviceWifiConfig>,
948 #[serde(default)]
949 pub collection: Option<DeviceCollectionConfig>,
950 #[serde(default)]
951 pub csi_config: Option<DeviceCsiConfig>,
952 pub csi_delivery_mode: Option<String>,
953 pub csi_logging_enabled: Option<bool>,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize, Default)]
958pub struct DeviceInfo {
959 pub banner_version: Option<String>,
960 pub name: Option<String>,
961 pub version: Option<String>,
962 pub chip: Option<String>,
963 pub mac: Option<String>,
964 pub protocol: Option<u32>,
965 #[serde(default)]
966 pub features: Vec<String>,
967}
968
969impl DeviceInfo {
970 pub fn supports_v07_modes(&self) -> bool {
972 firmware_version_at_least(&self.version, 0, 7, 0)
973 || firmware_version_at_least(&self.banner_version, 0, 7, 0)
974 }
975
976 pub fn supports_unsolicited(&self) -> bool {
981 firmware_version_at_least(&self.version, 0, 7, 1)
982 || firmware_version_at_least(&self.banner_version, 0, 7, 1)
983 }
984}
985
986impl DeviceState {
987 pub fn supports_v07_modes(&self) -> bool {
989 self.latest_info
990 .as_ref()
991 .is_some_and(DeviceInfo::supports_v07_modes)
992 }
993
994 pub fn supports_unsolicited(&self) -> bool {
996 self.latest_info
997 .as_ref()
998 .is_some_and(DeviceInfo::supports_unsolicited)
999 }
1000}
1001
1002fn firmware_version_at_least(
1004 version: &Option<String>,
1005 req_major: u64,
1006 req_minor: u64,
1007 req_patch: u64,
1008) -> bool {
1009 let Some(v) = version.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
1010 return false;
1011 };
1012 let mut parts = v.split('.');
1013 let Some(Ok(major)) = parts.next().map(str::parse) else {
1014 return false;
1015 };
1016 let Some(Ok(minor)) = parts.next().map(str::parse) else {
1017 return false;
1018 };
1019 let patch: u64 = parts
1020 .next()
1021 .unwrap_or("0")
1022 .parse()
1023 .unwrap_or(0);
1024 (major, minor, patch) >= (req_major, req_minor, req_patch)
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1029pub struct ControlStatus {
1030 pub serial_connected: Option<bool>,
1031 pub collection_running: Option<bool>,
1032 pub port_path: Option<String>,
1033}
1034
1035#[derive(Debug, Clone, Default)]
1037pub struct ReconcileOutcome {
1038 pub new_ids: Vec<String>,
1040 pub removed_ids: Vec<String>,
1042 pub changed: bool,
1044}
1045
1046#[derive(Debug, Clone, Default)]
1050pub struct AppState {
1051 pub server_host: String,
1052 pub server_port: String,
1053 pub export_dir: String,
1055 pub devices: Vec<DeviceState>,
1056 pub selected_device_ids: Vec<String>,
1059 pub transient: TransientUiState,
1060 pub events: Vec<String>,
1061 intent_queue: Vec<UserIntent>,
1062}
1063
1064impl AppState {
1065 pub fn with_defaults() -> Self {
1067 let mut state = Self::default();
1068 state.server_host = "127.0.0.1".to_owned();
1069 state.server_port = "3000".to_owned();
1070 state.export_dir = ".".to_owned();
1071 state
1072 }
1073
1074 pub fn push_intent(&mut self, intent: UserIntent) {
1076 self.intent_queue.push(intent);
1077 }
1078
1079 pub fn push_device_action(&mut self, id: impl Into<String>, action: DeviceAction) {
1081 self.intent_queue.push(UserIntent::Device {
1082 id: id.into(),
1083 action,
1084 });
1085 }
1086
1087 pub fn drain_intents(&mut self) -> Vec<UserIntent> {
1089 std::mem::take(&mut self.intent_queue)
1090 }
1091
1092 pub fn push_event(&mut self, message: impl Into<String>) {
1094 self.events.push(message.into());
1095 if self.events.len() > 300 {
1096 let drain_to = self.events.len() - 300;
1097 self.events.drain(0..drain_to);
1098 }
1099 }
1100
1101 pub fn selected_indices(&self) -> Vec<usize> {
1104 self.devices
1105 .iter()
1106 .enumerate()
1107 .filter(|(_, d)| self.selected_device_ids.iter().any(|id| id == &d.id))
1108 .map(|(idx, _)| idx)
1109 .collect()
1110 }
1111
1112 pub fn is_selected(&self, id: &str) -> bool {
1114 self.selected_device_ids.iter().any(|s| s == id)
1115 }
1116
1117 pub fn toggle_selection(&mut self, id: String) {
1119 if let Some(pos) = self.selected_device_ids.iter().position(|s| s == &id) {
1120 self.selected_device_ids.remove(pos);
1121 } else {
1122 self.selected_device_ids.push(id);
1123 }
1124 }
1125
1126 pub fn device_index_by_id(&self, id: &str) -> Option<usize> {
1128 self.devices.iter().position(|d| d.id == id)
1129 }
1130
1131 pub fn device_mut_by_id(&mut self, id: &str) -> Option<&mut DeviceState> {
1133 self.devices.iter_mut().find(|d| d.id == id)
1134 }
1135
1136 pub fn base_http_url(&self) -> String {
1138 format!(
1139 "http://{}:{}",
1140 self.server_host.trim(),
1141 self.server_port.trim()
1142 )
1143 }
1144
1145 pub fn device_ws_url(&self, id: &str) -> String {
1147 format!(
1148 "ws://{}:{}/api/devices/{}/ws",
1149 self.server_host.trim(),
1150 self.server_port.trim(),
1151 id
1152 )
1153 }
1154
1155 pub fn reconcile_devices(&mut self, entries: Vec<DeviceListEntry>) -> ReconcileOutcome {
1161 let mut outcome = ReconcileOutcome::default();
1162
1163 let incoming_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1164
1165 outcome.removed_ids = self
1166 .devices
1167 .iter()
1168 .map(|d| d.id.clone())
1169 .filter(|id| !incoming_ids.contains(id))
1170 .collect();
1171
1172 let before = self.devices.len();
1173 self.devices.retain(|d| incoming_ids.contains(&d.id));
1174 if self.devices.len() != before {
1175 outcome.changed = true;
1176 }
1177
1178 for entry in &entries {
1179 if let Some(idx) = self.device_index_by_id(&entry.id) {
1180 self.devices[idx].apply_list_entry(entry);
1181 } else {
1182 let mut device = DeviceState::new(entry.id.clone());
1183 device.apply_list_entry(entry);
1184 self.devices.push(device);
1185 outcome.new_ids.push(entry.id.clone());
1186 outcome.changed = true;
1187 }
1188 }
1189
1190 let before_selection = self.selected_device_ids.len();
1192 self.selected_device_ids
1193 .retain(|id| self.devices.iter().any(|d| &d.id == id));
1194 if self.selected_device_ids.len() != before_selection {
1195 outcome.changed = true;
1196 }
1197 if self.selected_device_ids.is_empty() {
1200 if let Some(first) = self.devices.first() {
1201 self.selected_device_ids.push(first.id.clone());
1202 outcome.changed = true;
1203 }
1204 }
1205
1206 outcome
1207 }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213
1214 #[test]
1215 fn device_config_parses_full_nested_response() {
1216 let json = r#"{
1217 "wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
1218 "collection": {
1219 "mode": "collector", "traffic_hz": 100, "unsolicited": true,
1220 "phy_rate": "mcs0-lgi",
1221 "protocol": "n", "io_tx_enabled": true, "io_rx_enabled": true
1222 },
1223 "csi_config": {
1224 "lltf_enabled": true, "htltf_enabled": true,
1225 "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
1226 "csi_he_stbc": 2, "val_scale_cfg": 2,
1227 "acquire_csi": 1, "acquire_csi_legacy": 0
1228 },
1229 "csi_delivery_mode": "async",
1230 "csi_logging_enabled": true
1231 }"#;
1232 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1233 let mut device = DeviceState::new("ttyUSB0");
1234 let applied = device.apply_device_config(cfg);
1235 assert!(applied > 0);
1236 assert_eq!(device.forms.wifi.mode, WiFiMode::Sniffer);
1237 assert_eq!(device.forms.wifi.channel, "6");
1238 assert_eq!(device.forms.traffic.frequency_hz, "100");
1239 assert!(device.forms.traffic.unsolicited);
1240 assert!(device.forms.csi.csi);
1241 assert!(!device.forms.csi.csi_legacy);
1242 assert_eq!(device.forms.protocol, WifiProtocol::N);
1243 }
1244
1245 #[test]
1246 fn device_config_tolerates_null_sub_objects() {
1247 let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
1248 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
1249 let mut device = DeviceState::new("ttyUSB0");
1250 assert_eq!(device.apply_device_config(cfg), 0);
1251 }
1252
1253 #[test]
1254 fn device_config_tolerates_missing_sub_objects() {
1255 let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
1256 let mut device = DeviceState::new("ttyUSB0");
1257 assert_eq!(device.apply_device_config(cfg), 0);
1258 }
1259
1260 #[test]
1261 fn wifi_mode_parses_v07_values() {
1262 assert_eq!(
1263 WiFiMode::from_api_value("wifi-ap"),
1264 Some(WiFiMode::WifiAp)
1265 );
1266 assert!(WiFiMode::EspNowFastCollector.is_esp_now());
1267 assert!(WiFiMode::WifiAp.requires_v07());
1268 }
1269
1270 #[test]
1271 fn wifi_mode_station_disallows_channel() {
1272 assert!(!WiFiMode::Station.allows_channel());
1273 assert!(WiFiMode::Sniffer.allows_channel());
1274 assert!(WiFiMode::WifiAp.allows_channel());
1275 assert!(WiFiMode::EspNowCentral.allows_channel());
1276 }
1277
1278 #[test]
1279 fn device_config_applies_ap_fields() {
1280 let json = r#"{
1281 "wifi": {
1282 "mode": "wifi-ap",
1283 "channel": 6,
1284 "ap_ssid": "lab-ap",
1285 "ap_dhcp": false
1286 }
1287 }"#;
1288 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1289 let mut device = DeviceState::new("D0CF13E290E8");
1290 device.apply_device_config(cfg);
1291 assert_eq!(device.forms.wifi.mode, WiFiMode::WifiAp);
1292 assert_eq!(device.forms.wifi.ap_ssid, "lab-ap");
1293 assert!(!device.forms.wifi.ap_dhcp);
1294 }
1295
1296 #[test]
1297 fn firmware_version_gating() {
1298 let info = DeviceInfo {
1299 version: Some("0.7.0".to_owned()),
1300 ..Default::default()
1301 };
1302 assert!(info.supports_v07_modes());
1303 let old = DeviceInfo {
1304 version: Some("0.6.0".to_owned()),
1305 ..Default::default()
1306 };
1307 assert!(!old.supports_v07_modes());
1308
1309 let v070 = DeviceInfo {
1312 version: Some("0.7.0".to_owned()),
1313 ..Default::default()
1314 };
1315 assert!(!v070.supports_unsolicited());
1316 let v071 = DeviceInfo {
1317 version: Some("0.7.1".to_owned()),
1318 ..Default::default()
1319 };
1320 assert!(v071.supports_unsolicited());
1321 }
1322
1323 #[test]
1324 fn device_list_parses_mac_field() {
1325 let json = r#"[
1326 {
1327 "id": "D0-CF-13-E2-90-E8",
1328 "mac": "D0:CF:13:E2:90:E8",
1329 "port_path": "/dev/ttyACM0",
1330 "serial_connected": true,
1331 "firmware_verified": true
1332 }
1333 ]"#;
1334 let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1335 let mut state = AppState::with_defaults();
1336 state.reconcile_devices(entries);
1337 assert_eq!(state.devices[0].id, "D0-CF-13-E2-90-E8");
1338 assert_eq!(
1339 state.devices[0].mac.as_deref(),
1340 Some("D0:CF:13:E2:90:E8")
1341 );
1342 }
1343
1344 #[test]
1345 fn device_list_parses_and_reconciles() {
1346 let json = r#"[
1347 {
1348 "id": "ttyUSB0", "port_path": "/dev/ttyUSB0", "baud_rate": 115200,
1349 "serial_connected": true, "collection_running": false,
1350 "firmware_verified": true,
1351 "device_info": { "name": "esp-csi-cli-rs", "chip": "esp32c6", "protocol": 1 },
1352 "fault": "USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — replug"
1353 }
1354 ]"#;
1355 let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1356 let mut state = AppState::with_defaults();
1357 let outcome = state.reconcile_devices(entries);
1358 assert!(outcome.changed);
1359 assert_eq!(outcome.new_ids, vec!["ttyUSB0".to_owned()]);
1360 assert_eq!(state.devices.len(), 1);
1361 assert_eq!(state.selected_device_ids, vec!["ttyUSB0".to_owned()]);
1362 let device = &state.devices[0];
1363 assert_eq!(device.serial_connected, Some(true));
1364 assert_eq!(device.latest_info.as_ref().unwrap().chip.as_deref(), Some("esp32c6"));
1365 assert!(device.fault.as_deref().unwrap().contains("USB-JTAG reset loop"));
1366 }
1367
1368 #[test]
1369 fn reconcile_drops_vanished_and_reselects() {
1370 let mut state = AppState::with_defaults();
1371 state.reconcile_devices(vec![
1372 DeviceListEntry { id: "a".to_owned(), ..Default::default() },
1373 DeviceListEntry { id: "b".to_owned(), ..Default::default() },
1374 ]);
1375 assert_eq!(state.selected_device_ids, vec!["a".to_owned()]);
1376
1377 let outcome = state.reconcile_devices(vec![DeviceListEntry {
1379 id: "b".to_owned(),
1380 ..Default::default()
1381 }]);
1382 assert!(outcome.changed);
1383 assert_eq!(outcome.removed_ids, vec!["a".to_owned()]);
1384 assert_eq!(state.devices.len(), 1);
1385 assert_eq!(state.selected_device_ids, vec!["b".to_owned()]);
1386 }
1387}