use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::profile::CsiProfile;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceConfig {
pub wifi: WifiSection,
pub collection: CollectionSection,
pub csi_config: CsiConfigSection,
pub csi_delivery_mode: Option<String>,
pub csi_logging_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WifiSection {
pub mode: Option<String>,
pub channel: Option<u8>,
pub sta_ssid: Option<String>,
pub ap_ssid: Option<String>,
pub ap_password: Option<String>,
pub ap_dhcp: Option<bool>,
pub ap_leases: Option<u8>,
pub ap_burst: Option<bool>,
pub peer_mac: Option<String>,
pub ht40: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CollectionSection {
pub mode: Option<String>,
pub traffic_hz: Option<u64>,
pub unsolicited: Option<bool>,
pub phy_rate: Option<String>,
pub protocol: Option<String>,
pub io_tx_enabled: Option<bool>,
pub io_rx_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CsiConfigSection {
pub lltf_enabled: Option<bool>,
pub htltf_enabled: Option<bool>,
pub stbc_htltf_enabled: Option<bool>,
pub ltf_merge_enabled: Option<bool>,
pub channel_filter_enabled: Option<bool>,
pub manual_scale: Option<bool>,
pub shift: Option<u8>,
pub dump_ack_enabled: Option<bool>,
pub acquire_csi: Option<u32>,
pub acquire_csi_legacy: Option<u32>,
pub acquire_csi_ht20: Option<u32>,
pub acquire_csi_ht40: Option<u32>,
pub val_scale_cfg: Option<u32>,
pub acquire_csi_force_lltf: Option<bool>,
pub acquire_csi_vht: Option<bool>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl DeviceConfig {
pub fn firmware_defaults() -> Self {
Self::firmware_defaults_for_chip(None)
}
pub fn firmware_defaults_for_chip(chip: Option<&str>) -> Self {
let defaults = Self {
wifi: WifiSection {
mode: Some("sniffer".to_string()),
channel: Some(default_wifi_channel(chip)),
sta_ssid: Some(String::new()),
ap_ssid: Some("esp-csi-ap".to_string()),
ap_password: None,
ap_dhcp: Some(true),
ap_leases: Some(4),
ap_burst: Some(false),
peer_mac: Some("auto".to_string()),
ht40: Some("none".to_string()),
},
collection: CollectionSection {
mode: Some("collector".to_string()),
traffic_hz: Some(100),
unsolicited: Some(false),
phy_rate: Some("mcs0-lgi".to_string()),
protocol: Some("lr".to_string()),
io_tx_enabled: Some(true),
io_rx_enabled: Some(true),
},
csi_config: CsiConfigSection {
lltf_enabled: Some(true),
htltf_enabled: Some(true),
stbc_htltf_enabled: Some(true),
ltf_merge_enabled: Some(true),
channel_filter_enabled: Some(false),
manual_scale: Some(false),
shift: Some(0),
dump_ack_enabled: Some(true),
acquire_csi: Some(1),
acquire_csi_legacy: Some(1),
acquire_csi_ht20: Some(1),
acquire_csi_ht40: Some(1),
val_scale_cfg: Some(2),
acquire_csi_force_lltf: Some(true),
acquire_csi_vht: Some(true),
extra: BTreeMap::new(),
},
csi_delivery_mode: None,
csi_logging_enabled: None,
};
defaults
}
}
pub fn default_wifi_channel(chip: Option<&str>) -> u8 {
match chip.map(|c| c.trim().to_ascii_lowercase()) {
Some(ref c) if c == "esp32c5" => 149,
Some(ref c) if c == "esp32c6" => 6,
_ => 1,
}
}
fn quote_cli_arg(s: &str) -> Result<String, String> {
if s.contains('\n') || s.contains('\r') {
return Err("value cannot contain newline characters".to_string());
}
if !s.contains('\'') {
Ok(format!("'{s}'"))
} else if !s.contains('"') {
Ok(format!("\"{s}\""))
} else {
Err("value cannot contain both single and double quote characters".to_string())
}
}
fn validate_peer_mac(mac: &str) -> Result<(), String> {
if mac.is_empty() {
return Ok(());
}
let sep = if mac.contains(':') {
':'
} else if mac.contains('-') {
'-'
} else {
return Err("peer_mac must use ':' or '-' separators (aa:bb:cc:dd:ee:ff)".to_string());
};
let octets: Vec<&str> = mac.split(sep).collect();
if octets.len() != 6 || octets.iter().any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit())) {
return Err(format!("Invalid peer_mac '{mac}' (use aa:bb:cc:dd:ee:ff)"));
}
Ok(())
}
const WIFI_MODES: &[&str] = &[
"station",
"sniffer",
"wifi-ap",
"esp-now-central",
"esp-now-peripheral",
"esp-now-fast-collector",
"esp-now-fast-source",
];
#[derive(Debug, Deserialize)]
pub struct WifiConfig {
pub mode: String,
pub sta_ssid: Option<String>,
pub sta_password: Option<String>,
pub ap_ssid: Option<String>,
pub ap_password: Option<String>,
pub ap_dhcp: Option<bool>,
pub ap_leases: Option<u8>,
pub ap_burst: Option<bool>,
pub channel: Option<u8>,
pub peer_mac: Option<String>,
pub ht40: Option<String>,
}
impl WifiConfig {
pub fn to_cli_command(&self, chip: Option<&str>) -> Result<String, String> {
if !WIFI_MODES.contains(&self.mode.as_str()) {
return Err(format!(
"Unknown wifi mode '{}'; expected one of: {}",
self.mode,
WIFI_MODES.join(", ")
));
}
let mut cmd = format!("set-wifi --mode={}", self.mode);
if let Some(ssid) = &self.sta_ssid {
if ssid.len() > 32 {
return Err(format!(
"sta_ssid is {} bytes; firmware limit is 32 bytes",
ssid.len()
));
}
cmd.push_str(&format!(" --sta-ssid={}", quote_cli_arg(ssid)?));
}
if let Some(pass) = &self.sta_password {
if pass.len() > 32 {
return Err(format!(
"sta_password is {} bytes; firmware limit is 32 bytes",
pass.len()
));
}
cmd.push_str(&format!(" --sta-password={}", quote_cli_arg(pass)?));
}
if let Some(ssid) = &self.ap_ssid {
if ssid.len() > 32 {
return Err(format!(
"ap_ssid is {} bytes; firmware limit is 32 bytes",
ssid.len()
));
}
cmd.push_str(&format!(" --ap-ssid={}", quote_cli_arg(ssid)?));
}
if let Some(pass) = &self.ap_password {
if pass.len() > 32 {
return Err(format!(
"ap_password is {} bytes; firmware limit is 32 bytes",
pass.len()
));
}
cmd.push_str(&format!(" --ap-password={}", quote_cli_arg(pass)?));
}
if let Some(dhcp) = self.ap_dhcp {
cmd.push_str(if dhcp {
" --ap-dhcp=on"
} else {
" --ap-dhcp=off"
});
}
if let Some(leases) = self.ap_leases {
if !(1..=8).contains(&leases) {
return Err(format!("ap_leases is {leases}; firmware accepts 1-8"));
}
cmd.push_str(&format!(" --ap-leases={leases}"));
}
if let Some(burst) = self.ap_burst {
cmd.push_str(if burst {
" --ap-burst=on"
} else {
" --ap-burst=off"
});
}
if let Some(ch) = self
.channel
.or_else(|| (self.mode != "station").then(|| default_wifi_channel(chip)))
{
cmd.push_str(&format!(" --set-channel={ch}"));
}
if let Some(mac) = &self.peer_mac {
validate_peer_mac(mac)?;
cmd.push_str(&format!(" --peer-mac={mac}"));
}
if let Some(ht40) = &self.ht40 {
match ht40.as_str() {
"above" | "below" | "none" | "off" => {}
other => {
return Err(format!("Invalid ht40 '{other}' (use above, below, none, or off)"));
}
}
cmd.push_str(&format!(" --ht40={ht40}"));
}
Ok(cmd)
}
}
#[derive(Debug, Deserialize)]
pub struct TrafficConfig {
pub frequency_hz: u64,
pub unsolicited: Option<bool>,
}
impl TrafficConfig {
pub fn to_cli_command(&self) -> String {
let mut cmd = format!("set-traffic --frequency-hz={}", self.frequency_hz);
push_on_off(&mut cmd, "unsolicited", self.unsolicited);
cmd
}
}
#[derive(Debug, Deserialize)]
pub struct CsiConfig {
pub lltf: Option<bool>,
pub htltf: Option<bool>,
pub stbc_htltf: Option<bool>,
pub ltf_merge: Option<bool>,
pub csi: Option<bool>,
pub csi_legacy: Option<bool>,
pub csi_ht20: Option<bool>,
pub csi_ht40: Option<bool>,
pub dump_ack: Option<bool>,
pub csi_force_lltf: Option<bool>,
pub csi_vht: Option<bool>,
pub val_scale_cfg: Option<u32>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl CsiConfig {
pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
let mut cmd = "set-csi".to_string();
if let Some(preset) = self.extra.get("preset") {
let name = preset
.as_str()
.ok_or_else(|| "preset must be a string".to_string())?;
if name == "default" {
cmd.push_str(" --preset=default");
return Ok(cmd);
}
if let Some(cli) = profile.preset_cli(name) {
return Ok(cli);
}
return Err(format!("unknown preset '{name}'; expected default"));
}
push_on_off(&mut cmd, "lltf", self.lltf);
push_on_off(&mut cmd, "htltf", self.htltf);
push_on_off(&mut cmd, "stbc-htltf", self.stbc_htltf);
push_on_off(&mut cmd, "ltf-merge", self.ltf_merge);
push_on_off(&mut cmd, "csi", self.csi);
push_on_off(&mut cmd, "csi-legacy", self.csi_legacy);
push_on_off(&mut cmd, "csi-ht20", self.csi_ht20);
push_on_off(&mut cmd, "csi-ht40", self.csi_ht40);
push_on_off(&mut cmd, "dump-ack", self.dump_ack);
push_on_off(&mut cmd, "csi-force-lltf", self.csi_force_lltf);
push_on_off(&mut cmd, "csi-vht", self.csi_vht);
if let Some(scale) = self.val_scale_cfg {
cmd.push_str(&format!(" --val-scale-cfg={scale}"));
}
for (key, value) in &self.extra {
push_extra(&mut cmd, key, value);
}
Ok(cmd)
}
pub fn apply_to_cache(&self, cfg: &mut CsiConfigSection, profile: &dyn CsiProfile) {
if let Some(preset) = self.extra.get("preset") {
if let Some(name) = preset.as_str() {
*cfg = profile
.resolve_preset(name)
.unwrap_or_else(|| DeviceConfig::firmware_defaults().csi_config);
return;
}
}
apply_bool_cache(&mut cfg.lltf_enabled, self.lltf);
apply_bool_cache(&mut cfg.htltf_enabled, self.htltf);
apply_bool_cache(&mut cfg.stbc_htltf_enabled, self.stbc_htltf);
apply_bool_cache(&mut cfg.ltf_merge_enabled, self.ltf_merge);
apply_u32_cache(&mut cfg.acquire_csi, self.csi);
apply_u32_cache(&mut cfg.acquire_csi_legacy, self.csi_legacy);
apply_u32_cache(&mut cfg.acquire_csi_ht20, self.csi_ht20);
apply_u32_cache(&mut cfg.acquire_csi_ht40, self.csi_ht40);
apply_bool_cache(&mut cfg.dump_ack_enabled, self.dump_ack);
apply_bool_cache(&mut cfg.acquire_csi_force_lltf, self.csi_force_lltf);
apply_bool_cache(&mut cfg.acquire_csi_vht, self.csi_vht);
if let Some(scale) = self.val_scale_cfg {
cfg.val_scale_cfg = Some(scale);
}
for (key, value) in &self.extra {
if key == "preset" {
continue;
}
cfg.extra.insert(key.clone(), value.clone());
}
}
}
fn push_on_off(cmd: &mut String, flag: &str, value: Option<bool>) {
if let Some(v) = value {
cmd.push_str(&format!(" --{flag}={}", if v { "on" } else { "off" }));
}
}
fn push_extra(cmd: &mut String, key: &str, value: &serde_json::Value) {
use serde_json::Value;
let rendered = match value {
Value::Bool(b) => (if *b { "on" } else { "off" }).to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
other => other.to_string(),
};
cmd.push_str(&format!(" --{key}={rendered}"));
}
fn apply_bool_cache(slot: &mut Option<bool>, value: Option<bool>) {
if let Some(v) = value {
*slot = Some(v);
}
}
fn apply_u32_cache(slot: &mut Option<u32>, value: Option<bool>) {
if let Some(v) = value {
*slot = Some(u32::from(v));
}
}
#[derive(Debug, Deserialize)]
pub struct CollectionModeConfig {
pub mode: String,
}
impl CollectionModeConfig {
pub fn to_cli_command(&self) -> Result<String, String> {
match self.mode.as_str() {
"collector" | "listener" => {
Ok(format!("set-collection-mode --mode={}", self.mode))
}
other => Err(format!(
"Unknown collection mode '{other}'; expected collector or listener"
)),
}
}
}
#[derive(Debug, Deserialize)]
pub struct StartConfig {
pub duration: Option<u64>,
}
impl StartConfig {
pub fn to_cli_command(&self) -> String {
match self.duration {
Some(d) => format!("start --duration={d}"),
None => "start".to_string(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RateConfig {
pub rate: String,
}
impl RateConfig {
pub fn to_cli_command(&self) -> String {
format!("set-rate --rate={}", self.rate)
}
}
#[derive(Debug, Deserialize)]
pub struct ProtocolConfig {
pub protocol: String,
}
impl ProtocolConfig {
const VALID: &[&str] = &["b", "g", "n", "lr", "a", "ac"];
pub fn to_cli_command(&self, profile: &dyn CsiProfile) -> Result<String, String> {
let protocol = self.protocol.to_ascii_lowercase();
let accepted = profile.extra_protocols();
if !Self::VALID.contains(&protocol.as_str()) && !accepted.contains(&protocol.as_str()) {
let mut valid: Vec<&str> = Self::VALID.to_vec();
valid.extend_from_slice(accepted);
return Err(format!(
"unknown protocol '{}'; expected one of: {}",
self.protocol,
valid.join(", "),
));
}
Ok(format!("set-protocol --protocol={protocol}"))
}
}
#[derive(Debug, Deserialize)]
pub struct IoTasksConfig {
pub tx: Option<bool>,
pub rx: Option<bool>,
}
impl IoTasksConfig {
pub fn to_cli_command(&self) -> Result<String, String> {
if self.tx.is_none() && self.rx.is_none() {
return Err("at least one of tx or rx must be provided".to_string());
}
let mut cmd = "set-io-tasks".to_string();
if let Some(tx) = self.tx {
cmd.push_str(&format!(" --tx={}", if tx { "on" } else { "off" }));
}
if let Some(rx) = self.rx {
cmd.push_str(&format!(" --rx={}", if rx { "on" } else { "off" }));
}
Ok(cmd)
}
}
#[derive(Debug, Deserialize)]
pub struct CsiDeliveryConfig {
pub mode: Option<String>,
pub logging: Option<bool>,
}
impl CsiDeliveryConfig {
pub fn to_cli_command(&self) -> Result<String, String> {
if self.mode.is_none() && self.logging.is_none() {
return Err("at least one of mode or logging must be provided".to_string());
}
let mut cmd = "set-csi-delivery".to_string();
if let Some(mode) = &self.mode {
match mode.as_str() {
"off" | "callback" | "async" | "raw" => {}
other => {
return Err(format!(
"Unknown csi-delivery mode '{other}'; expected off, callback, async, or raw"
));
}
}
cmd.push_str(&format!(" --mode={mode}"));
}
if let Some(logging) = self.logging {
cmd.push_str(&format!(
" --logging={}",
if logging { "on" } else { "off" }
));
}
Ok(cmd)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Stream,
Dump,
Both,
}
#[derive(Debug, Deserialize)]
pub struct OutputModeConfig {
pub mode: String,
}
#[derive(Debug, Serialize)]
pub struct ApiResponse {
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceInfo {
pub banner_version: String,
pub name: Option<String>,
pub version: Option<String>,
pub chip: Option<String>,
pub mac: Option<String>,
pub protocol: Option<u32>,
pub features: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CollectionStatusResponse {
pub serial_connected: bool,
pub collection_running: bool,
pub port_path: String,
}
impl CollectionStatusResponse {
pub fn from_state(
serial_connected: &AtomicBool,
collection_running: &AtomicBool,
port_path: String,
) -> Self {
Self {
serial_connected: serial_connected.load(Ordering::SeqCst),
collection_running: collection_running.load(Ordering::SeqCst),
port_path,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::StandardCsiProfile;
#[test]
fn traffic_emits_frequency_only_when_unsolicited_omitted() {
let cmd = TrafficConfig {
frequency_hz: 100,
unsolicited: None,
}
.to_cli_command();
assert_eq!(cmd, "set-traffic --frequency-hz=100");
}
#[test]
fn traffic_emits_unsolicited_flag() {
let on = TrafficConfig {
frequency_hz: 1000,
unsolicited: Some(true),
}
.to_cli_command();
assert_eq!(on, "set-traffic --frequency-hz=1000 --unsolicited=on");
let off = TrafficConfig {
frequency_hz: 1000,
unsolicited: Some(false),
}
.to_cli_command();
assert_eq!(off, "set-traffic --frequency-hz=1000 --unsolicited=off");
}
fn wifi(mode: &str, peer_mac: Option<&str>, ht40: Option<&str>) -> WifiConfig {
WifiConfig {
mode: mode.to_string(),
sta_ssid: None,
sta_password: None,
ap_ssid: None,
ap_password: None,
ap_dhcp: None,
ap_leases: None,
ap_burst: None,
channel: None,
peer_mac: peer_mac.map(str::to_string),
ht40: ht40.map(str::to_string),
}
}
#[test]
fn wifi_emits_peer_mac_and_ht40() {
let cmd = wifi("esp-now-central", Some("AA:BB:CC:DD:EE:FF"), Some("above"))
.to_cli_command(None)
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=esp-now-central --set-channel=1 --peer-mac=AA:BB:CC:DD:EE:FF --ht40=above"
);
}
#[test]
fn wifi_empty_peer_mac_clears_to_auto() {
let cmd = wifi("esp-now-peripheral", Some(""), None)
.to_cli_command(None)
.unwrap();
assert_eq!(cmd, "set-wifi --mode=esp-now-peripheral --set-channel=1 --peer-mac=");
}
#[test]
fn wifi_rejects_malformed_peer_mac() {
assert!(wifi("esp-now-central", Some("not-a-mac"), None)
.to_cli_command(None)
.is_err());
}
#[test]
fn wifi_rejects_bad_ht40() {
assert!(wifi("esp-now-central", None, Some("sideways"))
.to_cli_command(None)
.is_err());
}
#[test]
fn wifi_station_forwards_explicit_channel_hint() {
let cmd = WifiConfig {
mode: "station".to_string(),
sta_ssid: Some("MyNetwork".to_string()),
sta_password: None,
ap_ssid: None,
ap_password: None,
ap_dhcp: None,
ap_leases: None,
ap_burst: None,
channel: Some(6),
peer_mac: None,
ht40: None,
}
.to_cli_command(None)
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=station --sta-ssid='MyNetwork' --set-channel=6"
);
}
#[test]
fn wifi_station_omits_channel_when_unset() {
let cmd = WifiConfig {
mode: "station".to_string(),
sta_ssid: Some("MyNetwork".to_string()),
sta_password: None,
ap_ssid: None,
ap_password: None,
ap_dhcp: None,
ap_leases: None,
ap_burst: None,
channel: None,
peer_mac: None,
ht40: None,
}
.to_cli_command(Some("esp32c5"))
.unwrap();
assert_eq!(cmd, "set-wifi --mode=station --sta-ssid='MyNetwork'");
}
#[test]
fn wifi_ap_c5_defaults_channel_149() {
let cmd = WifiConfig {
mode: "wifi-ap".to_string(),
sta_ssid: None,
sta_password: None,
ap_ssid: Some("esp-csi-ap".to_string()),
ap_password: None,
ap_dhcp: None,
ap_leases: None,
ap_burst: None,
channel: None,
peer_mac: None,
ht40: None,
}
.to_cli_command(Some("esp32c5"))
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --set-channel=149"
);
}
#[test]
fn wifi_ap_emits_ap_fields() {
let cmd = WifiConfig {
mode: "wifi-ap".to_string(),
sta_ssid: None,
sta_password: None,
ap_ssid: Some("esp-csi-ap".to_string()),
ap_password: Some(String::new()),
ap_dhcp: Some(true),
ap_leases: None,
ap_burst: None,
channel: Some(6),
peer_mac: None,
ht40: None,
}
.to_cli_command(None)
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-password='' --ap-dhcp=on --set-channel=6"
);
}
#[test]
fn wifi_ap_emits_leases_and_burst() {
let cmd = WifiConfig {
mode: "wifi-ap".to_string(),
sta_ssid: None,
sta_password: None,
ap_ssid: Some("esp-csi-ap".to_string()),
ap_password: None,
ap_dhcp: Some(true),
ap_leases: Some(4),
ap_burst: Some(true),
channel: Some(6),
peer_mac: None,
ht40: None,
}
.to_cli_command(None)
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=wifi-ap --ap-ssid='esp-csi-ap' --ap-dhcp=on --ap-leases=4 --ap-burst=on --set-channel=6"
);
}
#[test]
fn wifi_ap_burst_off_emits_off() {
let mut cfg = wifi("wifi-ap", None, None);
cfg.ap_burst = Some(false);
let cmd = cfg.to_cli_command(None).unwrap();
assert_eq!(cmd, "set-wifi --mode=wifi-ap --ap-burst=off --set-channel=1");
}
#[test]
fn wifi_ap_rejects_out_of_range_leases() {
for bad in [0u8, 9] {
let mut cfg = wifi("wifi-ap", None, None);
cfg.ap_leases = Some(bad);
assert!(cfg.to_cli_command(None).is_err());
}
}
#[test]
fn wifi_fast_collector_emits_peer_mac_and_ht40() {
let cmd = wifi("esp-now-fast-collector", Some("aa:bb:cc:dd:ee:ff"), Some("below"))
.to_cli_command(None)
.unwrap();
assert_eq!(
cmd,
"set-wifi --mode=esp-now-fast-collector --set-channel=1 --peer-mac=aa:bb:cc:dd:ee:ff --ht40=below"
);
}
#[test]
fn wifi_rejects_unknown_mode() {
assert!(wifi("mesh", None, None).to_cli_command(None).is_err());
}
fn csi_cfg() -> CsiConfig {
CsiConfig {
lltf: None,
htltf: None,
stbc_htltf: None,
ltf_merge: None,
csi: None,
csi_legacy: None,
csi_ht20: None,
csi_ht40: None,
dump_ack: None,
csi_force_lltf: None,
csi_vht: None,
val_scale_cfg: None,
extra: BTreeMap::new(),
}
}
#[test]
fn csi_emits_on_off_toggles() {
let mut cfg = csi_cfg();
cfg.lltf = Some(false);
cfg.csi = Some(true);
cfg.csi_legacy = Some(false);
cfg.dump_ack = Some(false);
let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
assert_eq!(cmd, "set-csi --lltf=off --csi=on --csi-legacy=off --dump-ack=off");
}
#[test]
fn csi_emits_default_preset() {
let mut cfg = csi_cfg();
cfg.extra
.insert("preset".to_string(), serde_json::json!("default"));
let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
assert_eq!(cmd, "set-csi --preset=default");
}
#[test]
fn csi_emits_extra_flags_generically() {
let mut cfg = csi_cfg();
cfg.csi = Some(true);
cfg.extra
.insert("csi-su".to_string(), serde_json::json!(1));
cfg.extra
.insert("csi-beamformed".to_string(), serde_json::json!(true));
let cmd = cfg.to_cli_command(&StandardCsiProfile).unwrap();
assert_eq!(
cmd,
"set-csi --csi=on --csi-beamformed=on --csi-su=1"
);
}
#[test]
fn csi_rejects_unknown_preset() {
let mut cfg = csi_cfg();
cfg.extra
.insert("preset".to_string(), serde_json::json!("turbo"));
assert!(cfg.to_cli_command(&StandardCsiProfile).is_err());
}
#[test]
fn csi_delivery_accepts_raw() {
let cmd = CsiDeliveryConfig {
mode: Some("raw".to_string()),
logging: None,
}
.to_cli_command()
.unwrap();
assert_eq!(cmd, "set-csi-delivery --mode=raw");
}
#[test]
fn csi_delivery_rejects_unknown_mode() {
assert!(CsiDeliveryConfig {
mode: Some("bogus".to_string()),
logging: None,
}
.to_cli_command()
.is_err());
}
#[test]
fn protocol_emits_lowercased_command() {
let cmd = ProtocolConfig {
protocol: "AC".to_string(),
}
.to_cli_command(&StandardCsiProfile)
.unwrap();
assert_eq!(cmd, "set-protocol --protocol=ac");
}
#[test]
fn protocol_accepts_all_valid_values() {
for p in ["b", "g", "n", "lr", "a", "ac"] {
assert!(ProtocolConfig {
protocol: p.to_string(),
}
.to_cli_command(&StandardCsiProfile)
.is_ok());
}
}
#[test]
fn protocol_rejects_unknown_value() {
assert!(ProtocolConfig {
protocol: "wifi7".to_string(),
}
.to_cli_command(&StandardCsiProfile)
.is_err());
}
}