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, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum WiFiMode {
21 Station,
22 Sniffer,
23 WifiAp,
24 EspNowCentral,
25 EspNowPeripheral,
26 EspNowFastCollector,
27 EspNowFastSource,
28}
29
30impl WiFiMode {
31 pub fn as_api_value(self) -> &'static str {
33 match self {
34 Self::Station => "station",
35 Self::Sniffer => "sniffer",
36 Self::WifiAp => "wifi-ap",
37 Self::EspNowCentral => "esp-now-central",
38 Self::EspNowPeripheral => "esp-now-peripheral",
39 Self::EspNowFastCollector => "esp-now-fast-collector",
40 Self::EspNowFastSource => "esp-now-fast-source",
41 }
42 }
43
44 pub fn from_api_value(value: &str) -> Option<Self> {
46 match value {
47 "station" => Some(Self::Station),
48 "sniffer" => Some(Self::Sniffer),
49 "wifi-ap" => Some(Self::WifiAp),
50 "esp-now-central" => Some(Self::EspNowCentral),
51 "esp-now-peripheral" => Some(Self::EspNowPeripheral),
52 "esp-now-fast-collector" => Some(Self::EspNowFastCollector),
53 "esp-now-fast-source" => Some(Self::EspNowFastSource),
54 _ => None,
55 }
56 }
57
58 pub fn is_esp_now(self) -> bool {
60 matches!(
61 self,
62 Self::EspNowCentral
63 | Self::EspNowPeripheral
64 | Self::EspNowFastCollector
65 | Self::EspNowFastSource
66 )
67 }
68
69 pub fn requires_v07(self) -> bool {
71 matches!(
72 self,
73 Self::WifiAp | Self::EspNowFastCollector | Self::EspNowFastSource
74 )
75 }
76
77 pub fn channel_is_hint(self) -> bool {
85 matches!(self, Self::Station)
86 }
87}
88
89impl Default for WiFiMode {
90 fn default() -> Self {
91 Self::Station
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum CollectionMode {
99 Collector,
100 Listener,
101}
102
103impl CollectionMode {
104 pub fn as_api_value(self) -> &'static str {
106 match self {
107 Self::Collector => "collector",
108 Self::Listener => "listener",
109 }
110 }
111}
112
113impl Default for CollectionMode {
114 fn default() -> Self {
115 Self::Collector
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum Ht40Mode {
125 #[default]
126 None,
127 Above,
128 Below,
129}
130
131impl Ht40Mode {
132 pub fn as_api_value(self) -> &'static str {
134 match self {
135 Self::None => "none",
136 Self::Above => "above",
137 Self::Below => "below",
138 }
139 }
140
141 pub fn from_api_value(value: &str) -> Option<Self> {
143 match value {
144 "none" | "off" => Some(Self::None),
145 "above" => Some(Self::Above),
146 "below" => Some(Self::Below),
147 _ => None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum OutputMode {
156 Stream,
157 Dump,
158 Both,
159}
160
161impl OutputMode {
162 pub fn as_api_value(self) -> &'static str {
164 match self {
165 Self::Stream => "stream",
166 Self::Dump => "dump",
167 Self::Both => "both",
168 }
169 }
170}
171
172impl Default for OutputMode {
173 fn default() -> Self {
174 Self::Stream
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum CsiDeliveryMode {
182 Off,
183 Callback,
184 Async,
185 Raw,
188}
189
190impl CsiDeliveryMode {
191 pub fn as_api_value(self) -> &'static str {
192 match self {
193 Self::Off => "off",
194 Self::Callback => "callback",
195 Self::Async => "async",
196 Self::Raw => "raw",
197 }
198 }
199}
200
201impl Default for CsiDeliveryMode {
202 fn default() -> Self {
203 Self::Async
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum WifiProtocol {
220 B,
221 G,
222 N,
223 Lr,
224 A,
225 Ac,
226 Ext(&'static str),
229}
230
231impl WifiProtocol {
232 pub fn as_api_value(self) -> &'static str {
234 match self {
235 Self::B => "b",
236 Self::G => "g",
237 Self::N => "n",
238 Self::Lr => "lr",
239 Self::A => "a",
240 Self::Ac => "ac",
241 Self::Ext(s) => s,
242 }
243 }
244
245 pub fn from_api_value(value: &str) -> Option<Self> {
252 Some(match value.to_ascii_lowercase().as_str() {
253 "b" => Self::B,
254 "g" => Self::G,
255 "n" => Self::N,
256 "lr" => Self::Lr,
257 "a" => Self::A,
258 "ac" => Self::Ac,
259 other => Self::Ext(intern(other)),
260 })
261 }
262}
263
264fn intern(value: &str) -> &'static str {
269 Box::leak(value.to_owned().into_boxed_str())
270}
271
272impl Serialize for WifiProtocol {
273 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
274 serializer.serialize_str(self.as_api_value())
275 }
276}
277
278impl<'de> Deserialize<'de> for WifiProtocol {
279 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
280 let value = String::deserialize(deserializer)?;
281 Ok(Self::from_api_value(&value).unwrap_or_default())
282 }
283}
284
285impl Default for WifiProtocol {
286 fn default() -> Self {
287 Self::Lr
288 }
289}
290
291pub const PHY_RATES: &[&str] = &[
295 "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
296 "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
297 "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
298];
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(default)]
303pub struct WiFiForm {
304 pub mode: WiFiMode,
305 pub sta_ssid: String,
306 pub sta_password: String,
307 pub ap_ssid: String,
308 pub ap_password: String,
309 pub ap_dhcp: bool,
310 pub ap_leases: u8,
313 pub ap_burst: bool,
317 pub channel: String,
318 pub peer_mac: String,
321 pub ht40: Ht40Mode,
323}
324
325impl Default for WiFiForm {
326 fn default() -> Self {
327 Self {
328 mode: WiFiMode::Station,
329 sta_ssid: String::new(),
330 sta_password: String::new(),
331 ap_ssid: "esp-csi-ap".to_owned(),
332 ap_password: String::new(),
333 ap_dhcp: true,
334 ap_leases: 4,
335 ap_burst: false,
336 channel: String::new(),
337 peer_mac: String::new(),
338 ht40: Ht40Mode::None,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum PairingPreset {
346 SoftApLab,
347 EspNowFastSimplex,
348 EspNowBalanced,
349}
350
351impl PairingPreset {
352 pub fn label(self) -> &'static str {
353 match self {
354 Self::SoftApLab => "SoftAP lab pair",
355 Self::EspNowFastSimplex => "ESP-NOW fast simplex",
356 Self::EspNowBalanced => "ESP-NOW balanced",
357 }
358 }
359
360 pub fn requires_v07(self) -> bool {
362 matches!(self, Self::SoftApLab | Self::EspNowFastSimplex)
363 }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(default)]
369pub struct TrafficForm {
370 pub frequency_hz: String,
371 pub unsolicited: bool,
376}
377
378impl Default for TrafficForm {
379 fn default() -> Self {
380 Self {
381 frequency_hz: "100".to_owned(),
382 unsolicited: false,
383 }
384 }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
392#[serde(default)]
393pub struct CsiForm {
394 pub lltf: bool,
396 pub htltf: bool,
397 pub stbc_htltf: bool,
398 pub ltf_merge: bool,
399 pub csi: bool,
401 pub csi_legacy: bool,
402 pub csi_ht20: bool,
403 pub csi_ht40: bool,
404 pub dump_ack: bool,
405 pub csi_force_lltf: bool,
406 pub csi_vht: bool,
407 pub val_scale_cfg: String,
408}
409
410impl Default for CsiForm {
411 fn default() -> Self {
412 Self {
413 lltf: true,
414 htltf: true,
415 stbc_htltf: true,
416 ltf_merge: true,
417 csi: true,
418 csi_legacy: true,
419 csi_ht20: true,
420 csi_ht40: true,
421 dump_ack: true,
422 csi_force_lltf: true,
423 csi_vht: true,
424 val_scale_cfg: "2".to_owned(),
425 }
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(default)]
432pub struct PhyRateForm {
433 pub rate: String,
434}
435
436impl Default for PhyRateForm {
437 fn default() -> Self {
438 Self {
439 rate: "mcs0-lgi".to_owned(),
440 }
441 }
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(default)]
447pub struct IoTasksForm {
448 pub tx: bool,
449 pub rx: bool,
450}
451
452impl Default for IoTasksForm {
453 fn default() -> Self {
454 Self { tx: true, rx: true }
455 }
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(default)]
461pub struct CsiDeliveryForm {
462 pub mode: CsiDeliveryMode,
463 pub logging: bool,
464}
465
466impl Default for CsiDeliveryForm {
467 fn default() -> Self {
468 Self {
469 mode: CsiDeliveryMode::Async,
470 logging: true,
471 }
472 }
473}
474
475#[derive(Debug, Clone, Default, Serialize, Deserialize)]
480#[serde(default)]
481pub struct DeviceForms {
482 pub wifi: WiFiForm,
483 pub traffic: TrafficForm,
484 pub csi: CsiForm,
485 pub collection_mode: CollectionMode,
486 pub output_mode: OutputMode,
487 pub protocol: WifiProtocol,
488 pub phy_rate: PhyRateForm,
489 pub io_tasks: IoTasksForm,
490 pub csi_delivery: CsiDeliveryForm,
491 pub start_duration_seconds: String,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct ConfigSnapshotFile {
502 #[serde(default = "snapshot_format_version")]
504 pub format: u32,
505 #[serde(default)]
507 pub device_id: Option<String>,
508 #[serde(default)]
510 pub saved_at: Option<String>,
511 pub forms: DeviceForms,
512}
513
514fn snapshot_format_version() -> u32 {
516 1
517}
518
519#[derive(Debug, Clone, Default)]
521pub struct FrameSummary {
522 pub timestamp: String,
523 pub length: usize,
524 pub preview_hex: String,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub enum ServerStatus {
530 #[default]
532 Unknown,
533 Connecting,
535 Connected,
537 Disconnected,
539}
540
541impl ServerStatus {
542 pub fn label(self) -> &'static str {
544 match self {
545 Self::Unknown => "Not connected",
546 Self::Connecting => "Connecting…",
547 Self::Connected => "Connected",
548 Self::Disconnected => "Disconnected",
549 }
550 }
551}
552
553#[derive(Debug, Clone)]
555pub struct TransientUiState {
556 pub active_tab: Tab,
557 pub status_message: String,
558 pub error_message: String,
559 pub server_status: ServerStatus,
560 pub preset_channel: String,
562}
563
564impl Default for TransientUiState {
565 fn default() -> Self {
566 Self {
567 active_tab: Tab::Devices,
568 status_message: "Ready".to_owned(),
569 error_message: String::new(),
570 server_status: ServerStatus::Unknown,
571 preset_channel: "6".to_owned(),
572 }
573 }
574}
575
576#[derive(Debug, Clone)]
581pub struct DeviceState {
582 pub id: String,
584 pub mac: Option<String>,
586 pub port_path: Option<String>,
588 pub baud_rate: Option<u32>,
589 pub serial_connected: Option<bool>,
590 pub collection_running: Option<bool>,
591 pub firmware_verified: Option<bool>,
592 pub fault: Option<String>,
595 pub latest_info: Option<DeviceInfo>,
596 pub forms: DeviceForms,
598 pub latest_config: Option<DeviceConfig>,
599 pub auto_resetting_cache: bool,
602 pub ws_connected: bool,
604 pub frames_received: u64,
605 pub bytes_received: u64,
606 pub recent_frames: Vec<FrameSummary>,
607 pub auto_scroll_stream: bool,
608 pub details_loaded: bool,
611 pub config_path: String,
614 pub copy_source: String,
616 pub recording: bool,
619 pub record_path: Option<String>,
621 pub recorded_frames: u64,
623 pub record_decode_errors: u64,
625}
626
627impl DeviceState {
628 pub fn new(id: impl Into<String>) -> Self {
630 Self {
631 id: id.into(),
632 mac: None,
633 port_path: None,
634 baud_rate: None,
635 serial_connected: None,
636 collection_running: None,
637 firmware_verified: None,
638 fault: None,
639 latest_info: None,
640 forms: DeviceForms::default(),
641 latest_config: None,
642 auto_resetting_cache: false,
643 ws_connected: false,
644 frames_received: 0,
645 bytes_received: 0,
646 recent_frames: Vec::new(),
647 auto_scroll_stream: true,
648 details_loaded: false,
649 config_path: String::new(),
650 copy_source: String::new(),
651 recording: false,
652 record_path: None,
653 recorded_frames: 0,
654 record_decode_errors: 0,
655 }
656 }
657
658 pub fn apply_list_entry(&mut self, entry: &DeviceListEntry) {
663 self.mac = entry.mac.clone();
664 self.port_path = entry.port_path.clone();
665 self.baud_rate = entry.baud_rate;
666 self.serial_connected = entry.serial_connected;
667 self.collection_running = entry.collection_running;
668 self.firmware_verified = entry.firmware_verified;
669 self.fault = entry.fault.clone();
670 if let Some(info) = &entry.device_info {
671 self.latest_info = Some(info.clone());
672 }
673 }
674
675 pub fn push_frame(&mut self, bytes: &[u8]) {
677 self.frames_received = self.frames_received.saturating_add(1);
678 self.bytes_received = self.bytes_received.saturating_add(bytes.len() as u64);
679
680 let preview = bytes
681 .iter()
682 .take(24)
683 .map(|b| format!("{b:02X}"))
684 .collect::<Vec<_>>()
685 .join(" ");
686
687 self.recent_frames.push(FrameSummary {
688 timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
689 length: bytes.len(),
690 preview_hex: preview,
691 });
692
693 if self.recent_frames.len() > 300 {
694 let drain_to = self.recent_frames.len() - 300;
695 self.recent_frames.drain(0..drain_to);
696 }
697 }
698
699 pub fn clear_frames(&mut self) {
701 self.recent_frames.clear();
702 self.frames_received = 0;
703 self.bytes_received = 0;
704 }
705
706 pub fn apply_control_status(&mut self, status: ControlStatus) {
708 self.serial_connected = status.serial_connected;
709 self.collection_running = status.collection_running;
710 self.port_path = status.port_path;
711 }
712
713 pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
718 let mut applied = 0;
719 let forms = &mut self.forms;
720
721 if let Some(wifi) = config.wifi.as_ref() {
722 if let Some(mode) = wifi.mode.as_deref() {
723 if let Some(parsed) = WiFiMode::from_api_value(mode) {
724 forms.wifi.mode = parsed;
725 applied += 1;
726 }
727 }
728 if let Some(channel) = wifi.channel {
729 forms.wifi.channel = channel.to_string();
730 applied += 1;
731 }
732 if let Some(ssid) = &wifi.sta_ssid {
733 forms.wifi.sta_ssid = ssid.clone();
734 applied += 1;
735 }
736 if let Some(ap_ssid) = &wifi.ap_ssid {
737 forms.wifi.ap_ssid = ap_ssid.clone();
738 applied += 1;
739 }
740 if let Some(ap_dhcp) = wifi.ap_dhcp {
741 forms.wifi.ap_dhcp = ap_dhcp;
742 applied += 1;
743 }
744 if let Some(ap_leases) = wifi.ap_leases {
745 forms.wifi.ap_leases = ap_leases;
746 applied += 1;
747 }
748 if let Some(ap_burst) = wifi.ap_burst {
749 forms.wifi.ap_burst = ap_burst;
750 applied += 1;
751 }
752 if let Some(peer_mac) = &wifi.peer_mac {
753 forms.wifi.peer_mac = if peer_mac == "auto" {
756 String::new()
757 } else {
758 peer_mac.clone()
759 };
760 applied += 1;
761 }
762 if let Some(ht40) = wifi.ht40.as_deref() {
763 if let Some(parsed) = Ht40Mode::from_api_value(ht40) {
764 forms.wifi.ht40 = parsed;
765 applied += 1;
766 }
767 }
768 }
769
770 if let Some(collection) = config.collection.as_ref() {
771 if let Some(traffic_hz) = collection.traffic_hz {
772 forms.traffic.frequency_hz = traffic_hz.to_string();
773 applied += 1;
774 }
775 if let Some(unsolicited) = collection.unsolicited {
776 forms.traffic.unsolicited = unsolicited;
777 applied += 1;
778 }
779 if let Some(mode) = collection.mode.as_deref() {
780 forms.collection_mode = if mode == "listener" {
781 CollectionMode::Listener
782 } else {
783 CollectionMode::Collector
784 };
785 applied += 1;
786 }
787 if let Some(rate) = &collection.phy_rate {
788 forms.phy_rate.rate = rate.clone();
789 applied += 1;
790 }
791 if let Some(protocol) = collection.protocol.as_deref() {
792 if let Some(parsed) = WifiProtocol::from_api_value(protocol) {
793 forms.protocol = parsed;
794 applied += 1;
795 }
796 }
797 if let Some(tx) = collection.io_tx_enabled {
798 forms.io_tasks.tx = tx;
799 applied += 1;
800 }
801 if let Some(rx) = collection.io_rx_enabled {
802 forms.io_tasks.rx = rx;
803 applied += 1;
804 }
805 }
806
807 if let Some(csi_cfg) = config.csi_config.as_ref() {
808 if let Some(v) = csi_cfg.lltf_enabled {
809 forms.csi.lltf = v;
810 applied += 1;
811 }
812 if let Some(v) = csi_cfg.htltf_enabled {
813 forms.csi.htltf = v;
814 applied += 1;
815 }
816 if let Some(v) = csi_cfg.stbc_htltf_enabled {
817 forms.csi.stbc_htltf = v;
818 applied += 1;
819 }
820 if let Some(v) = csi_cfg.ltf_merge_enabled {
821 forms.csi.ltf_merge = v;
822 applied += 1;
823 }
824 if let Some(v) = csi_cfg.acquire_csi {
825 forms.csi.csi = v != 0;
826 applied += 1;
827 }
828 if let Some(v) = csi_cfg.acquire_csi_legacy {
829 forms.csi.csi_legacy = v != 0;
830 applied += 1;
831 }
832 if let Some(v) = csi_cfg.acquire_csi_ht20 {
833 forms.csi.csi_ht20 = v != 0;
834 applied += 1;
835 }
836 if let Some(v) = csi_cfg.acquire_csi_ht40 {
837 forms.csi.csi_ht40 = v != 0;
838 applied += 1;
839 }
840 if let Some(v) = csi_cfg.dump_ack_enabled {
841 forms.csi.dump_ack = v;
842 applied += 1;
843 }
844 if let Some(v) = csi_cfg.acquire_csi_force_lltf {
845 forms.csi.csi_force_lltf = v;
846 applied += 1;
847 }
848 if let Some(v) = csi_cfg.acquire_csi_vht {
849 forms.csi.csi_vht = v;
850 applied += 1;
851 }
852 if let Some(v) = csi_cfg.val_scale_cfg {
853 forms.csi.val_scale_cfg = v.to_string();
854 applied += 1;
855 }
856 }
857
858 if let Some(mode) = config.csi_delivery_mode.as_deref() {
859 forms.csi_delivery.mode = match mode {
860 "off" => CsiDeliveryMode::Off,
861 "callback" => CsiDeliveryMode::Callback,
862 "raw" => CsiDeliveryMode::Raw,
863 _ => CsiDeliveryMode::Async,
864 };
865 applied += 1;
866 }
867 if let Some(logging) = config.csi_logging_enabled {
868 forms.csi_delivery.logging = logging;
869 applied += 1;
870 }
871
872 self.latest_config = Some(config);
873 applied
874 }
875}
876
877#[derive(Debug, Clone)]
879pub enum UserIntent {
880 FetchDevices,
882 ToggleDeviceSelection(String),
884 SelectAllDevices,
886 ClearDeviceSelection,
888 StartAllCollections { duration_seconds: String },
890 StopAllCollections,
892 StartSelectedCollections { duration_seconds: String },
894 StopSelectedCollections,
896 StartSelectedRecording,
898 StopSelectedRecording,
900 Device { id: String, action: DeviceAction },
902}
903
904#[derive(Debug, Clone)]
906pub enum DeviceAction {
907 FetchConfig,
908 FetchInfo,
909 FetchStatus,
910 ResetConfig,
911 SetWifi(WiFiForm),
912 SetTraffic(TrafficForm),
913 SetCsi(CsiForm),
914 SetCsiPreset(&'static str),
917 SetCollectionMode(CollectionMode),
918 SetOutputMode(OutputMode),
919 SetProtocol(WifiProtocol),
920 SetPhyRate(PhyRateForm),
921 SetIoTasks(IoTasksForm),
922 SetCsiDelivery(CsiDeliveryForm),
923 StartCollection { duration_seconds: String },
924 StopCollection,
925 ShowStats,
926 ResetDevice,
927 ConnectWebSocket,
928 DisconnectWebSocket,
929 ClearFrames,
930 StartRecording,
932 StopRecording,
934 ApplyPairingPreset {
936 preset: PairingPreset,
937 device_ids: [String; 2],
938 channel: u8,
939 },
940 SaveConfigFile { path: String },
943 LoadConfigFile { path: String },
946 ApplyFullConfig,
949 CopyConfigFrom { source_id: String },
951}
952
953#[derive(Debug, Clone, Serialize, Deserialize, Default)]
955pub struct DeviceListEntry {
956 pub id: String,
957 pub mac: Option<String>,
958 pub port_path: Option<String>,
959 pub baud_rate: Option<u32>,
960 pub serial_connected: Option<bool>,
961 pub collection_running: Option<bool>,
962 pub firmware_verified: Option<bool>,
963 #[serde(default)]
964 pub device_info: Option<DeviceInfo>,
965 #[serde(default)]
966 pub fault: Option<String>,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, Default)]
971pub struct DeviceWifiConfig {
972 pub mode: Option<String>,
973 pub channel: Option<u16>,
974 pub sta_ssid: Option<String>,
975 pub ap_ssid: Option<String>,
976 pub ap_dhcp: Option<bool>,
977 pub ap_leases: Option<u8>,
978 pub ap_burst: Option<bool>,
979 pub peer_mac: Option<String>,
980 pub ht40: Option<String>,
981}
982
983#[derive(Debug, Clone, Serialize, Deserialize, Default)]
985pub struct DeviceCollectionConfig {
986 pub mode: Option<String>,
987 pub traffic_hz: Option<u64>,
988 pub unsolicited: Option<bool>,
989 pub phy_rate: Option<String>,
990 pub protocol: Option<String>,
991 pub io_tx_enabled: Option<bool>,
992 pub io_rx_enabled: Option<bool>,
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1001pub struct DeviceCsiConfig {
1002 pub lltf_enabled: Option<bool>,
1003 pub htltf_enabled: Option<bool>,
1004 pub stbc_htltf_enabled: Option<bool>,
1005 pub ltf_merge_enabled: Option<bool>,
1006 pub channel_filter_enabled: Option<bool>,
1007 pub manual_scale: Option<bool>,
1008 pub shift: Option<i32>,
1009 pub dump_ack_enabled: Option<bool>,
1010 pub acquire_csi_force_lltf: Option<bool>,
1011 pub acquire_csi_vht: Option<bool>,
1012 pub acquire_csi: Option<u32>,
1013 pub acquire_csi_legacy: Option<u32>,
1014 pub acquire_csi_ht20: Option<u32>,
1015 pub acquire_csi_ht40: Option<u32>,
1016 pub val_scale_cfg: Option<u32>,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025pub struct DeviceConfig {
1026 #[serde(default)]
1027 pub wifi: Option<DeviceWifiConfig>,
1028 #[serde(default)]
1029 pub collection: Option<DeviceCollectionConfig>,
1030 #[serde(default)]
1031 pub csi_config: Option<DeviceCsiConfig>,
1032 pub csi_delivery_mode: Option<String>,
1033 pub csi_logging_enabled: Option<bool>,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1038pub struct DeviceInfo {
1039 pub banner_version: Option<String>,
1040 pub name: Option<String>,
1041 pub version: Option<String>,
1042 pub chip: Option<String>,
1043 pub mac: Option<String>,
1044 pub protocol: Option<u32>,
1045 #[serde(default)]
1046 pub features: Vec<String>,
1047}
1048
1049impl DeviceInfo {
1050 pub fn supports_v07_modes(&self) -> bool {
1052 firmware_version_at_least(&self.version, 0, 7, 0)
1053 || firmware_version_at_least(&self.banner_version, 0, 7, 0)
1054 }
1055
1056 pub fn supports_unsolicited(&self) -> bool {
1061 firmware_version_at_least(&self.version, 0, 7, 0)
1062 || firmware_version_at_least(&self.banner_version, 0, 7, 0)
1063 }
1064}
1065
1066impl DeviceState {
1067 pub fn supports_v07_modes(&self) -> bool {
1069 self.latest_info
1070 .as_ref()
1071 .is_some_and(DeviceInfo::supports_v07_modes)
1072 }
1073
1074 pub fn supports_unsolicited(&self) -> bool {
1076 self.latest_info
1077 .as_ref()
1078 .is_some_and(DeviceInfo::supports_unsolicited)
1079 }
1080}
1081
1082fn firmware_version_at_least(
1084 version: &Option<String>,
1085 req_major: u64,
1086 req_minor: u64,
1087 req_patch: u64,
1088) -> bool {
1089 let Some(v) = version.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
1090 return false;
1091 };
1092 let mut parts = v.split('.');
1093 let Some(Ok(major)) = parts.next().map(str::parse) else {
1094 return false;
1095 };
1096 let Some(Ok(minor)) = parts.next().map(str::parse) else {
1097 return false;
1098 };
1099 let patch: u64 = parts
1100 .next()
1101 .unwrap_or("0")
1102 .parse()
1103 .unwrap_or(0);
1104 (major, minor, patch) >= (req_major, req_minor, req_patch)
1105}
1106
1107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1109pub struct ControlStatus {
1110 pub serial_connected: Option<bool>,
1111 pub collection_running: Option<bool>,
1112 pub port_path: Option<String>,
1113}
1114
1115#[derive(Debug, Clone, Default)]
1117pub struct ReconcileOutcome {
1118 pub new_ids: Vec<String>,
1120 pub removed_ids: Vec<String>,
1122 pub changed: bool,
1124}
1125
1126#[derive(Debug, Clone, Default)]
1130pub struct AppState {
1131 pub server_host: String,
1132 pub server_port: String,
1133 pub export_dir: String,
1135 pub devices: Vec<DeviceState>,
1136 pub selected_device_ids: Vec<String>,
1139 pub transient: TransientUiState,
1140 pub events: Vec<String>,
1141 intent_queue: Vec<UserIntent>,
1142}
1143
1144impl AppState {
1145 pub fn with_defaults() -> Self {
1147 let mut state = Self::default();
1148 state.server_host = "127.0.0.1".to_owned();
1149 state.server_port = "3000".to_owned();
1150 state.export_dir = ".".to_owned();
1151 state
1152 }
1153
1154 pub fn push_intent(&mut self, intent: UserIntent) {
1156 self.intent_queue.push(intent);
1157 }
1158
1159 pub fn push_device_action(&mut self, id: impl Into<String>, action: DeviceAction) {
1161 self.intent_queue.push(UserIntent::Device {
1162 id: id.into(),
1163 action,
1164 });
1165 }
1166
1167 pub fn drain_intents(&mut self) -> Vec<UserIntent> {
1169 std::mem::take(&mut self.intent_queue)
1170 }
1171
1172 pub fn push_event(&mut self, message: impl Into<String>) {
1174 self.events.push(message.into());
1175 if self.events.len() > 300 {
1176 let drain_to = self.events.len() - 300;
1177 self.events.drain(0..drain_to);
1178 }
1179 }
1180
1181 pub fn selected_indices(&self) -> Vec<usize> {
1184 self.devices
1185 .iter()
1186 .enumerate()
1187 .filter(|(_, d)| self.selected_device_ids.iter().any(|id| id == &d.id))
1188 .map(|(idx, _)| idx)
1189 .collect()
1190 }
1191
1192 pub fn is_selected(&self, id: &str) -> bool {
1194 self.selected_device_ids.iter().any(|s| s == id)
1195 }
1196
1197 pub fn toggle_selection(&mut self, id: String) {
1199 if let Some(pos) = self.selected_device_ids.iter().position(|s| s == &id) {
1200 self.selected_device_ids.remove(pos);
1201 } else {
1202 self.selected_device_ids.push(id);
1203 }
1204 }
1205
1206 pub fn device_index_by_id(&self, id: &str) -> Option<usize> {
1208 self.devices.iter().position(|d| d.id == id)
1209 }
1210
1211 pub fn device_mut_by_id(&mut self, id: &str) -> Option<&mut DeviceState> {
1213 self.devices.iter_mut().find(|d| d.id == id)
1214 }
1215
1216 pub fn base_http_url(&self) -> String {
1218 format!(
1219 "http://{}:{}",
1220 self.server_host.trim(),
1221 self.server_port.trim()
1222 )
1223 }
1224
1225 pub fn device_ws_url(&self, id: &str) -> String {
1227 format!(
1228 "ws://{}:{}/api/devices/{}/ws",
1229 self.server_host.trim(),
1230 self.server_port.trim(),
1231 id
1232 )
1233 }
1234
1235 pub fn reconcile_devices(&mut self, entries: Vec<DeviceListEntry>) -> ReconcileOutcome {
1241 let mut outcome = ReconcileOutcome::default();
1242
1243 let incoming_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1244
1245 outcome.removed_ids = self
1246 .devices
1247 .iter()
1248 .map(|d| d.id.clone())
1249 .filter(|id| !incoming_ids.contains(id))
1250 .collect();
1251
1252 let before = self.devices.len();
1253 self.devices.retain(|d| incoming_ids.contains(&d.id));
1254 if self.devices.len() != before {
1255 outcome.changed = true;
1256 }
1257
1258 for entry in &entries {
1259 if let Some(idx) = self.device_index_by_id(&entry.id) {
1260 self.devices[idx].apply_list_entry(entry);
1261 } else {
1262 let mut device = DeviceState::new(entry.id.clone());
1263 device.apply_list_entry(entry);
1264 self.devices.push(device);
1265 outcome.new_ids.push(entry.id.clone());
1266 outcome.changed = true;
1267 }
1268 }
1269
1270 let before_selection = self.selected_device_ids.len();
1272 self.selected_device_ids
1273 .retain(|id| self.devices.iter().any(|d| &d.id == id));
1274 if self.selected_device_ids.len() != before_selection {
1275 outcome.changed = true;
1276 }
1277 if self.selected_device_ids.is_empty() {
1280 if let Some(first) = self.devices.first() {
1281 self.selected_device_ids.push(first.id.clone());
1282 outcome.changed = true;
1283 }
1284 }
1285
1286 outcome
1287 }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293
1294 #[test]
1295 fn device_config_parses_full_nested_response() {
1296 let json = r#"{
1297 "wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
1298 "collection": {
1299 "mode": "collector", "traffic_hz": 100, "unsolicited": true,
1300 "phy_rate": "mcs0-lgi",
1301 "protocol": "n", "io_tx_enabled": true, "io_rx_enabled": true
1302 },
1303 "csi_config": {
1304 "lltf_enabled": true, "htltf_enabled": true,
1305 "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
1306 "val_scale_cfg": 2,
1307 "acquire_csi": 1, "acquire_csi_legacy": 0
1308 },
1309 "csi_delivery_mode": "async",
1310 "csi_logging_enabled": true
1311 }"#;
1312 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1313 let mut device = DeviceState::new("ttyUSB0");
1314 let applied = device.apply_device_config(cfg);
1315 assert!(applied > 0);
1316 assert_eq!(device.forms.wifi.mode, WiFiMode::Sniffer);
1317 assert_eq!(device.forms.wifi.channel, "6");
1318 assert_eq!(device.forms.traffic.frequency_hz, "100");
1319 assert!(device.forms.traffic.unsolicited);
1320 assert!(device.forms.csi.csi);
1321 assert!(!device.forms.csi.csi_legacy);
1322 assert_eq!(device.forms.protocol, WifiProtocol::N);
1323 }
1324
1325 #[test]
1326 fn device_config_tolerates_null_sub_objects() {
1327 let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
1328 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
1329 let mut device = DeviceState::new("ttyUSB0");
1330 assert_eq!(device.apply_device_config(cfg), 0);
1331 }
1332
1333 #[test]
1334 fn device_config_tolerates_missing_sub_objects() {
1335 let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
1336 let mut device = DeviceState::new("ttyUSB0");
1337 assert_eq!(device.apply_device_config(cfg), 0);
1338 }
1339
1340 #[test]
1341 fn config_snapshot_roundtrips_via_json() {
1342 let mut forms = DeviceForms::default();
1343 forms.wifi.mode = WiFiMode::EspNowCentral;
1344 forms.wifi.channel = "11".to_owned();
1345 forms.wifi.sta_password = "hunter22".to_owned();
1346 forms.traffic.frequency_hz = "500".to_owned();
1347 forms.protocol = WifiProtocol::Ext("myproto");
1349 forms.collection_mode = CollectionMode::Listener;
1350 forms.output_mode = OutputMode::Both;
1351 forms.csi_delivery.mode = CsiDeliveryMode::Raw;
1352 forms.csi.csi_vht = false;
1353
1354 let snapshot = ConfigSnapshotFile {
1355 format: 1,
1356 device_id: Some("dev-a".to_owned()),
1357 saved_at: None,
1358 forms,
1359 };
1360 let json = serde_json::to_string_pretty(&snapshot).expect("serialize");
1361 assert!(json.contains("\"esp-now-central\""));
1363 assert!(json.contains("\"myproto\""));
1364 assert!(json.contains("\"listener\""));
1365
1366 let parsed: ConfigSnapshotFile = serde_json::from_str(&json).expect("parse");
1367 assert_eq!(parsed.forms.wifi.mode, WiFiMode::EspNowCentral);
1368 assert_eq!(parsed.forms.wifi.channel, "11");
1369 assert_eq!(parsed.forms.wifi.sta_password, "hunter22");
1370 assert_eq!(parsed.forms.traffic.frequency_hz, "500");
1371 assert_eq!(parsed.forms.protocol, WifiProtocol::Ext("myproto"));
1372 assert_eq!(parsed.forms.collection_mode, CollectionMode::Listener);
1373 assert_eq!(parsed.forms.output_mode, OutputMode::Both);
1374 assert_eq!(parsed.forms.csi_delivery.mode, CsiDeliveryMode::Raw);
1375 assert!(!parsed.forms.csi.csi_vht);
1376 }
1377
1378 #[test]
1379 fn config_snapshot_fills_missing_fields_with_defaults() {
1380 let json = r#"{ "forms": { "wifi": { "mode": "sniffer" } } }"#;
1383 let parsed: ConfigSnapshotFile = serde_json::from_str(json).expect("parse partial");
1384 assert_eq!(parsed.format, 1);
1385 assert_eq!(parsed.forms.wifi.mode, WiFiMode::Sniffer);
1386 assert_eq!(parsed.forms.wifi.ap_ssid, "esp-csi-ap");
1387 assert_eq!(parsed.forms.traffic.frequency_hz, "100");
1388 assert_eq!(parsed.forms.phy_rate.rate, "mcs0-lgi");
1389 }
1390
1391 #[test]
1392 fn wifi_mode_parses_v07_values() {
1393 assert_eq!(
1394 WiFiMode::from_api_value("wifi-ap"),
1395 Some(WiFiMode::WifiAp)
1396 );
1397 assert!(WiFiMode::EspNowFastCollector.is_esp_now());
1398 assert!(WiFiMode::WifiAp.requires_v07());
1399 }
1400
1401 #[test]
1402 fn wifi_mode_station_channel_is_hint() {
1403 assert!(WiFiMode::Station.channel_is_hint());
1404 assert!(!WiFiMode::Sniffer.channel_is_hint());
1405 assert!(!WiFiMode::WifiAp.channel_is_hint());
1406 assert!(!WiFiMode::EspNowCentral.channel_is_hint());
1407 }
1408
1409 #[test]
1410 fn device_config_applies_ap_fields() {
1411 let json = r#"{
1412 "wifi": {
1413 "mode": "wifi-ap",
1414 "channel": 6,
1415 "ap_ssid": "lab-ap",
1416 "ap_dhcp": false
1417 }
1418 }"#;
1419 let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1420 let mut device = DeviceState::new("D0CF13E290E8");
1421 device.apply_device_config(cfg);
1422 assert_eq!(device.forms.wifi.mode, WiFiMode::WifiAp);
1423 assert_eq!(device.forms.wifi.ap_ssid, "lab-ap");
1424 assert!(!device.forms.wifi.ap_dhcp);
1425 }
1426
1427 #[test]
1428 fn firmware_version_gating() {
1429 let info = DeviceInfo {
1430 version: Some("0.7.0".to_owned()),
1431 ..Default::default()
1432 };
1433 assert!(info.supports_v07_modes());
1434 let old = DeviceInfo {
1435 version: Some("0.6.0".to_owned()),
1436 ..Default::default()
1437 };
1438 assert!(!old.supports_v07_modes());
1439
1440 let v06 = DeviceInfo {
1443 version: Some("0.6.0".to_owned()),
1444 ..Default::default()
1445 };
1446 assert!(!v06.supports_unsolicited());
1447 let v07 = DeviceInfo {
1448 version: Some("0.7.0".to_owned()),
1449 ..Default::default()
1450 };
1451 assert!(v07.supports_unsolicited());
1452 }
1453
1454 #[test]
1455 fn device_list_parses_mac_field() {
1456 let json = r#"[
1457 {
1458 "id": "D0-CF-13-E2-90-E8",
1459 "mac": "D0:CF:13:E2:90:E8",
1460 "port_path": "/dev/ttyACM0",
1461 "serial_connected": true,
1462 "firmware_verified": true
1463 }
1464 ]"#;
1465 let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1466 let mut state = AppState::with_defaults();
1467 state.reconcile_devices(entries);
1468 assert_eq!(state.devices[0].id, "D0-CF-13-E2-90-E8");
1469 assert_eq!(
1470 state.devices[0].mac.as_deref(),
1471 Some("D0:CF:13:E2:90:E8")
1472 );
1473 }
1474
1475 #[test]
1476 fn device_list_parses_and_reconciles() {
1477 let json = r#"[
1478 {
1479 "id": "ttyUSB0", "port_path": "/dev/ttyUSB0", "baud_rate": 115200,
1480 "serial_connected": true, "collection_running": false,
1481 "firmware_verified": true,
1482 "device_info": { "name": "esp-csi-cli-rs", "chip": "esp32c6", "protocol": 1 },
1483 "fault": "USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — replug"
1484 }
1485 ]"#;
1486 let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1487 let mut state = AppState::with_defaults();
1488 let outcome = state.reconcile_devices(entries);
1489 assert!(outcome.changed);
1490 assert_eq!(outcome.new_ids, vec!["ttyUSB0".to_owned()]);
1491 assert_eq!(state.devices.len(), 1);
1492 assert_eq!(state.selected_device_ids, vec!["ttyUSB0".to_owned()]);
1493 let device = &state.devices[0];
1494 assert_eq!(device.serial_connected, Some(true));
1495 assert_eq!(device.latest_info.as_ref().unwrap().chip.as_deref(), Some("esp32c6"));
1496 assert!(device.fault.as_deref().unwrap().contains("USB-JTAG reset loop"));
1497 }
1498
1499 #[test]
1500 fn reconcile_drops_vanished_and_reselects() {
1501 let mut state = AppState::with_defaults();
1502 state.reconcile_devices(vec![
1503 DeviceListEntry { id: "a".to_owned(), ..Default::default() },
1504 DeviceListEntry { id: "b".to_owned(), ..Default::default() },
1505 ]);
1506 assert_eq!(state.selected_device_ids, vec!["a".to_owned()]);
1507
1508 let outcome = state.reconcile_devices(vec![DeviceListEntry {
1510 id: "b".to_owned(),
1511 ..Default::default()
1512 }]);
1513 assert!(outcome.changed);
1514 assert_eq!(outcome.removed_ids, vec!["a".to_owned()]);
1515 assert_eq!(state.devices.len(), 1);
1516 assert_eq!(state.selected_device_ids, vec!["b".to_owned()]);
1517 }
1518}