use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Devices,
Dashboard,
Config,
Control,
Stream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WiFiMode {
Station,
Sniffer,
WifiAp,
EspNowCentral,
EspNowPeripheral,
EspNowFastCollector,
EspNowFastSource,
}
impl WiFiMode {
pub fn as_api_value(self) -> &'static str {
match self {
Self::Station => "station",
Self::Sniffer => "sniffer",
Self::WifiAp => "wifi-ap",
Self::EspNowCentral => "esp-now-central",
Self::EspNowPeripheral => "esp-now-peripheral",
Self::EspNowFastCollector => "esp-now-fast-collector",
Self::EspNowFastSource => "esp-now-fast-source",
}
}
pub fn from_api_value(value: &str) -> Option<Self> {
match value {
"station" => Some(Self::Station),
"sniffer" => Some(Self::Sniffer),
"wifi-ap" => Some(Self::WifiAp),
"esp-now-central" => Some(Self::EspNowCentral),
"esp-now-peripheral" => Some(Self::EspNowPeripheral),
"esp-now-fast-collector" => Some(Self::EspNowFastCollector),
"esp-now-fast-source" => Some(Self::EspNowFastSource),
_ => None,
}
}
pub fn is_esp_now(self) -> bool {
matches!(
self,
Self::EspNowCentral
| Self::EspNowPeripheral
| Self::EspNowFastCollector
| Self::EspNowFastSource
)
}
pub fn requires_v07(self) -> bool {
matches!(
self,
Self::WifiAp | Self::EspNowFastCollector | Self::EspNowFastSource
)
}
pub fn allows_channel(self) -> bool {
!matches!(self, Self::Station)
}
}
impl Default for WiFiMode {
fn default() -> Self {
Self::Station
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CollectionMode {
Collector,
Listener,
}
impl CollectionMode {
pub fn as_api_value(self) -> &'static str {
match self {
Self::Collector => "collector",
Self::Listener => "listener",
}
}
}
impl Default for CollectionMode {
fn default() -> Self {
Self::Collector
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Ht40Mode {
#[default]
None,
Above,
Below,
}
impl Ht40Mode {
pub fn as_api_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Above => "above",
Self::Below => "below",
}
}
pub fn from_api_value(value: &str) -> Option<Self> {
match value {
"none" | "off" => Some(Self::None),
"above" => Some(Self::Above),
"below" => Some(Self::Below),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Stream,
Dump,
Both,
}
impl OutputMode {
pub fn as_api_value(self) -> &'static str {
match self {
Self::Stream => "stream",
Self::Dump => "dump",
Self::Both => "both",
}
}
}
impl Default for OutputMode {
fn default() -> Self {
Self::Stream
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CsiDeliveryMode {
Off,
Callback,
Async,
Raw,
}
impl CsiDeliveryMode {
pub fn as_api_value(self) -> &'static str {
match self {
Self::Off => "off",
Self::Callback => "callback",
Self::Async => "async",
Self::Raw => "raw",
}
}
}
impl Default for CsiDeliveryMode {
fn default() -> Self {
Self::Async
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WifiProtocol {
B,
G,
N,
Lr,
A,
Ac,
Ax,
}
impl WifiProtocol {
pub fn as_api_value(self) -> &'static str {
match self {
Self::B => "b",
Self::G => "g",
Self::N => "n",
Self::Lr => "lr",
Self::A => "a",
Self::Ac => "ac",
Self::Ax => "ax",
}
}
pub fn from_api_value(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"b" => Some(Self::B),
"g" => Some(Self::G),
"n" => Some(Self::N),
"lr" => Some(Self::Lr),
"a" => Some(Self::A),
"ac" => Some(Self::Ac),
"ax" => Some(Self::Ax),
_ => None,
}
}
}
impl Default for WifiProtocol {
fn default() -> Self {
Self::Lr
}
}
pub const PHY_RATES: &[&str] = &[
"1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
"48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
"mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
];
#[derive(Debug, Clone)]
pub struct WiFiForm {
pub mode: WiFiMode,
pub sta_ssid: String,
pub sta_password: String,
pub ap_ssid: String,
pub ap_password: String,
pub ap_dhcp: bool,
pub channel: String,
pub peer_mac: String,
pub ht40: Ht40Mode,
}
impl Default for WiFiForm {
fn default() -> Self {
Self {
mode: WiFiMode::Station,
sta_ssid: String::new(),
sta_password: String::new(),
ap_ssid: "esp-csi-ap".to_owned(),
ap_password: String::new(),
ap_dhcp: true,
channel: String::new(),
peer_mac: String::new(),
ht40: Ht40Mode::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairingPreset {
SoftApLab,
EspNowFastSimplex,
EspNowBalanced,
}
impl PairingPreset {
pub fn label(self) -> &'static str {
match self {
Self::SoftApLab => "SoftAP lab pair",
Self::EspNowFastSimplex => "ESP-NOW fast simplex",
Self::EspNowBalanced => "ESP-NOW balanced",
}
}
pub fn requires_v07(self) -> bool {
matches!(self, Self::SoftApLab | Self::EspNowFastSimplex)
}
}
#[derive(Debug, Clone)]
pub struct TrafficForm {
pub frequency_hz: String,
pub unsolicited: bool,
}
impl Default for TrafficForm {
fn default() -> Self {
Self {
frequency_hz: "100".to_owned(),
unsolicited: false,
}
}
}
#[derive(Debug, Clone)]
pub struct CsiForm {
pub lltf: bool,
pub htltf: bool,
pub stbc_htltf: bool,
pub ltf_merge: bool,
pub csi: bool,
pub csi_legacy: bool,
pub csi_ht20: bool,
pub csi_ht40: bool,
pub csi_su: bool,
pub csi_mu: bool,
pub csi_dcm: bool,
pub csi_beamformed: bool,
pub dump_ack: bool,
pub csi_force_lltf: bool,
pub csi_vht: bool,
pub csi_he_stbc: String,
pub val_scale_cfg: String,
}
impl Default for CsiForm {
fn default() -> Self {
Self {
lltf: true,
htltf: true,
stbc_htltf: true,
ltf_merge: true,
csi: true,
csi_legacy: true,
csi_ht20: true,
csi_ht40: true,
csi_su: true,
csi_mu: true,
csi_dcm: true,
csi_beamformed: true,
dump_ack: true,
csi_force_lltf: true,
csi_vht: true,
csi_he_stbc: "2".to_owned(),
val_scale_cfg: "2".to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct PhyRateForm {
pub rate: String,
}
impl Default for PhyRateForm {
fn default() -> Self {
Self {
rate: "mcs0-lgi".to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct IoTasksForm {
pub tx: bool,
pub rx: bool,
}
impl Default for IoTasksForm {
fn default() -> Self {
Self { tx: true, rx: true }
}
}
#[derive(Debug, Clone)]
pub struct CsiDeliveryForm {
pub mode: CsiDeliveryMode,
pub logging: bool,
}
impl Default for CsiDeliveryForm {
fn default() -> Self {
Self {
mode: CsiDeliveryMode::Async,
logging: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DeviceForms {
pub wifi: WiFiForm,
pub traffic: TrafficForm,
pub csi: CsiForm,
pub collection_mode: CollectionMode,
pub output_mode: OutputMode,
pub protocol: WifiProtocol,
pub phy_rate: PhyRateForm,
pub io_tasks: IoTasksForm,
pub csi_delivery: CsiDeliveryForm,
pub start_duration_seconds: String,
}
#[derive(Debug, Clone, Default)]
pub struct FrameSummary {
pub timestamp: String,
pub length: usize,
pub preview_hex: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ServerStatus {
#[default]
Unknown,
Connecting,
Connected,
Disconnected,
}
impl ServerStatus {
pub fn label(self) -> &'static str {
match self {
Self::Unknown => "Not connected",
Self::Connecting => "Connecting…",
Self::Connected => "Connected",
Self::Disconnected => "Disconnected",
}
}
}
#[derive(Debug, Clone)]
pub struct TransientUiState {
pub active_tab: Tab,
pub status_message: String,
pub error_message: String,
pub server_status: ServerStatus,
pub preset_channel: String,
}
impl Default for TransientUiState {
fn default() -> Self {
Self {
active_tab: Tab::Devices,
status_message: "Ready".to_owned(),
error_message: String::new(),
server_status: ServerStatus::Unknown,
preset_channel: "6".to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct DeviceState {
pub id: String,
pub mac: Option<String>,
pub port_path: Option<String>,
pub baud_rate: Option<u32>,
pub serial_connected: Option<bool>,
pub collection_running: Option<bool>,
pub firmware_verified: Option<bool>,
pub fault: Option<String>,
pub latest_info: Option<DeviceInfo>,
pub forms: DeviceForms,
pub latest_config: Option<DeviceConfig>,
pub auto_resetting_cache: bool,
pub ws_connected: bool,
pub frames_received: u64,
pub bytes_received: u64,
pub recent_frames: Vec<FrameSummary>,
pub auto_scroll_stream: bool,
pub details_loaded: bool,
pub recording: bool,
pub record_path: Option<String>,
pub recorded_frames: u64,
pub record_decode_errors: u64,
}
impl DeviceState {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
mac: None,
port_path: None,
baud_rate: None,
serial_connected: None,
collection_running: None,
firmware_verified: None,
fault: None,
latest_info: None,
forms: DeviceForms::default(),
latest_config: None,
auto_resetting_cache: false,
ws_connected: false,
frames_received: 0,
bytes_received: 0,
recent_frames: Vec::new(),
auto_scroll_stream: true,
details_loaded: false,
recording: false,
record_path: None,
recorded_frames: 0,
record_decode_errors: 0,
}
}
pub fn apply_list_entry(&mut self, entry: &DeviceListEntry) {
self.mac = entry.mac.clone();
self.port_path = entry.port_path.clone();
self.baud_rate = entry.baud_rate;
self.serial_connected = entry.serial_connected;
self.collection_running = entry.collection_running;
self.firmware_verified = entry.firmware_verified;
self.fault = entry.fault.clone();
if let Some(info) = &entry.device_info {
self.latest_info = Some(info.clone());
}
}
pub fn push_frame(&mut self, bytes: &[u8]) {
self.frames_received = self.frames_received.saturating_add(1);
self.bytes_received = self.bytes_received.saturating_add(bytes.len() as u64);
let preview = bytes
.iter()
.take(24)
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ");
self.recent_frames.push(FrameSummary {
timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
length: bytes.len(),
preview_hex: preview,
});
if self.recent_frames.len() > 300 {
let drain_to = self.recent_frames.len() - 300;
self.recent_frames.drain(0..drain_to);
}
}
pub fn clear_frames(&mut self) {
self.recent_frames.clear();
self.frames_received = 0;
self.bytes_received = 0;
}
pub fn apply_control_status(&mut self, status: ControlStatus) {
self.serial_connected = status.serial_connected;
self.collection_running = status.collection_running;
self.port_path = status.port_path;
}
pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
let mut applied = 0;
let forms = &mut self.forms;
if let Some(wifi) = config.wifi.as_ref() {
if let Some(mode) = wifi.mode.as_deref() {
if let Some(parsed) = WiFiMode::from_api_value(mode) {
forms.wifi.mode = parsed;
applied += 1;
}
}
if let Some(channel) = wifi.channel {
forms.wifi.channel = channel.to_string();
applied += 1;
}
if let Some(ssid) = &wifi.sta_ssid {
forms.wifi.sta_ssid = ssid.clone();
applied += 1;
}
if let Some(ap_ssid) = &wifi.ap_ssid {
forms.wifi.ap_ssid = ap_ssid.clone();
applied += 1;
}
if let Some(ap_dhcp) = wifi.ap_dhcp {
forms.wifi.ap_dhcp = ap_dhcp;
applied += 1;
}
if let Some(peer_mac) = &wifi.peer_mac {
forms.wifi.peer_mac = if peer_mac == "auto" {
String::new()
} else {
peer_mac.clone()
};
applied += 1;
}
if let Some(ht40) = wifi.ht40.as_deref() {
if let Some(parsed) = Ht40Mode::from_api_value(ht40) {
forms.wifi.ht40 = parsed;
applied += 1;
}
}
}
if let Some(collection) = config.collection.as_ref() {
if let Some(traffic_hz) = collection.traffic_hz {
forms.traffic.frequency_hz = traffic_hz.to_string();
applied += 1;
}
if let Some(unsolicited) = collection.unsolicited {
forms.traffic.unsolicited = unsolicited;
applied += 1;
}
if let Some(mode) = collection.mode.as_deref() {
forms.collection_mode = if mode == "listener" {
CollectionMode::Listener
} else {
CollectionMode::Collector
};
applied += 1;
}
if let Some(rate) = &collection.phy_rate {
forms.phy_rate.rate = rate.clone();
applied += 1;
}
if let Some(protocol) = collection.protocol.as_deref() {
if let Some(parsed) = WifiProtocol::from_api_value(protocol) {
forms.protocol = parsed;
applied += 1;
}
}
if let Some(tx) = collection.io_tx_enabled {
forms.io_tasks.tx = tx;
applied += 1;
}
if let Some(rx) = collection.io_rx_enabled {
forms.io_tasks.rx = rx;
applied += 1;
}
}
if let Some(csi_cfg) = config.csi_config.as_ref() {
if let Some(v) = csi_cfg.lltf_enabled {
forms.csi.lltf = v;
applied += 1;
}
if let Some(v) = csi_cfg.htltf_enabled {
forms.csi.htltf = v;
applied += 1;
}
if let Some(v) = csi_cfg.stbc_htltf_enabled {
forms.csi.stbc_htltf = v;
applied += 1;
}
if let Some(v) = csi_cfg.ltf_merge_enabled {
forms.csi.ltf_merge = v;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi {
forms.csi.csi = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_legacy {
forms.csi.csi_legacy = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_ht20 {
forms.csi.csi_ht20 = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_ht40 {
forms.csi.csi_ht40 = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_su {
forms.csi.csi_su = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_mu {
forms.csi.csi_mu = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_dcm {
forms.csi.csi_dcm = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_beamformed {
forms.csi.csi_beamformed = v != 0;
applied += 1;
}
if let Some(v) = csi_cfg.dump_ack_enabled {
forms.csi.dump_ack = v;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_force_lltf {
forms.csi.csi_force_lltf = v;
applied += 1;
}
if let Some(v) = csi_cfg.acquire_csi_vht {
forms.csi.csi_vht = v;
applied += 1;
}
if let Some(v) = csi_cfg.csi_he_stbc {
forms.csi.csi_he_stbc = v.to_string();
applied += 1;
}
if let Some(v) = csi_cfg.val_scale_cfg {
forms.csi.val_scale_cfg = v.to_string();
applied += 1;
}
}
if let Some(mode) = config.csi_delivery_mode.as_deref() {
forms.csi_delivery.mode = match mode {
"off" => CsiDeliveryMode::Off,
"callback" => CsiDeliveryMode::Callback,
"raw" => CsiDeliveryMode::Raw,
_ => CsiDeliveryMode::Async,
};
applied += 1;
}
if let Some(logging) = config.csi_logging_enabled {
forms.csi_delivery.logging = logging;
applied += 1;
}
self.latest_config = Some(config);
applied
}
}
#[derive(Debug, Clone)]
pub enum UserIntent {
FetchDevices,
ToggleDeviceSelection(String),
SelectAllDevices,
ClearDeviceSelection,
StartAllCollections { duration_seconds: String },
StopAllCollections,
StartSelectedCollections { duration_seconds: String },
StopSelectedCollections,
StartSelectedRecording,
StopSelectedRecording,
Device { id: String, action: DeviceAction },
}
#[derive(Debug, Clone)]
pub enum DeviceAction {
FetchConfig,
FetchInfo,
FetchStatus,
ResetConfig,
SetWifi(WiFiForm),
SetTraffic(TrafficForm),
SetCsi(CsiForm),
SetCsiPreset(&'static str),
SetCollectionMode(CollectionMode),
SetOutputMode(OutputMode),
SetProtocol(WifiProtocol),
SetPhyRate(PhyRateForm),
SetIoTasks(IoTasksForm),
SetCsiDelivery(CsiDeliveryForm),
StartCollection { duration_seconds: String },
StopCollection,
ShowStats,
ResetDevice,
ConnectWebSocket,
DisconnectWebSocket,
ClearFrames,
StartRecording,
StopRecording,
ApplyPairingPreset {
preset: PairingPreset,
device_ids: [String; 2],
channel: u8,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceListEntry {
pub id: String,
pub mac: Option<String>,
pub port_path: Option<String>,
pub baud_rate: Option<u32>,
pub serial_connected: Option<bool>,
pub collection_running: Option<bool>,
pub firmware_verified: Option<bool>,
#[serde(default)]
pub device_info: Option<DeviceInfo>,
#[serde(default)]
pub fault: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceWifiConfig {
pub mode: Option<String>,
pub channel: Option<u16>,
pub sta_ssid: Option<String>,
pub ap_ssid: Option<String>,
pub ap_dhcp: Option<bool>,
pub peer_mac: Option<String>,
pub ht40: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceCollectionConfig {
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 DeviceCsiConfig {
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<i32>,
pub dump_ack_enabled: Option<bool>,
pub acquire_csi_force_lltf: Option<bool>,
pub acquire_csi_vht: 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 acquire_csi_su: Option<u32>,
pub acquire_csi_mu: Option<u32>,
pub acquire_csi_dcm: Option<u32>,
pub acquire_csi_beamformed: Option<u32>,
pub csi_he_stbc: Option<u32>,
pub val_scale_cfg: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceConfig {
#[serde(default)]
pub wifi: Option<DeviceWifiConfig>,
#[serde(default)]
pub collection: Option<DeviceCollectionConfig>,
#[serde(default)]
pub csi_config: Option<DeviceCsiConfig>,
pub csi_delivery_mode: Option<String>,
pub csi_logging_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceInfo {
pub banner_version: Option<String>,
pub name: Option<String>,
pub version: Option<String>,
pub chip: Option<String>,
pub mac: Option<String>,
pub protocol: Option<u32>,
#[serde(default)]
pub features: Vec<String>,
}
impl DeviceInfo {
pub fn supports_v07_modes(&self) -> bool {
firmware_version_at_least(&self.version, 0, 7, 0)
|| firmware_version_at_least(&self.banner_version, 0, 7, 0)
}
pub fn supports_unsolicited(&self) -> bool {
firmware_version_at_least(&self.version, 0, 7, 2)
|| firmware_version_at_least(&self.banner_version, 0, 7, 2)
}
}
impl DeviceState {
pub fn supports_v07_modes(&self) -> bool {
self.latest_info
.as_ref()
.is_some_and(DeviceInfo::supports_v07_modes)
}
pub fn supports_unsolicited(&self) -> bool {
self.latest_info
.as_ref()
.is_some_and(DeviceInfo::supports_unsolicited)
}
}
fn firmware_version_at_least(
version: &Option<String>,
req_major: u64,
req_minor: u64,
req_patch: u64,
) -> bool {
let Some(v) = version.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
return false;
};
let mut parts = v.split('.');
let Some(Ok(major)) = parts.next().map(str::parse) else {
return false;
};
let Some(Ok(minor)) = parts.next().map(str::parse) else {
return false;
};
let patch: u64 = parts
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
(major, minor, patch) >= (req_major, req_minor, req_patch)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ControlStatus {
pub serial_connected: Option<bool>,
pub collection_running: Option<bool>,
pub port_path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReconcileOutcome {
pub new_ids: Vec<String>,
pub removed_ids: Vec<String>,
pub changed: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AppState {
pub server_host: String,
pub server_port: String,
pub export_dir: String,
pub devices: Vec<DeviceState>,
pub selected_device_ids: Vec<String>,
pub transient: TransientUiState,
pub events: Vec<String>,
intent_queue: Vec<UserIntent>,
}
impl AppState {
pub fn with_defaults() -> Self {
let mut state = Self::default();
state.server_host = "127.0.0.1".to_owned();
state.server_port = "3000".to_owned();
state.export_dir = ".".to_owned();
state
}
pub fn push_intent(&mut self, intent: UserIntent) {
self.intent_queue.push(intent);
}
pub fn push_device_action(&mut self, id: impl Into<String>, action: DeviceAction) {
self.intent_queue.push(UserIntent::Device {
id: id.into(),
action,
});
}
pub fn drain_intents(&mut self) -> Vec<UserIntent> {
std::mem::take(&mut self.intent_queue)
}
pub fn push_event(&mut self, message: impl Into<String>) {
self.events.push(message.into());
if self.events.len() > 300 {
let drain_to = self.events.len() - 300;
self.events.drain(0..drain_to);
}
}
pub fn selected_indices(&self) -> Vec<usize> {
self.devices
.iter()
.enumerate()
.filter(|(_, d)| self.selected_device_ids.iter().any(|id| id == &d.id))
.map(|(idx, _)| idx)
.collect()
}
pub fn is_selected(&self, id: &str) -> bool {
self.selected_device_ids.iter().any(|s| s == id)
}
pub fn toggle_selection(&mut self, id: String) {
if let Some(pos) = self.selected_device_ids.iter().position(|s| s == &id) {
self.selected_device_ids.remove(pos);
} else {
self.selected_device_ids.push(id);
}
}
pub fn device_index_by_id(&self, id: &str) -> Option<usize> {
self.devices.iter().position(|d| d.id == id)
}
pub fn device_mut_by_id(&mut self, id: &str) -> Option<&mut DeviceState> {
self.devices.iter_mut().find(|d| d.id == id)
}
pub fn base_http_url(&self) -> String {
format!(
"http://{}:{}",
self.server_host.trim(),
self.server_port.trim()
)
}
pub fn device_ws_url(&self, id: &str) -> String {
format!(
"ws://{}:{}/api/devices/{}/ws",
self.server_host.trim(),
self.server_port.trim(),
id
)
}
pub fn reconcile_devices(&mut self, entries: Vec<DeviceListEntry>) -> ReconcileOutcome {
let mut outcome = ReconcileOutcome::default();
let incoming_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
outcome.removed_ids = self
.devices
.iter()
.map(|d| d.id.clone())
.filter(|id| !incoming_ids.contains(id))
.collect();
let before = self.devices.len();
self.devices.retain(|d| incoming_ids.contains(&d.id));
if self.devices.len() != before {
outcome.changed = true;
}
for entry in &entries {
if let Some(idx) = self.device_index_by_id(&entry.id) {
self.devices[idx].apply_list_entry(entry);
} else {
let mut device = DeviceState::new(entry.id.clone());
device.apply_list_entry(entry);
self.devices.push(device);
outcome.new_ids.push(entry.id.clone());
outcome.changed = true;
}
}
let before_selection = self.selected_device_ids.len();
self.selected_device_ids
.retain(|id| self.devices.iter().any(|d| &d.id == id));
if self.selected_device_ids.len() != before_selection {
outcome.changed = true;
}
if self.selected_device_ids.is_empty() {
if let Some(first) = self.devices.first() {
self.selected_device_ids.push(first.id.clone());
outcome.changed = true;
}
}
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_config_parses_full_nested_response() {
let json = r#"{
"wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
"collection": {
"mode": "collector", "traffic_hz": 100, "unsolicited": true,
"phy_rate": "mcs0-lgi",
"protocol": "n", "io_tx_enabled": true, "io_rx_enabled": true
},
"csi_config": {
"lltf_enabled": true, "htltf_enabled": true,
"stbc_htltf_enabled": true, "ltf_merge_enabled": true,
"csi_he_stbc": 2, "val_scale_cfg": 2,
"acquire_csi": 1, "acquire_csi_legacy": 0
},
"csi_delivery_mode": "async",
"csi_logging_enabled": true
}"#;
let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
let mut device = DeviceState::new("ttyUSB0");
let applied = device.apply_device_config(cfg);
assert!(applied > 0);
assert_eq!(device.forms.wifi.mode, WiFiMode::Sniffer);
assert_eq!(device.forms.wifi.channel, "6");
assert_eq!(device.forms.traffic.frequency_hz, "100");
assert!(device.forms.traffic.unsolicited);
assert!(device.forms.csi.csi);
assert!(!device.forms.csi.csi_legacy);
assert_eq!(device.forms.protocol, WifiProtocol::N);
}
#[test]
fn device_config_tolerates_null_sub_objects() {
let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
let mut device = DeviceState::new("ttyUSB0");
assert_eq!(device.apply_device_config(cfg), 0);
}
#[test]
fn device_config_tolerates_missing_sub_objects() {
let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
let mut device = DeviceState::new("ttyUSB0");
assert_eq!(device.apply_device_config(cfg), 0);
}
#[test]
fn wifi_mode_parses_v07_values() {
assert_eq!(
WiFiMode::from_api_value("wifi-ap"),
Some(WiFiMode::WifiAp)
);
assert!(WiFiMode::EspNowFastCollector.is_esp_now());
assert!(WiFiMode::WifiAp.requires_v07());
}
#[test]
fn wifi_mode_station_disallows_channel() {
assert!(!WiFiMode::Station.allows_channel());
assert!(WiFiMode::Sniffer.allows_channel());
assert!(WiFiMode::WifiAp.allows_channel());
assert!(WiFiMode::EspNowCentral.allows_channel());
}
#[test]
fn device_config_applies_ap_fields() {
let json = r#"{
"wifi": {
"mode": "wifi-ap",
"channel": 6,
"ap_ssid": "lab-ap",
"ap_dhcp": false
}
}"#;
let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
let mut device = DeviceState::new("D0CF13E290E8");
device.apply_device_config(cfg);
assert_eq!(device.forms.wifi.mode, WiFiMode::WifiAp);
assert_eq!(device.forms.wifi.ap_ssid, "lab-ap");
assert!(!device.forms.wifi.ap_dhcp);
}
#[test]
fn firmware_version_gating() {
let info = DeviceInfo {
version: Some("0.7.0".to_owned()),
..Default::default()
};
assert!(info.supports_v07_modes());
let old = DeviceInfo {
version: Some("0.6.0".to_owned()),
..Default::default()
};
assert!(!old.supports_v07_modes());
let v071 = DeviceInfo {
version: Some("0.7.1".to_owned()),
..Default::default()
};
assert!(!v071.supports_unsolicited());
let v072 = DeviceInfo {
version: Some("0.7.2".to_owned()),
..Default::default()
};
assert!(v072.supports_unsolicited());
}
#[test]
fn device_list_parses_mac_field() {
let json = r#"[
{
"id": "D0-CF-13-E2-90-E8",
"mac": "D0:CF:13:E2:90:E8",
"port_path": "/dev/ttyACM0",
"serial_connected": true,
"firmware_verified": true
}
]"#;
let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
let mut state = AppState::with_defaults();
state.reconcile_devices(entries);
assert_eq!(state.devices[0].id, "D0-CF-13-E2-90-E8");
assert_eq!(
state.devices[0].mac.as_deref(),
Some("D0:CF:13:E2:90:E8")
);
}
#[test]
fn device_list_parses_and_reconciles() {
let json = r#"[
{
"id": "ttyUSB0", "port_path": "/dev/ttyUSB0", "baud_rate": 115200,
"serial_connected": true, "collection_running": false,
"firmware_verified": true,
"device_info": { "name": "esp-csi-cli-rs", "chip": "esp32c6", "protocol": 1 },
"fault": "USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — replug"
}
]"#;
let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
let mut state = AppState::with_defaults();
let outcome = state.reconcile_devices(entries);
assert!(outcome.changed);
assert_eq!(outcome.new_ids, vec!["ttyUSB0".to_owned()]);
assert_eq!(state.devices.len(), 1);
assert_eq!(state.selected_device_ids, vec!["ttyUSB0".to_owned()]);
let device = &state.devices[0];
assert_eq!(device.serial_connected, Some(true));
assert_eq!(device.latest_info.as_ref().unwrap().chip.as_deref(), Some("esp32c6"));
assert!(device.fault.as_deref().unwrap().contains("USB-JTAG reset loop"));
}
#[test]
fn reconcile_drops_vanished_and_reselects() {
let mut state = AppState::with_defaults();
state.reconcile_devices(vec![
DeviceListEntry { id: "a".to_owned(), ..Default::default() },
DeviceListEntry { id: "b".to_owned(), ..Default::default() },
]);
assert_eq!(state.selected_device_ids, vec!["a".to_owned()]);
let outcome = state.reconcile_devices(vec![DeviceListEntry {
id: "b".to_owned(),
..Default::default()
}]);
assert!(outcome.changed);
assert_eq!(outcome.removed_ids, vec!["a".to_owned()]);
assert_eq!(state.devices.len(), 1);
assert_eq!(state.selected_device_ids, vec!["b".to_owned()]);
}
}