1use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use crate::profile::CsiProfile;
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct DeviceConfig {
36 pub wifi: WifiSection,
37 pub collection: CollectionSection,
38 pub csi_config: CsiConfigSection,
39 pub csi_delivery_mode: Option<String>,
40 pub csi_logging_enabled: Option<bool>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct WifiSection {
46 pub mode: Option<String>,
49 pub channel: Option<u8>,
51 pub sta_ssid: Option<String>,
53 pub ap_ssid: Option<String>,
55 pub ap_password: Option<String>,
57 pub ap_dhcp: Option<bool>,
59 pub ap_leases: Option<u8>,
62 pub ap_burst: Option<bool>,
65 pub peer_mac: Option<String>,
68 pub ht40: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct CollectionSection {
76 pub mode: Option<String>,
78 pub traffic_hz: Option<u64>,
80 pub unsolicited: Option<bool>,
83 pub phy_rate: Option<String>,
85 pub protocol: Option<String>,
88 pub io_tx_enabled: Option<bool>,
90 pub io_rx_enabled: Option<bool>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct CsiConfigSection {
109 pub lltf_enabled: Option<bool>,
112 pub htltf_enabled: Option<bool>,
114 pub stbc_htltf_enabled: Option<bool>,
116 pub ltf_merge_enabled: Option<bool>,
118 pub channel_filter_enabled: Option<bool>,
120 pub manual_scale: Option<bool>,
122 pub shift: Option<u8>,
124 pub dump_ack_enabled: Option<bool>,
126
127 pub acquire_csi: Option<u32>,
130 pub acquire_csi_legacy: Option<u32>,
132 pub acquire_csi_ht20: Option<u32>,
134 pub acquire_csi_ht40: Option<u32>,
136 pub val_scale_cfg: Option<u32>,
138 pub acquire_csi_force_lltf: Option<bool>,
140 pub acquire_csi_vht: Option<bool>,
142
143 #[serde(flatten)]
148 pub extra: BTreeMap<String, serde_json::Value>,
149}
150
151impl DeviceConfig {
152 pub fn firmware_defaults() -> Self {
162 Self::firmware_defaults_for_chip(None)
163 }
164
165 pub fn firmware_defaults_for_chip(chip: Option<&str>) -> Self {
168 let defaults = Self {
169 wifi: WifiSection {
170 mode: Some("sniffer".to_string()),
171 channel: Some(default_wifi_channel(chip)),
172 sta_ssid: Some(String::new()),
173 ap_ssid: Some("esp-csi-ap".to_string()),
174 ap_password: None,
175 ap_dhcp: Some(true),
176 ap_leases: Some(4),
177 ap_burst: Some(false),
178 peer_mac: Some("auto".to_string()),
179 ht40: Some("none".to_string()),
180 },
181 collection: CollectionSection {
182 mode: Some("collector".to_string()),
183 traffic_hz: Some(100),
184 unsolicited: Some(false),
185 phy_rate: Some("mcs0-lgi".to_string()),
186 protocol: Some("lr".to_string()),
187 io_tx_enabled: Some(true),
188 io_rx_enabled: Some(true),
189 },
190 csi_config: CsiConfigSection {
191 lltf_enabled: Some(true),
193 htltf_enabled: Some(true),
194 stbc_htltf_enabled: Some(true),
195 ltf_merge_enabled: Some(true),
196 channel_filter_enabled: Some(false),
197 manual_scale: Some(false),
198 shift: Some(0),
199 dump_ack_enabled: Some(true),
200 acquire_csi: Some(1),
202 acquire_csi_legacy: Some(1),
203 acquire_csi_ht20: Some(1),
204 acquire_csi_ht40: Some(1),
205 val_scale_cfg: Some(2),
206 acquire_csi_force_lltf: Some(true),
207 acquire_csi_vht: Some(true),
208 extra: BTreeMap::new(),
209 },
210 csi_delivery_mode: None,
211 csi_logging_enabled: None,
212 };
213 defaults
214 }
215}
216
217pub fn default_wifi_channel(chip: Option<&str>) -> u8 {
219 match chip.map(|c| c.trim().to_ascii_lowercase()) {
220 Some(ref c) if c == "esp32c5" => 149,
221 Some(ref c) if c == "esp32c6" => 6,
222 _ => 1,
223 }
224}
225
226fn quote_cli_arg(s: &str) -> Result<String, String> {
236 if s.contains('\n') || s.contains('\r') {
237 return Err("value cannot contain newline characters".to_string());
238 }
239 if !s.contains('\'') {
240 Ok(format!("'{s}'"))
241 } else if !s.contains('"') {
242 Ok(format!("\"{s}\""))
243 } else {
244 Err("value cannot contain both single and double quote characters".to_string())
245 }
246}
247
248fn validate_peer_mac(mac: &str) -> Result<(), String> {
254 if mac.is_empty() {
255 return Ok(());
256 }
257 let sep = if mac.contains(':') {
258 ':'
259 } else if mac.contains('-') {
260 '-'
261 } else {
262 return Err("peer_mac must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_string());
263 };
264 let octets: Vec<&str> = mac.split(sep).collect();
265 if octets.len() != 6 || octets.iter().any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit())) {
266 return Err(format!("Invalid peer_mac '{mac}' (use aa:bb:cc:dd:ee:ff)"));
267 }
268 Ok(())
269}
270
271const WIFI_MODES: &[&str] = &[
273 "station",
274 "sniffer",
275 "wifi-ap",
276 "esp-now-central",
277 "esp-now-peripheral",
278 "esp-now-fast-collector",
279 "esp-now-fast-source",
280];
281
282#[derive(Debug, Deserialize)]
283pub struct WifiConfig {
284 pub mode: String,
286 pub sta_ssid: Option<String>,
287 pub sta_password: Option<String>,
288 pub ap_ssid: Option<String>,
290 pub ap_password: Option<String>,
292 pub ap_dhcp: Option<bool>,
294 pub ap_leases: Option<u8>,
297 pub ap_burst: Option<bool>,
301 pub channel: Option<u8>,
302 pub peer_mac: Option<String>,
306 pub ht40: Option<String>,
309}
310
311impl WifiConfig {
312 pub fn to_cli_command(&self, chip: Option<&str>) -> Result<String, String> {
317 if !WIFI_MODES.contains(&self.mode.as_str()) {
318 return Err(format!(
319 "Unknown wifi mode '{}'; expected one of: {}",
320 self.mode,
321 WIFI_MODES.join(", ")
322 ));
323 }
324
325 let mut cmd = format!("set-wifi --mode={}", self.mode);
326
327 if let Some(ssid) = &self.sta_ssid {
328 if ssid.len() > 32 {
329 return Err(format!(
330 "sta_ssid is {} bytes; firmware limit is 32 bytes",
331 ssid.len()
332 ));
333 }
334 cmd.push_str(&format!(" --sta-ssid={}", quote_cli_arg(ssid)?));
335 }
336
337 if let Some(pass) = &self.sta_password {
338 if pass.len() > 32 {
339 return Err(format!(
340 "sta_password is {} bytes; firmware limit is 32 bytes",
341 pass.len()
342 ));
343 }
344 cmd.push_str(&format!(" --sta-password={}", quote_cli_arg(pass)?));
345 }
346
347 if let Some(ssid) = &self.ap_ssid {
348 if ssid.len() > 32 {
349 return Err(format!(
350 "ap_ssid is {} bytes; firmware limit is 32 bytes",
351 ssid.len()
352 ));
353 }
354 cmd.push_str(&format!(" --ap-ssid={}", quote_cli_arg(ssid)?));
355 }
356
357 if let Some(pass) = &self.ap_password {
358 if pass.len() > 32 {
359 return Err(format!(
360 "ap_password is {} bytes; firmware limit is 32 bytes",
361 pass.len()
362 ));
363 }
364 cmd.push_str(&format!(" --ap-password={}", quote_cli_arg(pass)?));
365 }
366
367 if let Some(dhcp) = self.ap_dhcp {
368 cmd.push_str(if dhcp {
369 " --ap-dhcp=on"
370 } else {
371 " --ap-dhcp=off"
372 });
373 }
374
375 if let Some(leases) = self.ap_leases {
376 if !(1..=8).contains(&leases) {
377 return Err(format!("ap_leases is {leases}; firmware accepts 1-8"));
378 }
379 cmd.push_str(&format!(" --ap-leases={leases}"));
380 }
381
382 if let Some(burst) = self.ap_burst {
383 cmd.push_str(if burst {
384 " --ap-burst=on"
385 } else {
386 " --ap-burst=off"
387 });
388 }
389
390 if let Some(ch) = self
399 .channel
400 .or_else(|| (self.mode != "station").then(|| default_wifi_channel(chip)))
401 {
402 cmd.push_str(&format!(" --set-channel={ch}"));
403 }
404
405 if let Some(mac) = &self.peer_mac {
406 validate_peer_mac(mac)?;
407 cmd.push_str(&format!(" --peer-mac={mac}"));
410 }
411
412 if let Some(ht40) = &self.ht40 {
413 match ht40.as_str() {
414 "above" | "below" | "none" | "off" => {}
415 other => {
416 return Err(format!("Invalid ht40 '{other}' (use above, below, none, or off)"));
417 }
418 }
419 cmd.push_str(&format!(" --ht40={ht40}"));
420 }
421
422 Ok(cmd)
423 }
424}
425
426#[derive(Debug, Deserialize)]
427pub struct TrafficConfig {
428 pub frequency_hz: u64,
430 pub unsolicited: Option<bool>,
436}
437
438impl TrafficConfig {
439 pub fn to_cli_command(&self) -> String {
440 let mut cmd = format!("set-traffic --frequency-hz={}", self.frequency_hz);
441 push_on_off(&mut cmd, "unsolicited", self.unsolicited);
442 cmd
443 }
444}
445
446#[derive(Debug, Deserialize)]
455pub struct CsiConfig {
456 pub lltf: Option<bool>,
459 pub htltf: Option<bool>,
461 pub stbc_htltf: Option<bool>,
463 pub ltf_merge: Option<bool>,
465 pub csi: Option<bool>,
468 pub csi_legacy: Option<bool>,
470 pub csi_ht20: Option<bool>,
472 pub csi_ht40: Option<bool>,
474 pub dump_ack: Option<bool>,
476 pub csi_force_lltf: Option<bool>,
478 pub csi_vht: Option<bool>,
480 pub val_scale_cfg: Option<u32>,
482 #[serde(flatten)]
486 pub extra: BTreeMap<String, serde_json::Value>,
487}
488
489impl CsiConfig {
490 pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
491 let mut cmd = "set-csi".to_string();
492
493 if let Some(preset) = self.extra.get("preset") {
496 let name = preset
497 .as_str()
498 .ok_or_else(|| "preset must be a string".to_string())?;
499 if name == "default" {
500 cmd.push_str(" --preset=default");
501 return Ok(cmd);
502 }
503 if let Some(cli) = profile.preset_cli(name) {
504 return Ok(cli);
505 }
506 return Err(format!("unknown preset '{name}'; expected default"));
507 }
508
509 push_on_off(&mut cmd, "lltf", self.lltf);
510 push_on_off(&mut cmd, "htltf", self.htltf);
511 push_on_off(&mut cmd, "stbc-htltf", self.stbc_htltf);
512 push_on_off(&mut cmd, "ltf-merge", self.ltf_merge);
513 push_on_off(&mut cmd, "csi", self.csi);
514 push_on_off(&mut cmd, "csi-legacy", self.csi_legacy);
515 push_on_off(&mut cmd, "csi-ht20", self.csi_ht20);
516 push_on_off(&mut cmd, "csi-ht40", self.csi_ht40);
517 push_on_off(&mut cmd, "dump-ack", self.dump_ack);
518 push_on_off(&mut cmd, "csi-force-lltf", self.csi_force_lltf);
519 push_on_off(&mut cmd, "csi-vht", self.csi_vht);
520
521 if let Some(scale) = self.val_scale_cfg {
522 cmd.push_str(&format!(" --val-scale-cfg={scale}"));
523 }
524
525 for (key, value) in &self.extra {
527 push_extra(&mut cmd, key, value);
528 }
529 Ok(cmd)
530 }
531
532 pub fn apply_to_cache(&self, cfg: &mut CsiConfigSection, profile: &dyn CsiProfile) {
534 if let Some(preset) = self.extra.get("preset") {
535 if let Some(name) = preset.as_str() {
536 *cfg = profile
537 .resolve_preset(name)
538 .unwrap_or_else(|| DeviceConfig::firmware_defaults().csi_config);
539 return;
540 }
541 }
542 apply_bool_cache(&mut cfg.lltf_enabled, self.lltf);
543 apply_bool_cache(&mut cfg.htltf_enabled, self.htltf);
544 apply_bool_cache(&mut cfg.stbc_htltf_enabled, self.stbc_htltf);
545 apply_bool_cache(&mut cfg.ltf_merge_enabled, self.ltf_merge);
546 apply_u32_cache(&mut cfg.acquire_csi, self.csi);
547 apply_u32_cache(&mut cfg.acquire_csi_legacy, self.csi_legacy);
548 apply_u32_cache(&mut cfg.acquire_csi_ht20, self.csi_ht20);
549 apply_u32_cache(&mut cfg.acquire_csi_ht40, self.csi_ht40);
550 apply_bool_cache(&mut cfg.dump_ack_enabled, self.dump_ack);
551 apply_bool_cache(&mut cfg.acquire_csi_force_lltf, self.csi_force_lltf);
552 apply_bool_cache(&mut cfg.acquire_csi_vht, self.csi_vht);
553 if let Some(scale) = self.val_scale_cfg {
554 cfg.val_scale_cfg = Some(scale);
555 }
556 for (key, value) in &self.extra {
559 if key == "preset" {
560 continue;
561 }
562 cfg.extra.insert(key.clone(), value.clone());
563 }
564 }
565}
566
567fn push_on_off(cmd: &mut String, flag: &str, value: Option<bool>) {
568 if let Some(v) = value {
569 cmd.push_str(&format!(" --{flag}={}", if v { "on" } else { "off" }));
570 }
571}
572
573fn push_extra(cmd: &mut String, key: &str, value: &serde_json::Value) {
576 use serde_json::Value;
577 let rendered = match value {
578 Value::Bool(b) => (if *b { "on" } else { "off" }).to_string(),
579 Value::Number(n) => n.to_string(),
580 Value::String(s) => s.clone(),
581 other => other.to_string(),
582 };
583 cmd.push_str(&format!(" --{key}={rendered}"));
584}
585
586fn apply_bool_cache(slot: &mut Option<bool>, value: Option<bool>) {
587 if let Some(v) = value {
588 *slot = Some(v);
589 }
590}
591
592fn apply_u32_cache(slot: &mut Option<u32>, value: Option<bool>) {
593 if let Some(v) = value {
594 *slot = Some(u32::from(v));
595 }
596}
597
598#[derive(Debug, Deserialize)]
599pub struct CollectionModeConfig {
600 pub mode: String,
602}
603
604impl CollectionModeConfig {
605 pub fn to_cli_command(&self) -> Result<String, String> {
606 match self.mode.as_str() {
607 "collector" | "listener" => {
608 Ok(format!("set-collection-mode --mode={}", self.mode))
609 }
610 other => Err(format!(
611 "Unknown collection mode '{other}'; expected collector or listener"
612 )),
613 }
614 }
615}
616
617#[derive(Debug, Deserialize)]
618pub struct StartConfig {
619 pub duration: Option<u64>,
621}
622
623impl StartConfig {
624 pub fn to_cli_command(&self) -> String {
625 match self.duration {
626 Some(d) => format!("start --duration={d}"),
627 None => "start".to_string(),
628 }
629 }
630}
631
632#[derive(Debug, Deserialize)]
635pub struct RateConfig {
636 pub rate: String,
639}
640
641impl RateConfig {
642 pub fn to_cli_command(&self) -> String {
643 format!("set-rate --rate={}", self.rate)
644 }
645}
646
647#[derive(Debug, Deserialize)]
650pub struct ProtocolConfig {
651 pub protocol: String,
654}
655
656impl ProtocolConfig {
657 const VALID: &[&str] = &["b", "g", "n", "lr", "a", "ac"];
659
660 pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
666 let protocol = self.protocol.to_ascii_lowercase();
667 let accepted = profile.extra_protocols();
668 if !Self::VALID.contains(&protocol.as_str()) && !accepted.contains(&protocol.as_str()) {
669 let mut valid: Vec<&str> = Self::VALID.to_vec();
670 valid.extend_from_slice(accepted);
671 return Err(format!(
672 "unknown protocol '{}'; expected one of: {}",
673 self.protocol,
674 valid.join(", "),
675 ));
676 }
677 Ok(format!("set-protocol --protocol={protocol}"))
678 }
679}
680
681#[derive(Debug, Deserialize)]
685pub struct IoTasksConfig {
686 pub tx: Option<bool>,
687 pub rx: Option<bool>,
688}
689
690impl IoTasksConfig {
691 pub fn to_cli_command(&self) -> Result<String, String> {
692 if self.tx.is_none() && self.rx.is_none() {
693 return Err("at least one of tx or rx must be provided".to_string());
694 }
695 let mut cmd = "set-io-tasks".to_string();
696 if let Some(tx) = self.tx {
697 cmd.push_str(&format!(" --tx={}", if tx { "on" } else { "off" }));
698 }
699 if let Some(rx) = self.rx {
700 cmd.push_str(&format!(" --rx={}", if rx { "on" } else { "off" }));
701 }
702 Ok(cmd)
703 }
704}
705
706#[derive(Debug, Deserialize)]
709pub struct CsiDeliveryConfig {
710 pub mode: Option<String>,
716 pub logging: Option<bool>,
718}
719
720impl CsiDeliveryConfig {
721 pub fn to_cli_command(&self) -> Result<String, String> {
722 if self.mode.is_none() && self.logging.is_none() {
723 return Err("at least one of mode or logging must be provided".to_string());
724 }
725 let mut cmd = "set-csi-delivery".to_string();
726 if let Some(mode) = &self.mode {
727 match mode.as_str() {
728 "off" | "callback" | "async" | "raw" => {}
729 other => {
730 return Err(format!(
731 "Unknown csi-delivery mode '{other}'; expected off, callback, async, or raw"
732 ));
733 }
734 }
735 cmd.push_str(&format!(" --mode={mode}"));
736 }
737 if let Some(logging) = self.logging {
738 cmd.push_str(&format!(
739 " --logging={}",
740 if logging { "on" } else { "off" }
741 ));
742 }
743 Ok(cmd)
744 }
745}
746
747#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
751#[serde(rename_all = "lowercase")]
752pub enum OutputMode {
753 #[default]
755 Stream,
756 Dump,
758 Both,
760}
761
762#[derive(Debug, Deserialize)]
763pub struct OutputModeConfig {
764 pub mode: String,
765}
766
767#[derive(Debug, Serialize)]
770pub struct ApiResponse {
771 pub success: bool,
772 pub message: String,
773}
774
775#[derive(Debug, Clone, Serialize)]
784pub struct DeviceInfo {
785 pub banner_version: String,
787 pub name: Option<String>,
789 pub version: Option<String>,
791 pub chip: Option<String>,
793 pub mac: Option<String>,
798 pub protocol: Option<u32>,
802 pub features: Vec<String>,
804}
805
806#[derive(Debug, Serialize)]
809pub struct CollectionStatusResponse {
810 pub serial_connected: bool,
811 pub collection_running: bool,
812 pub port_path: String,
813}
814
815impl CollectionStatusResponse {
816 pub fn from_state(
817 serial_connected: &AtomicBool,
818 collection_running: &AtomicBool,
819 port_path: String,
820 ) -> Self {
821 Self {
822 serial_connected: serial_connected.load(Ordering::SeqCst),
823 collection_running: collection_running.load(Ordering::SeqCst),
824 port_path,
825 }
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use crate::profile::StandardCsiProfile;
833
834 #[test]
835 fn traffic_emits_frequency_only_when_unsolicited_omitted() {
836 let cmd = TrafficConfig {
837 frequency_hz: 100,
838 unsolicited: None,
839 }
840 .to_cli_command();
841 assert_eq!(cmd, "set-traffic --frequency-hz=100");
842 }
843
844 #[test]
845 fn traffic_emits_unsolicited_flag() {
846 let on = TrafficConfig {
847 frequency_hz: 1000,
848 unsolicited: Some(true),
849 }
850 .to_cli_command();
851 assert_eq!(on, "set-traffic --frequency-hz=1000 --unsolicited=on");
852 let off = TrafficConfig {
853 frequency_hz: 1000,
854 unsolicited: Some(false),
855 }
856 .to_cli_command();
857 assert_eq!(off, "set-traffic --frequency-hz=1000 --unsolicited=off");
858 }
859
860 fn wifi(mode: &str, peer_mac: Option<&str>, ht40: Option<&str>) -> WifiConfig {
861 WifiConfig {
862 mode: mode.to_string(),
863 sta_ssid: None,
864 sta_password: None,
865 ap_ssid: None,
866 ap_password: None,
867 ap_dhcp: None,
868 ap_leases: None,
869 ap_burst: None,
870 channel: None,
871 peer_mac: peer_mac.map(str::to_string),
872 ht40: ht40.map(str::to_string),
873 }
874 }
875
876 #[test]
877 fn wifi_emits_peer_mac_and_ht40() {
878 let cmd = wifi("esp-now-central", Some("AA:BB:CC:DD:EE:FF"), Some("above"))
879 .to_cli_command(None)
880 .unwrap();
881 assert_eq!(
882 cmd,
883 "set-wifi --mode=esp-now-central --set-channel=1 --peer-mac=AA:BB:CC:DD:EE:FF --ht40=above"
884 );
885 }
886
887 #[test]
888 fn wifi_empty_peer_mac_clears_to_auto() {
889 let cmd = wifi("esp-now-peripheral", Some(""), None)
890 .to_cli_command(None)
891 .unwrap();
892 assert_eq!(cmd, "set-wifi --mode=esp-now-peripheral --set-channel=1 --peer-mac=");
893 }
894
895 #[test]
896 fn wifi_rejects_malformed_peer_mac() {
897 assert!(wifi("esp-now-central", Some("not-a-mac"), None)
898 .to_cli_command(None)
899 .is_err());
900 }
901
902 #[test]
903 fn wifi_rejects_bad_ht40() {
904 assert!(wifi("esp-now-central", None, Some("sideways"))
905 .to_cli_command(None)
906 .is_err());
907 }
908
909 #[test]
910 fn wifi_station_forwards_explicit_channel_hint() {
911 let cmd = WifiConfig {
914 mode: "station".to_string(),
915 sta_ssid: Some("MyNetwork".to_string()),
916 sta_password: None,
917 ap_ssid: None,
918 ap_password: None,
919 ap_dhcp: None,
920 ap_leases: None,
921 ap_burst: None,
922 channel: Some(6),
923 peer_mac: None,
924 ht40: None,
925 }
926 .to_cli_command(None)
927 .unwrap();
928 assert_eq!(
929 cmd,
930 "set-wifi --mode=station --sta-ssid='MyNetwork' --set-channel=6"
931 );
932 }
933
934 #[test]
935 fn wifi_station_omits_channel_when_unset() {
936 let cmd = WifiConfig {
940 mode: "station".to_string(),
941 sta_ssid: Some("MyNetwork".to_string()),
942 sta_password: None,
943 ap_ssid: None,
944 ap_password: None,
945 ap_dhcp: None,
946 ap_leases: None,
947 ap_burst: None,
948 channel: None,
949 peer_mac: None,
950 ht40: None,
951 }
952 .to_cli_command(Some("esp32c5"))
953 .unwrap();
954 assert_eq!(cmd, "set-wifi --mode=station --sta-ssid='MyNetwork'");
955 }
956
957 #[test]
958 fn wifi_ap_c5_defaults_channel_149() {
959 let cmd = WifiConfig {
960 mode: "wifi-ap".to_string(),
961 sta_ssid: None,
962 sta_password: None,
963 ap_ssid: Some("esp-csi-ap".to_string()),
964 ap_password: None,
965 ap_dhcp: None,
966 ap_leases: None,
967 ap_burst: None,
968 channel: None,
969 peer_mac: None,
970 ht40: None,
971 }
972 .to_cli_command(Some("esp32c5"))
973 .unwrap();
974 assert_eq!(
975 cmd,
976 "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --set-channel=149"
977 );
978 }
979
980 #[test]
981 fn wifi_ap_emits_ap_fields() {
982 let cmd = WifiConfig {
983 mode: "wifi-ap".to_string(),
984 sta_ssid: None,
985 sta_password: None,
986 ap_ssid: Some("esp-csi-ap".to_string()),
987 ap_password: Some(String::new()),
988 ap_dhcp: Some(true),
989 ap_leases: None,
990 ap_burst: None,
991 channel: Some(6),
992 peer_mac: None,
993 ht40: None,
994 }
995 .to_cli_command(None)
996 .unwrap();
997 assert_eq!(
998 cmd,
999 "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-password='' --ap-dhcp=on --set-channel=6"
1000 );
1001 }
1002
1003 #[test]
1004 fn wifi_ap_emits_leases_and_burst() {
1005 let cmd = WifiConfig {
1006 mode: "wifi-ap".to_string(),
1007 sta_ssid: None,
1008 sta_password: None,
1009 ap_ssid: Some("esp-csi-ap".to_string()),
1010 ap_password: None,
1011 ap_dhcp: Some(true),
1012 ap_leases: Some(4),
1013 ap_burst: Some(true),
1014 channel: Some(6),
1015 peer_mac: None,
1016 ht40: None,
1017 }
1018 .to_cli_command(None)
1019 .unwrap();
1020 assert_eq!(
1021 cmd,
1022 "set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-dhcp=on --ap-leases=4 --ap-burst=on --set-channel=6"
1023 );
1024 }
1025
1026 #[test]
1027 fn wifi_ap_burst_off_emits_off() {
1028 let mut cfg = wifi("wifi-ap", None, None);
1029 cfg.ap_burst = Some(false);
1030 let cmd = cfg.to_cli_command(None).unwrap();
1031 assert_eq!(cmd, "set-wifi --mode=wifi-ap --ap-burst=off --set-channel=1");
1032 }
1033
1034 #[test]
1035 fn wifi_ap_rejects_out_of_range_leases() {
1036 for bad in [0u8, 9] {
1037 let mut cfg = wifi("wifi-ap", None, None);
1038 cfg.ap_leases = Some(bad);
1039 assert!(cfg.to_cli_command(None).is_err());
1040 }
1041 }
1042
1043 #[test]
1044 fn wifi_fast_collector_emits_peer_mac_and_ht40() {
1045 let cmd = wifi("esp-now-fast-collector", Some("aa:bb:cc:dd:ee:ff"), Some("below"))
1046 .to_cli_command(None)
1047 .unwrap();
1048 assert_eq!(
1049 cmd,
1050 "set-wifi --mode=esp-now-fast-collector --set-channel=1 --peer-mac=aa:bb:cc:dd:ee:ff --ht40=below"
1051 );
1052 }
1053
1054 #[test]
1055 fn wifi_rejects_unknown_mode() {
1056 assert!(wifi("mesh", None, None).to_cli_command(None).is_err());
1057 }
1058
1059 fn csi_cfg() -> CsiConfig {
1061 CsiConfig {
1062 lltf: None,
1063 htltf: None,
1064 stbc_htltf: None,
1065 ltf_merge: None,
1066 csi: None,
1067 csi_legacy: None,
1068 csi_ht20: None,
1069 csi_ht40: None,
1070 dump_ack: None,
1071 csi_force_lltf: None,
1072 csi_vht: None,
1073 val_scale_cfg: None,
1074 extra: BTreeMap::new(),
1075 }
1076 }
1077
1078 #[test]
1079 fn csi_emits_on_off_toggles() {
1080 let mut cfg = csi_cfg();
1081 cfg.lltf = Some(false);
1082 cfg.csi = Some(true);
1083 cfg.csi_legacy = Some(false);
1084 cfg.dump_ack = Some(false);
1085 let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1086 assert_eq!(cmd, "set-csi --lltf=off --csi=on --csi-legacy=off --dump-ack=off");
1087 }
1088
1089 #[test]
1090 fn csi_emits_default_preset() {
1091 let mut cfg = csi_cfg();
1092 cfg.extra
1093 .insert("preset".to_string(), serde_json::json!("default"));
1094 let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1095 assert_eq!(cmd, "set-csi --preset=default");
1096 }
1097
1098 #[test]
1099 fn csi_emits_extra_flags_generically() {
1100 let mut cfg = csi_cfg();
1103 cfg.csi = Some(true);
1104 cfg.extra
1105 .insert("csi-su".to_string(), serde_json::json!(1));
1106 cfg.extra
1107 .insert("csi-beamformed".to_string(), serde_json::json!(true));
1108 let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
1109 assert_eq!(
1110 cmd,
1111 "set-csi --csi=on --csi-beamformed=on --csi-su=1"
1112 );
1113 }
1114
1115 #[test]
1116 fn csi_rejects_unknown_preset() {
1117 let mut cfg = csi_cfg();
1118 cfg.extra
1119 .insert("preset".to_string(), serde_json::json!("turbo"));
1120 assert!(cfg.to_cli_command(&StandardCsiProfile).is_err());
1121 }
1122
1123 #[test]
1124 fn csi_delivery_accepts_raw() {
1125 let cmd = CsiDeliveryConfig {
1126 mode: Some("raw".to_string()),
1127 logging: None,
1128 }
1129 .to_cli_command()
1130 .unwrap();
1131 assert_eq!(cmd, "set-csi-delivery --mode=raw");
1132 }
1133
1134 #[test]
1135 fn csi_delivery_rejects_unknown_mode() {
1136 assert!(CsiDeliveryConfig {
1137 mode: Some("bogus".to_string()),
1138 logging: None,
1139 }
1140 .to_cli_command()
1141 .is_err());
1142 }
1143
1144 #[test]
1145 fn protocol_emits_lowercased_command() {
1146 let cmd = ProtocolConfig {
1147 protocol: "AC".to_string(),
1148 }
1149 .to_cli_command(&StandardCsiProfile)
1150 .unwrap();
1151 assert_eq!(cmd, "set-protocol --protocol=ac");
1152 }
1153
1154 #[test]
1155 fn protocol_accepts_all_valid_values() {
1156 for p in ["b", "g", "n", "lr", "a", "ac"] {
1157 assert!(ProtocolConfig {
1158 protocol: p.to_string(),
1159 }
1160 .to_cli_command(&StandardCsiProfile)
1161 .is_ok());
1162 }
1163 }
1164
1165 #[test]
1166 fn protocol_rejects_unknown_value() {
1167 assert!(ProtocolConfig {
1168 protocol: "wifi7".to_string(),
1169 }
1170 .to_cli_command(&StandardCsiProfile)
1171 .is_err());
1172 }
1173}