Skip to main content

csi_webclient/state/
mod.rs

1use serde::{Deserialize, Serialize};
2
3/// UI navigation tabs for the main window.
4#[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/// Wi-Fi operating modes accepted by `POST /api/devices/{id}/config/wifi`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum WiFiMode {
17    Station,
18    Sniffer,
19    WifiAp,
20    EspNowCentral,
21    EspNowPeripheral,
22    EspNowFastCollector,
23    EspNowFastSource,
24}
25
26impl WiFiMode {
27    /// Convert enum variant to backend API value.
28    pub fn as_api_value(self) -> &'static str {
29        match self {
30            Self::Station => "station",
31            Self::Sniffer => "sniffer",
32            Self::WifiAp => "wifi-ap",
33            Self::EspNowCentral => "esp-now-central",
34            Self::EspNowPeripheral => "esp-now-peripheral",
35            Self::EspNowFastCollector => "esp-now-fast-collector",
36            Self::EspNowFastSource => "esp-now-fast-source",
37        }
38    }
39
40    /// Resolve a backend value back to a variant.
41    pub fn from_api_value(value: &str) -> Option<Self> {
42        match value {
43            "station" => Some(Self::Station),
44            "sniffer" => Some(Self::Sniffer),
45            "wifi-ap" => Some(Self::WifiAp),
46            "esp-now-central" => Some(Self::EspNowCentral),
47            "esp-now-peripheral" => Some(Self::EspNowPeripheral),
48            "esp-now-fast-collector" => Some(Self::EspNowFastCollector),
49            "esp-now-fast-source" => Some(Self::EspNowFastSource),
50            _ => None,
51        }
52    }
53
54    /// True for all ESP-NOW operating modes (balanced and fast simplex).
55    pub fn is_esp_now(self) -> bool {
56        matches!(
57            self,
58            Self::EspNowCentral
59                | Self::EspNowPeripheral
60                | Self::EspNowFastCollector
61                | Self::EspNowFastSource
62        )
63    }
64
65    /// Requires `esp-csi-cli-rs` ≥ 0.7.0 on the device.
66    pub fn requires_v07(self) -> bool {
67        matches!(
68            self,
69            Self::WifiAp | Self::EspNowFastCollector | Self::EspNowFastSource
70        )
71    }
72
73    /// Whether `POST …/config/wifi` accepts a `channel` field for this mode.
74    ///
75    /// Station mode inherits the channel from the associated AP; the server
76    /// omits `--set-channel` and ignores `channel` in the request body.
77    pub fn allows_channel(self) -> bool {
78        !matches!(self, Self::Station)
79    }
80}
81
82impl Default for WiFiMode {
83    fn default() -> Self {
84        Self::Station
85    }
86}
87
88/// Collection role for the ESP32 firmware session.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum CollectionMode {
91    Collector,
92    Listener,
93}
94
95impl CollectionMode {
96    /// Convert enum variant to backend API value.
97    pub fn as_api_value(self) -> &'static str {
98        match self {
99            Self::Collector => "collector",
100            Self::Listener => "listener",
101        }
102    }
103}
104
105impl Default for CollectionMode {
106    fn default() -> Self {
107        Self::Collector
108    }
109}
110
111/// Forced ESP-NOW TX HT40 secondary-channel selection (`set-wifi --ht40`).
112///
113/// Only meaningful in ESP-NOW modes; ignored by the firmware otherwise.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum Ht40Mode {
116    #[default]
117    None,
118    Above,
119    Below,
120}
121
122impl Ht40Mode {
123    /// Convert enum variant to backend API value.
124    pub fn as_api_value(self) -> &'static str {
125        match self {
126            Self::None => "none",
127            Self::Above => "above",
128            Self::Below => "below",
129        }
130    }
131
132    /// Resolve a backend value back to a variant (`off` aliases `none`).
133    pub fn from_api_value(value: &str) -> Option<Self> {
134        match value {
135            "none" | "off" => Some(Self::None),
136            "above" => Some(Self::Above),
137            "below" => Some(Self::Below),
138            _ => None,
139        }
140    }
141}
142
143/// Output routing mode for CSI frames.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum OutputMode {
146    Stream,
147    Dump,
148    Both,
149}
150
151impl OutputMode {
152    /// Convert enum variant to backend API value.
153    pub fn as_api_value(self) -> &'static str {
154        match self {
155            Self::Stream => "stream",
156            Self::Dump => "dump",
157            Self::Both => "both",
158        }
159    }
160}
161
162impl Default for OutputMode {
163    fn default() -> Self {
164        Self::Stream
165    }
166}
167
168/// CSI delivery path accepted by `POST /api/devices/{id}/config/csi-delivery`.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum CsiDeliveryMode {
171    Off,
172    Callback,
173    Async,
174    /// Zero-copy fast-path; stored as a device flag, takes effect on next
175    /// `start`. No CSI data is delivered or logged while active.
176    Raw,
177}
178
179impl CsiDeliveryMode {
180    pub fn as_api_value(self) -> &'static str {
181        match self {
182            Self::Off => "off",
183            Self::Callback => "callback",
184            Self::Async => "async",
185            Self::Raw => "raw",
186        }
187    }
188}
189
190impl Default for CsiDeliveryMode {
191    fn default() -> Self {
192        Self::Async
193    }
194}
195
196/// Wi-Fi PHY protocol applied at the start of each collection run
197/// (`POST /api/devices/{id}/config/protocol`).
198///
199/// Default on the device is `lr` (Espressif Long-Range), which is
200/// proprietary and won't associate with a standard AP — set `n`/`ax`
201/// explicitly for station mode.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum WifiProtocol {
204    B,
205    G,
206    N,
207    Lr,
208    A,
209    Ac,
210    Ax,
211}
212
213impl WifiProtocol {
214    /// Convert enum variant to backend API value.
215    pub fn as_api_value(self) -> &'static str {
216        match self {
217            Self::B => "b",
218            Self::G => "g",
219            Self::N => "n",
220            Self::Lr => "lr",
221            Self::A => "a",
222            Self::Ac => "ac",
223            Self::Ax => "ax",
224        }
225    }
226
227    /// Resolve a backend value back to a variant (case-insensitive).
228    pub fn from_api_value(value: &str) -> Option<Self> {
229        match value.to_ascii_lowercase().as_str() {
230            "b" => Some(Self::B),
231            "g" => Some(Self::G),
232            "n" => Some(Self::N),
233            "lr" => Some(Self::Lr),
234            "a" => Some(Self::A),
235            "ac" => Some(Self::Ac),
236            "ax" => Some(Self::Ax),
237            _ => None,
238        }
239    }
240}
241
242impl Default for WifiProtocol {
243    fn default() -> Self {
244        Self::Lr
245    }
246}
247
248/// PHY rate options accepted by `POST /api/devices/{id}/config/rate`.
249///
250/// Honored by all modes except `station` on the firmware side.
251pub const PHY_RATES: &[&str] = &[
252    "1m", "1m-l", "2m", "5m5", "5m5-l", "11m", "11m-l", "6m", "9m", "12m", "18m", "24m", "36m",
253    "48m", "54m", "mcs0-lgi", "mcs1-lgi", "mcs2-lgi", "mcs3-lgi", "mcs4-lgi", "mcs5-lgi",
254    "mcs6-lgi", "mcs7-lgi", "mcs0-sgi",
255];
256
257/// Editable Wi-Fi form values in the Config view.
258#[derive(Debug, Clone)]
259pub struct WiFiForm {
260    pub mode: WiFiMode,
261    pub sta_ssid: String,
262    pub sta_password: String,
263    pub ap_ssid: String,
264    pub ap_password: String,
265    pub ap_dhcp: bool,
266    pub channel: String,
267    /// ESP-NOW peer source-MAC filter (`aa:bb:cc:dd:ee:ff`); empty means
268    /// clear back to automatic magic-prefix pairing. ESP-NOW modes only.
269    pub peer_mac: String,
270    /// Forced ESP-NOW TX HT40 secondary channel. ESP-NOW modes only.
271    pub ht40: Ht40Mode,
272}
273
274impl Default for WiFiForm {
275    fn default() -> Self {
276        Self {
277            mode: WiFiMode::Station,
278            sta_ssid: String::new(),
279            sta_password: String::new(),
280            ap_ssid: "esp-csi-ap".to_owned(),
281            ap_password: String::new(),
282            ap_dhcp: true,
283            channel: String::new(),
284            peer_mac: String::new(),
285            ht40: Ht40Mode::None,
286        }
287    }
288}
289
290/// Pairing cookbook from esp-csi-cli-rs WEBSERVER.md (two-device setups).
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum PairingPreset {
293    SoftApLab,
294    EspNowFastSimplex,
295    EspNowBalanced,
296}
297
298impl PairingPreset {
299    pub fn label(self) -> &'static str {
300        match self {
301            Self::SoftApLab => "SoftAP lab pair",
302            Self::EspNowFastSimplex => "ESP-NOW fast simplex",
303            Self::EspNowBalanced => "ESP-NOW balanced",
304        }
305    }
306
307    /// Whether this preset requires firmware ≥ 0.7.0 on both boards.
308    pub fn requires_v07(self) -> bool {
309        matches!(self, Self::SoftApLab | Self::EspNowFastSimplex)
310    }
311}
312#[derive(Debug, Clone)]
313pub struct TrafficForm {
314    pub frequency_hz: String,
315    /// Flood unsolicited echo replies instead of echo requests: strictly
316    /// one-directional traffic (peer never answers at the IP level), stable
317    /// offered rate — but this device captures no CSI back from replies.
318    /// Only meaningful for WiFi AP/station modes with frequency > 0.
319    pub unsolicited: bool,
320}
321
322impl Default for TrafficForm {
323    fn default() -> Self {
324        Self {
325            frequency_hz: "100".to_owned(),
326            unsolicited: false,
327        }
328    }
329}
330
331/// Editable CSI feature flags and numeric values.
332///
333/// Boolean fields use explicit on/off semantics matching `POST …/config/csi`
334/// (`lltf`, `csi_legacy`, …). Defaults mirror firmware `reset-config`.
335#[derive(Debug, Clone)]
336pub struct CsiForm {
337    // Classic (ESP32 / C3 / S3)
338    pub lltf: bool,
339    pub htltf: bool,
340    pub stbc_htltf: bool,
341    pub ltf_merge: bool,
342    // HE (ESP32-C5 / C6)
343    pub csi: bool,
344    pub csi_legacy: bool,
345    pub csi_ht20: bool,
346    pub csi_ht40: bool,
347    pub csi_su: bool,
348    pub csi_mu: bool,
349    pub csi_dcm: bool,
350    pub csi_beamformed: bool,
351    pub dump_ack: bool,
352    pub csi_force_lltf: bool,
353    pub csi_vht: bool,
354    pub csi_he_stbc: String,
355    pub val_scale_cfg: String,
356}
357
358impl Default for CsiForm {
359    fn default() -> Self {
360        Self {
361            lltf: true,
362            htltf: true,
363            stbc_htltf: true,
364            ltf_merge: true,
365            csi: true,
366            csi_legacy: true,
367            csi_ht20: true,
368            csi_ht40: true,
369            csi_su: true,
370            csi_mu: true,
371            csi_dcm: true,
372            csi_beamformed: true,
373            dump_ack: true,
374            csi_force_lltf: true,
375            csi_vht: true,
376            csi_he_stbc: "2".to_owned(),
377            val_scale_cfg: "2".to_owned(),
378        }
379    }
380}
381
382/// Editable PHY rate form value.
383#[derive(Debug, Clone)]
384pub struct PhyRateForm {
385    pub rate: String,
386}
387
388impl Default for PhyRateForm {
389    fn default() -> Self {
390        Self {
391            rate: "mcs0-lgi".to_owned(),
392        }
393    }
394}
395
396/// Editable IO tasks toggle form values.
397#[derive(Debug, Clone)]
398pub struct IoTasksForm {
399    pub tx: bool,
400    pub rx: bool,
401}
402
403impl Default for IoTasksForm {
404    fn default() -> Self {
405        Self { tx: true, rx: true }
406    }
407}
408
409/// Editable CSI delivery form values.
410#[derive(Debug, Clone)]
411pub struct CsiDeliveryForm {
412    pub mode: CsiDeliveryMode,
413    pub logging: bool,
414}
415
416impl Default for CsiDeliveryForm {
417    fn default() -> Self {
418        Self {
419            mode: CsiDeliveryMode::Async,
420            logging: true,
421        }
422    }
423}
424
425/// All editable configuration form values for one device.
426///
427/// These mirror the per-device `config` sub-resources and are populated from
428/// `GET /api/devices/{id}/config`.
429#[derive(Debug, Clone, Default)]
430pub struct DeviceForms {
431    pub wifi: WiFiForm,
432    pub traffic: TrafficForm,
433    pub csi: CsiForm,
434    pub collection_mode: CollectionMode,
435    pub output_mode: OutputMode,
436    pub protocol: WifiProtocol,
437    pub phy_rate: PhyRateForm,
438    pub io_tasks: IoTasksForm,
439    pub csi_delivery: CsiDeliveryForm,
440    pub start_duration_seconds: String,
441}
442
443/// Lightweight frame metadata shown in the Stream tab.
444#[derive(Debug, Clone, Default)]
445pub struct FrameSummary {
446    pub timestamp: String,
447    pub length: usize,
448    pub preview_hex: String,
449}
450
451/// Connection state to the webserver, derived from `GET /api/devices` results.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
453pub enum ServerStatus {
454    /// No attempt has completed yet (initial state).
455    #[default]
456    Unknown,
457    /// A connection attempt is in flight (e.g. Connect was just clicked).
458    Connecting,
459    /// The last `GET /api/devices` succeeded — the server is reachable.
460    Connected,
461    /// The last `GET /api/devices` failed (network error or HTTP error).
462    Disconnected,
463}
464
465impl ServerStatus {
466    /// Short human-readable label for display.
467    pub fn label(self) -> &'static str {
468        match self {
469            Self::Unknown => "Not connected",
470            Self::Connecting => "Connecting…",
471            Self::Connected => "Connected",
472            Self::Disconnected => "Disconnected",
473        }
474    }
475}
476
477/// Ephemeral UI state that is not part of backend/device config.
478#[derive(Debug, Clone)]
479pub struct TransientUiState {
480    pub active_tab: Tab,
481    pub status_message: String,
482    pub error_message: String,
483    pub server_status: ServerStatus,
484    /// Channel used by two-device pairing presets on the Devices tab.
485    pub preset_channel: String,
486}
487
488impl Default for TransientUiState {
489    fn default() -> Self {
490        Self {
491            active_tab: Tab::Devices,
492            status_message: "Ready".to_owned(),
493            error_message: String::new(),
494            server_status: ServerStatus::Unknown,
495            preset_channel: "6".to_owned(),
496        }
497    }
498}
499
500/// Complete per-device state: identity, config forms, status, and stream.
501///
502/// Every attached device discovered via `GET /api/devices` owns one of these.
503/// State is fully isolated per device, matching the server's per-device model.
504#[derive(Debug, Clone)]
505pub struct DeviceState {
506    /// Stable device id used to build `/api/devices/{id}/...` paths.
507    pub id: String,
508    /// Stable board MAC when reported by the webserver (USB iSerialNumber).
509    pub mac: Option<String>,
510    // ---- live metadata from GET /api/devices ----
511    pub port_path: Option<String>,
512    pub baud_rate: Option<u32>,
513    pub serial_connected: Option<bool>,
514    pub collection_running: Option<bool>,
515    pub firmware_verified: Option<bool>,
516    /// Chip fault reported by the webserver (e.g. the ESP32-C5/C6 USB-JTAG
517    /// reset-loop wedge), including the recovery action. `None` = healthy.
518    pub fault: Option<String>,
519    pub latest_info: Option<DeviceInfo>,
520    // ---- editable config + cache ----
521    pub forms: DeviceForms,
522    pub latest_config: Option<DeviceConfig>,
523    /// Guards against an infinite reset/fetch loop when an empty fetch
524    /// auto-issues `config/reset` and the follow-up fetch is also empty.
525    pub auto_resetting_cache: bool,
526    // ---- stream ----
527    pub ws_connected: bool,
528    pub frames_received: u64,
529    pub bytes_received: u64,
530    pub recent_frames: Vec<FrameSummary>,
531    pub auto_scroll_stream: bool,
532    /// True once the one-time info/config/status fetch has been issued for
533    /// this freshly-discovered device.
534    pub details_loaded: bool,
535    // ---- local Parquet export ----
536    /// True while this device's stream is being recorded to a Parquet file.
537    pub recording: bool,
538    /// Output path of the current (or most recent) recording.
539    pub record_path: Option<String>,
540    /// Frames written to the active recording.
541    pub recorded_frames: u64,
542    /// Frames the recorder could not decode (wire drift / truncation).
543    pub record_decode_errors: u64,
544}
545
546impl DeviceState {
547    /// Create a new device state with default forms.
548    pub fn new(id: impl Into<String>) -> Self {
549        Self {
550            id: id.into(),
551            mac: None,
552            port_path: None,
553            baud_rate: None,
554            serial_connected: None,
555            collection_running: None,
556            firmware_verified: None,
557            fault: None,
558            latest_info: None,
559            forms: DeviceForms::default(),
560            latest_config: None,
561            auto_resetting_cache: false,
562            ws_connected: false,
563            frames_received: 0,
564            bytes_received: 0,
565            recent_frames: Vec::new(),
566            auto_scroll_stream: true,
567            details_loaded: false,
568            recording: false,
569            record_path: None,
570            recorded_frames: 0,
571            record_decode_errors: 0,
572        }
573    }
574
575    /// Refresh the live status fields from a `GET /api/devices` list entry.
576    ///
577    /// This keeps the dashboard/fleet view current on every discovery poll
578    /// without per-device round-trips.
579    pub fn apply_list_entry(&mut self, entry: &DeviceListEntry) {
580        self.mac = entry.mac.clone();
581        self.port_path = entry.port_path.clone();
582        self.baud_rate = entry.baud_rate;
583        self.serial_connected = entry.serial_connected;
584        self.collection_running = entry.collection_running;
585        self.firmware_verified = entry.firmware_verified;
586        self.fault = entry.fault.clone();
587        if let Some(info) = &entry.device_info {
588            self.latest_info = Some(info.clone());
589        }
590    }
591
592    /// Record one received frame and update stream counters/history.
593    pub fn push_frame(&mut self, bytes: &[u8]) {
594        self.frames_received = self.frames_received.saturating_add(1);
595        self.bytes_received = self.bytes_received.saturating_add(bytes.len() as u64);
596
597        let preview = bytes
598            .iter()
599            .take(24)
600            .map(|b| format!("{b:02X}"))
601            .collect::<Vec<_>>()
602            .join(" ");
603
604        self.recent_frames.push(FrameSummary {
605            timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
606            length: bytes.len(),
607            preview_hex: preview,
608        });
609
610        if self.recent_frames.len() > 300 {
611            let drain_to = self.recent_frames.len() - 300;
612            self.recent_frames.drain(0..drain_to);
613        }
614    }
615
616    /// Clear stream frames and counters for this device.
617    pub fn clear_frames(&mut self) {
618        self.recent_frames.clear();
619        self.frames_received = 0;
620        self.bytes_received = 0;
621    }
622
623    /// Apply a `control/status` payload to this device's runtime status.
624    pub fn apply_control_status(&mut self, status: ControlStatus) {
625        self.serial_connected = status.serial_connected;
626        self.collection_running = status.collection_running;
627        self.port_path = status.port_path;
628    }
629
630    /// Apply server config payload into this device's form fields.
631    ///
632    /// Returns the number of fields that were actually applied; callers
633    /// use a zero return to detect an empty server cache.
634    pub fn apply_device_config(&mut self, config: DeviceConfig) -> usize {
635        let mut applied = 0;
636        let forms = &mut self.forms;
637
638        if let Some(wifi) = config.wifi.as_ref() {
639            if let Some(mode) = wifi.mode.as_deref() {
640                if let Some(parsed) = WiFiMode::from_api_value(mode) {
641                    forms.wifi.mode = parsed;
642                    applied += 1;
643                }
644            }
645            if let Some(channel) = wifi.channel {
646                forms.wifi.channel = channel.to_string();
647                applied += 1;
648            }
649            if let Some(ssid) = &wifi.sta_ssid {
650                forms.wifi.sta_ssid = ssid.clone();
651                applied += 1;
652            }
653            if let Some(ap_ssid) = &wifi.ap_ssid {
654                forms.wifi.ap_ssid = ap_ssid.clone();
655                applied += 1;
656            }
657            if let Some(ap_dhcp) = wifi.ap_dhcp {
658                forms.wifi.ap_dhcp = ap_dhcp;
659                applied += 1;
660            }
661            if let Some(peer_mac) = &wifi.peer_mac {
662                // The server reports "auto" for the default pairing; surface
663                // that as an empty field so re-submitting keeps it automatic.
664                forms.wifi.peer_mac = if peer_mac == "auto" {
665                    String::new()
666                } else {
667                    peer_mac.clone()
668                };
669                applied += 1;
670            }
671            if let Some(ht40) = wifi.ht40.as_deref() {
672                if let Some(parsed) = Ht40Mode::from_api_value(ht40) {
673                    forms.wifi.ht40 = parsed;
674                    applied += 1;
675                }
676            }
677        }
678
679        if let Some(collection) = config.collection.as_ref() {
680            if let Some(traffic_hz) = collection.traffic_hz {
681                forms.traffic.frequency_hz = traffic_hz.to_string();
682                applied += 1;
683            }
684            if let Some(unsolicited) = collection.unsolicited {
685                forms.traffic.unsolicited = unsolicited;
686                applied += 1;
687            }
688            if let Some(mode) = collection.mode.as_deref() {
689                forms.collection_mode = if mode == "listener" {
690                    CollectionMode::Listener
691                } else {
692                    CollectionMode::Collector
693                };
694                applied += 1;
695            }
696            if let Some(rate) = &collection.phy_rate {
697                forms.phy_rate.rate = rate.clone();
698                applied += 1;
699            }
700            if let Some(protocol) = collection.protocol.as_deref() {
701                if let Some(parsed) = WifiProtocol::from_api_value(protocol) {
702                    forms.protocol = parsed;
703                    applied += 1;
704                }
705            }
706            if let Some(tx) = collection.io_tx_enabled {
707                forms.io_tasks.tx = tx;
708                applied += 1;
709            }
710            if let Some(rx) = collection.io_rx_enabled {
711                forms.io_tasks.rx = rx;
712                applied += 1;
713            }
714        }
715
716        if let Some(csi_cfg) = config.csi_config.as_ref() {
717            if let Some(v) = csi_cfg.lltf_enabled {
718                forms.csi.lltf = v;
719                applied += 1;
720            }
721            if let Some(v) = csi_cfg.htltf_enabled {
722                forms.csi.htltf = v;
723                applied += 1;
724            }
725            if let Some(v) = csi_cfg.stbc_htltf_enabled {
726                forms.csi.stbc_htltf = v;
727                applied += 1;
728            }
729            if let Some(v) = csi_cfg.ltf_merge_enabled {
730                forms.csi.ltf_merge = v;
731                applied += 1;
732            }
733            if let Some(v) = csi_cfg.acquire_csi {
734                forms.csi.csi = v != 0;
735                applied += 1;
736            }
737            if let Some(v) = csi_cfg.acquire_csi_legacy {
738                forms.csi.csi_legacy = v != 0;
739                applied += 1;
740            }
741            if let Some(v) = csi_cfg.acquire_csi_ht20 {
742                forms.csi.csi_ht20 = v != 0;
743                applied += 1;
744            }
745            if let Some(v) = csi_cfg.acquire_csi_ht40 {
746                forms.csi.csi_ht40 = v != 0;
747                applied += 1;
748            }
749            if let Some(v) = csi_cfg.acquire_csi_su {
750                forms.csi.csi_su = v != 0;
751                applied += 1;
752            }
753            if let Some(v) = csi_cfg.acquire_csi_mu {
754                forms.csi.csi_mu = v != 0;
755                applied += 1;
756            }
757            if let Some(v) = csi_cfg.acquire_csi_dcm {
758                forms.csi.csi_dcm = v != 0;
759                applied += 1;
760            }
761            if let Some(v) = csi_cfg.acquire_csi_beamformed {
762                forms.csi.csi_beamformed = v != 0;
763                applied += 1;
764            }
765            if let Some(v) = csi_cfg.dump_ack_enabled {
766                forms.csi.dump_ack = v;
767                applied += 1;
768            }
769            if let Some(v) = csi_cfg.acquire_csi_force_lltf {
770                forms.csi.csi_force_lltf = v;
771                applied += 1;
772            }
773            if let Some(v) = csi_cfg.acquire_csi_vht {
774                forms.csi.csi_vht = v;
775                applied += 1;
776            }
777            if let Some(v) = csi_cfg.csi_he_stbc {
778                forms.csi.csi_he_stbc = v.to_string();
779                applied += 1;
780            }
781            if let Some(v) = csi_cfg.val_scale_cfg {
782                forms.csi.val_scale_cfg = v.to_string();
783                applied += 1;
784            }
785        }
786
787        if let Some(mode) = config.csi_delivery_mode.as_deref() {
788            forms.csi_delivery.mode = match mode {
789                "off" => CsiDeliveryMode::Off,
790                "callback" => CsiDeliveryMode::Callback,
791                "raw" => CsiDeliveryMode::Raw,
792                _ => CsiDeliveryMode::Async,
793            };
794            applied += 1;
795        }
796        if let Some(logging) = config.csi_logging_enabled {
797            forms.csi_delivery.logging = logging;
798            applied += 1;
799        }
800
801        self.latest_config = Some(config);
802        applied
803    }
804}
805
806/// High-level user actions queued by the UI for orchestration.
807#[derive(Debug, Clone)]
808pub enum UserIntent {
809    /// Refresh the attached-device list (`GET /api/devices`).
810    FetchDevices,
811    /// Toggle whether a device is in the detail-tab selection set.
812    ToggleDeviceSelection(String),
813    /// Select every attached device for the detail tabs.
814    SelectAllDevices,
815    /// Clear the detail-tab device selection.
816    ClearDeviceSelection,
817    /// Start collection on every attached device.
818    StartAllCollections { duration_seconds: String },
819    /// Stop collection on every attached device.
820    StopAllCollections,
821    /// Start collection on every selected device (synchronized FDM start).
822    StartSelectedCollections { duration_seconds: String },
823    /// Stop collection on every selected device.
824    StopSelectedCollections,
825    /// Start Parquet recording on every selected device.
826    StartSelectedRecording,
827    /// Stop Parquet recording on every selected device.
828    StopSelectedRecording,
829    /// An action addressed to one specific device.
830    Device { id: String, action: DeviceAction },
831}
832
833/// Per-device action; addressed to a device id by [`UserIntent::Device`].
834#[derive(Debug, Clone)]
835pub enum DeviceAction {
836    FetchConfig,
837    FetchInfo,
838    FetchStatus,
839    ResetConfig,
840    SetWifi(WiFiForm),
841    SetTraffic(TrafficForm),
842    SetCsi(CsiForm),
843    /// Apply a full CSI acquisition preset (`default` | `he20`).
844    SetCsiPreset(&'static str),
845    SetCollectionMode(CollectionMode),
846    SetOutputMode(OutputMode),
847    SetProtocol(WifiProtocol),
848    SetPhyRate(PhyRateForm),
849    SetIoTasks(IoTasksForm),
850    SetCsiDelivery(CsiDeliveryForm),
851    StartCollection { duration_seconds: String },
852    StopCollection,
853    ShowStats,
854    ResetDevice,
855    ConnectWebSocket,
856    DisconnectWebSocket,
857    ClearFrames,
858    /// Begin recording this device's incoming CSI stream to a Parquet file.
859    StartRecording,
860    /// Stop the active recording and finalize the Parquet file.
861    StopRecording,
862    /// Apply a two-device pairing cookbook (ordered config steps per board).
863    ApplyPairingPreset {
864        preset: PairingPreset,
865        device_ids: [String; 2],
866        channel: u8,
867    },
868}
869
870/// One element of the `GET /api/devices` array.
871#[derive(Debug, Clone, Serialize, Deserialize, Default)]
872pub struct DeviceListEntry {
873    pub id: String,
874    pub mac: Option<String>,
875    pub port_path: Option<String>,
876    pub baud_rate: Option<u32>,
877    pub serial_connected: Option<bool>,
878    pub collection_running: Option<bool>,
879    pub firmware_verified: Option<bool>,
880    #[serde(default)]
881    pub device_info: Option<DeviceInfo>,
882    #[serde(default)]
883    pub fault: Option<String>,
884}
885
886/// Wi-Fi section of `GET /api/devices/{id}/config`.
887#[derive(Debug, Clone, Serialize, Deserialize, Default)]
888pub struct DeviceWifiConfig {
889    pub mode: Option<String>,
890    pub channel: Option<u16>,
891    pub sta_ssid: Option<String>,
892    pub ap_ssid: Option<String>,
893    pub ap_dhcp: Option<bool>,
894    pub peer_mac: Option<String>,
895    pub ht40: Option<String>,
896}
897
898/// Collection section of `GET /api/devices/{id}/config`.
899#[derive(Debug, Clone, Serialize, Deserialize, Default)]
900pub struct DeviceCollectionConfig {
901    pub mode: Option<String>,
902    pub traffic_hz: Option<u64>,
903    pub unsolicited: Option<bool>,
904    pub phy_rate: Option<String>,
905    pub protocol: Option<String>,
906    pub io_tx_enabled: Option<bool>,
907    pub io_rx_enabled: Option<bool>,
908}
909
910/// CSI section of `GET /api/devices/{id}/config`.
911///
912/// Mirrors firmware `show-config`: classic-chip booleans, HE-chip
913/// `acquire_csi*` integers, plus read-only fields the device exposes
914/// but does not accept via `POST /api/devices/{id}/config/csi`.
915#[derive(Debug, Clone, Serialize, Deserialize, Default)]
916pub struct DeviceCsiConfig {
917    pub lltf_enabled: Option<bool>,
918    pub htltf_enabled: Option<bool>,
919    pub stbc_htltf_enabled: Option<bool>,
920    pub ltf_merge_enabled: Option<bool>,
921    pub channel_filter_enabled: Option<bool>,
922    pub manual_scale: Option<bool>,
923    pub shift: Option<i32>,
924    pub dump_ack_enabled: Option<bool>,
925    pub acquire_csi_force_lltf: Option<bool>,
926    pub acquire_csi_vht: Option<bool>,
927    pub acquire_csi: Option<u32>,
928    pub acquire_csi_legacy: Option<u32>,
929    pub acquire_csi_ht20: Option<u32>,
930    pub acquire_csi_ht40: Option<u32>,
931    pub acquire_csi_su: Option<u32>,
932    pub acquire_csi_mu: Option<u32>,
933    pub acquire_csi_dcm: Option<u32>,
934    pub acquire_csi_beamformed: Option<u32>,
935    pub csi_he_stbc: Option<u32>,
936    pub val_scale_cfg: Option<u32>,
937}
938
939/// Cached server-side device configuration model.
940///
941/// Mirrors `GET /api/devices/{id}/config`. Sub-sections are `Option` so an
942/// explicit `null` from the server (cache-not-yet-populated) deserializes as
943/// `None` instead of erroring the whole payload out.
944#[derive(Debug, Clone, Serialize, Deserialize, Default)]
945pub struct DeviceConfig {
946    #[serde(default)]
947    pub wifi: Option<DeviceWifiConfig>,
948    #[serde(default)]
949    pub collection: Option<DeviceCollectionConfig>,
950    #[serde(default)]
951    pub csi_config: Option<DeviceCsiConfig>,
952    pub csi_delivery_mode: Option<String>,
953    pub csi_logging_enabled: Option<bool>,
954}
955
956/// Firmware identity from `GET /api/devices/{id}/info`.
957#[derive(Debug, Clone, Serialize, Deserialize, Default)]
958pub struct DeviceInfo {
959    pub banner_version: Option<String>,
960    pub name: Option<String>,
961    pub version: Option<String>,
962    pub chip: Option<String>,
963    pub mac: Option<String>,
964    pub protocol: Option<u32>,
965    #[serde(default)]
966    pub features: Vec<String>,
967}
968
969impl DeviceInfo {
970    /// True when firmware is new enough for v0.7.0 Wi-Fi modes.
971    pub fn supports_v07_modes(&self) -> bool {
972        firmware_version_at_least(&self.version, 0, 7, 0)
973            || firmware_version_at_least(&self.banner_version, 0, 7, 0)
974    }
975
976    /// True when firmware understands `set-traffic --unsolicited` (≥ 0.7.2).
977    /// Older firmware's CLI rejects the WHOLE set-traffic command on an
978    /// unknown flag ("Error: Did not understand"), silently discarding the
979    /// frequency too — so the client must not send the flag to old firmware.
980    pub fn supports_unsolicited(&self) -> bool {
981        firmware_version_at_least(&self.version, 0, 7, 2)
982            || firmware_version_at_least(&self.banner_version, 0, 7, 2)
983    }
984}
985
986impl DeviceState {
987    /// True when cached firmware info supports v0.7.0 modes.
988    pub fn supports_v07_modes(&self) -> bool {
989        self.latest_info
990            .as_ref()
991            .is_some_and(DeviceInfo::supports_v07_modes)
992    }
993
994    /// True when cached firmware info supports `set-traffic --unsolicited`.
995    pub fn supports_unsolicited(&self) -> bool {
996        self.latest_info
997            .as_ref()
998            .is_some_and(DeviceInfo::supports_unsolicited)
999    }
1000}
1001
1002/// Parse `major.minor.patch` and test ≥ `(req_major, req_minor, req_patch)`.
1003fn firmware_version_at_least(
1004    version: &Option<String>,
1005    req_major: u64,
1006    req_minor: u64,
1007    req_patch: u64,
1008) -> bool {
1009    let Some(v) = version.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
1010        return false;
1011    };
1012    let mut parts = v.split('.');
1013    let Some(Ok(major)) = parts.next().map(str::parse) else {
1014        return false;
1015    };
1016    let Some(Ok(minor)) = parts.next().map(str::parse) else {
1017        return false;
1018    };
1019    let patch: u64 = parts
1020        .next()
1021        .unwrap_or("0")
1022        .parse()
1023        .unwrap_or(0);
1024    (major, minor, patch) >= (req_major, req_minor, req_patch)
1025}
1026
1027/// Runtime status payload from `GET /api/devices/{id}/control/status`.
1028#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1029pub struct ControlStatus {
1030    pub serial_connected: Option<bool>,
1031    pub collection_running: Option<bool>,
1032    pub port_path: Option<String>,
1033}
1034
1035/// Result of reconciling a fresh `GET /api/devices` payload into state.
1036#[derive(Debug, Clone, Default)]
1037pub struct ReconcileOutcome {
1038    /// Ids of devices that were newly discovered this poll.
1039    pub new_ids: Vec<String>,
1040    /// Ids of devices that disappeared since the last poll.
1041    pub removed_ids: Vec<String>,
1042    /// True when the device set (membership or selection) changed.
1043    pub changed: bool,
1044}
1045
1046/// Full application state.
1047///
1048/// This is the single source of truth for all UI-visible data.
1049#[derive(Debug, Clone, Default)]
1050pub struct AppState {
1051    pub server_host: String,
1052    pub server_port: String,
1053    /// Directory where locally-recorded Parquet exports are written.
1054    pub export_dir: String,
1055    pub devices: Vec<DeviceState>,
1056    /// Devices currently selected for the detail tabs. Multiple devices can be
1057    /// selected at once for side-by-side (FDM mesh) collection.
1058    pub selected_device_ids: Vec<String>,
1059    pub transient: TransientUiState,
1060    pub events: Vec<String>,
1061    intent_queue: Vec<UserIntent>,
1062}
1063
1064impl AppState {
1065    /// Construct default state with localhost webserver settings.
1066    pub fn with_defaults() -> Self {
1067        let mut state = Self::default();
1068        state.server_host = "127.0.0.1".to_owned();
1069        state.server_port = "3000".to_owned();
1070        state.export_dir = ".".to_owned();
1071        state
1072    }
1073
1074    /// Queue one user intent.
1075    pub fn push_intent(&mut self, intent: UserIntent) {
1076        self.intent_queue.push(intent);
1077    }
1078
1079    /// Queue a per-device action addressed to `id`.
1080    pub fn push_device_action(&mut self, id: impl Into<String>, action: DeviceAction) {
1081        self.intent_queue.push(UserIntent::Device {
1082            id: id.into(),
1083            action,
1084        });
1085    }
1086
1087    /// Drain queued intents in FIFO order.
1088    pub fn drain_intents(&mut self) -> Vec<UserIntent> {
1089        std::mem::take(&mut self.intent_queue)
1090    }
1091
1092    /// Append one event line to the global event history.
1093    pub fn push_event(&mut self, message: impl Into<String>) {
1094        self.events.push(message.into());
1095        if self.events.len() > 300 {
1096            let drain_to = self.events.len() - 300;
1097            self.events.drain(0..drain_to);
1098        }
1099    }
1100
1101    /// Indices of the currently selected devices, in `devices` order (so the
1102    /// detail tabs render columns in a stable order regardless of click order).
1103    pub fn selected_indices(&self) -> Vec<usize> {
1104        self.devices
1105            .iter()
1106            .enumerate()
1107            .filter(|(_, d)| self.selected_device_ids.iter().any(|id| id == &d.id))
1108            .map(|(idx, _)| idx)
1109            .collect()
1110    }
1111
1112    /// Whether the device with the given id is currently selected.
1113    pub fn is_selected(&self, id: &str) -> bool {
1114        self.selected_device_ids.iter().any(|s| s == id)
1115    }
1116
1117    /// Toggle membership of `id` in the selection set.
1118    pub fn toggle_selection(&mut self, id: String) {
1119        if let Some(pos) = self.selected_device_ids.iter().position(|s| s == &id) {
1120            self.selected_device_ids.remove(pos);
1121        } else {
1122            self.selected_device_ids.push(id);
1123        }
1124    }
1125
1126    /// Index of the device with the given id.
1127    pub fn device_index_by_id(&self, id: &str) -> Option<usize> {
1128        self.devices.iter().position(|d| d.id == id)
1129    }
1130
1131    /// Mutable reference to the device with the given id.
1132    pub fn device_mut_by_id(&mut self, id: &str) -> Option<&mut DeviceState> {
1133        self.devices.iter_mut().find(|d| d.id == id)
1134    }
1135
1136    /// Build the HTTP base URL from host/port fields.
1137    pub fn base_http_url(&self) -> String {
1138        format!(
1139            "http://{}:{}",
1140            self.server_host.trim(),
1141            self.server_port.trim()
1142        )
1143    }
1144
1145    /// Build the per-device WebSocket stream URL.
1146    pub fn device_ws_url(&self, id: &str) -> String {
1147        format!(
1148            "ws://{}:{}/api/devices/{}/ws",
1149            self.server_host.trim(),
1150            self.server_port.trim(),
1151            id
1152        )
1153    }
1154
1155    /// Reconcile a fresh `GET /api/devices` payload into per-device state.
1156    ///
1157    /// Adds new devices (default forms), refreshes live status on existing
1158    /// ones, drops devices that vanished, and auto-selects the first device
1159    /// when the current selection is empty or stale.
1160    pub fn reconcile_devices(&mut self, entries: Vec<DeviceListEntry>) -> ReconcileOutcome {
1161        let mut outcome = ReconcileOutcome::default();
1162
1163        let incoming_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1164
1165        outcome.removed_ids = self
1166            .devices
1167            .iter()
1168            .map(|d| d.id.clone())
1169            .filter(|id| !incoming_ids.contains(id))
1170            .collect();
1171
1172        let before = self.devices.len();
1173        self.devices.retain(|d| incoming_ids.contains(&d.id));
1174        if self.devices.len() != before {
1175            outcome.changed = true;
1176        }
1177
1178        for entry in &entries {
1179            if let Some(idx) = self.device_index_by_id(&entry.id) {
1180                self.devices[idx].apply_list_entry(entry);
1181            } else {
1182                let mut device = DeviceState::new(entry.id.clone());
1183                device.apply_list_entry(entry);
1184                self.devices.push(device);
1185                outcome.new_ids.push(entry.id.clone());
1186                outcome.changed = true;
1187            }
1188        }
1189
1190        // Drop any selected ids whose device vanished.
1191        let before_selection = self.selected_device_ids.len();
1192        self.selected_device_ids
1193            .retain(|id| self.devices.iter().any(|d| &d.id == id));
1194        if self.selected_device_ids.len() != before_selection {
1195            outcome.changed = true;
1196        }
1197        // Seed the selection with the first device on a fresh start so the
1198        // detail tabs aren't empty when devices are present.
1199        if self.selected_device_ids.is_empty() {
1200            if let Some(first) = self.devices.first() {
1201                self.selected_device_ids.push(first.id.clone());
1202                outcome.changed = true;
1203            }
1204        }
1205
1206        outcome
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213
1214    #[test]
1215    fn device_config_parses_full_nested_response() {
1216        let json = r#"{
1217            "wifi": { "mode": "sniffer", "channel": 6, "sta_ssid": "MyNetwork" },
1218            "collection": {
1219                "mode": "collector", "traffic_hz": 100, "unsolicited": true,
1220                "phy_rate": "mcs0-lgi",
1221                "protocol": "n", "io_tx_enabled": true, "io_rx_enabled": true
1222            },
1223            "csi_config": {
1224                "lltf_enabled": true, "htltf_enabled": true,
1225                "stbc_htltf_enabled": true, "ltf_merge_enabled": true,
1226                "csi_he_stbc": 2, "val_scale_cfg": 2,
1227                "acquire_csi": 1, "acquire_csi_legacy": 0
1228            },
1229            "csi_delivery_mode": "async",
1230            "csi_logging_enabled": true
1231        }"#;
1232        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1233        let mut device = DeviceState::new("ttyUSB0");
1234        let applied = device.apply_device_config(cfg);
1235        assert!(applied > 0);
1236        assert_eq!(device.forms.wifi.mode, WiFiMode::Sniffer);
1237        assert_eq!(device.forms.wifi.channel, "6");
1238        assert_eq!(device.forms.traffic.frequency_hz, "100");
1239        assert!(device.forms.traffic.unsolicited);
1240        assert!(device.forms.csi.csi);
1241        assert!(!device.forms.csi.csi_legacy);
1242        assert_eq!(device.forms.protocol, WifiProtocol::N);
1243    }
1244
1245    #[test]
1246    fn device_config_tolerates_null_sub_objects() {
1247        let json = r#"{ "wifi": null, "collection": null, "csi_config": null }"#;
1248        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse null subobjects");
1249        let mut device = DeviceState::new("ttyUSB0");
1250        assert_eq!(device.apply_device_config(cfg), 0);
1251    }
1252
1253    #[test]
1254    fn device_config_tolerates_missing_sub_objects() {
1255        let cfg: DeviceConfig = serde_json::from_str("{}").expect("parse empty");
1256        let mut device = DeviceState::new("ttyUSB0");
1257        assert_eq!(device.apply_device_config(cfg), 0);
1258    }
1259
1260    #[test]
1261    fn wifi_mode_parses_v07_values() {
1262        assert_eq!(
1263            WiFiMode::from_api_value("wifi-ap"),
1264            Some(WiFiMode::WifiAp)
1265        );
1266        assert!(WiFiMode::EspNowFastCollector.is_esp_now());
1267        assert!(WiFiMode::WifiAp.requires_v07());
1268    }
1269
1270    #[test]
1271    fn wifi_mode_station_disallows_channel() {
1272        assert!(!WiFiMode::Station.allows_channel());
1273        assert!(WiFiMode::Sniffer.allows_channel());
1274        assert!(WiFiMode::WifiAp.allows_channel());
1275        assert!(WiFiMode::EspNowCentral.allows_channel());
1276    }
1277
1278    #[test]
1279    fn device_config_applies_ap_fields() {
1280        let json = r#"{
1281            "wifi": {
1282                "mode": "wifi-ap",
1283                "channel": 6,
1284                "ap_ssid": "lab-ap",
1285                "ap_dhcp": false
1286            }
1287        }"#;
1288        let cfg: DeviceConfig = serde_json::from_str(json).expect("parse");
1289        let mut device = DeviceState::new("D0CF13E290E8");
1290        device.apply_device_config(cfg);
1291        assert_eq!(device.forms.wifi.mode, WiFiMode::WifiAp);
1292        assert_eq!(device.forms.wifi.ap_ssid, "lab-ap");
1293        assert!(!device.forms.wifi.ap_dhcp);
1294    }
1295
1296    #[test]
1297    fn firmware_version_gating() {
1298        let info = DeviceInfo {
1299            version: Some("0.7.0".to_owned()),
1300            ..Default::default()
1301        };
1302        assert!(info.supports_v07_modes());
1303        let old = DeviceInfo {
1304            version: Some("0.6.0".to_owned()),
1305            ..Default::default()
1306        };
1307        assert!(!old.supports_v07_modes());
1308
1309        // `--unsolicited` needs ≥ 0.7.2: 0.7.1 and older firmware reject the
1310        // whole set-traffic command on the unknown flag.
1311        let v071 = DeviceInfo {
1312            version: Some("0.7.1".to_owned()),
1313            ..Default::default()
1314        };
1315        assert!(!v071.supports_unsolicited());
1316        let v072 = DeviceInfo {
1317            version: Some("0.7.2".to_owned()),
1318            ..Default::default()
1319        };
1320        assert!(v072.supports_unsolicited());
1321    }
1322
1323    #[test]
1324    fn device_list_parses_mac_field() {
1325        let json = r#"[
1326            {
1327                "id": "D0-CF-13-E2-90-E8",
1328                "mac": "D0:CF:13:E2:90:E8",
1329                "port_path": "/dev/ttyACM0",
1330                "serial_connected": true,
1331                "firmware_verified": true
1332            }
1333        ]"#;
1334        let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1335        let mut state = AppState::with_defaults();
1336        state.reconcile_devices(entries);
1337        assert_eq!(state.devices[0].id, "D0-CF-13-E2-90-E8");
1338        assert_eq!(
1339            state.devices[0].mac.as_deref(),
1340            Some("D0:CF:13:E2:90:E8")
1341        );
1342    }
1343
1344    #[test]
1345    fn device_list_parses_and_reconciles() {
1346        let json = r#"[
1347            {
1348                "id": "ttyUSB0", "port_path": "/dev/ttyUSB0", "baud_rate": 115200,
1349                "serial_connected": true, "collection_running": false,
1350                "firmware_verified": true,
1351                "device_info": { "name": "esp-csi-cli-rs", "chip": "esp32c6", "protocol": 1 },
1352                "fault": "USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — replug"
1353            }
1354        ]"#;
1355        let entries: Vec<DeviceListEntry> = serde_json::from_str(json).expect("parse list");
1356        let mut state = AppState::with_defaults();
1357        let outcome = state.reconcile_devices(entries);
1358        assert!(outcome.changed);
1359        assert_eq!(outcome.new_ids, vec!["ttyUSB0".to_owned()]);
1360        assert_eq!(state.devices.len(), 1);
1361        assert_eq!(state.selected_device_ids, vec!["ttyUSB0".to_owned()]);
1362        let device = &state.devices[0];
1363        assert_eq!(device.serial_connected, Some(true));
1364        assert_eq!(device.latest_info.as_ref().unwrap().chip.as_deref(), Some("esp32c6"));
1365        assert!(device.fault.as_deref().unwrap().contains("USB-JTAG reset loop"));
1366    }
1367
1368    #[test]
1369    fn reconcile_drops_vanished_and_reselects() {
1370        let mut state = AppState::with_defaults();
1371        state.reconcile_devices(vec![
1372            DeviceListEntry { id: "a".to_owned(), ..Default::default() },
1373            DeviceListEntry { id: "b".to_owned(), ..Default::default() },
1374        ]);
1375        assert_eq!(state.selected_device_ids, vec!["a".to_owned()]);
1376
1377        // Device "a" unplugged; selection should fall back to "b".
1378        let outcome = state.reconcile_devices(vec![DeviceListEntry {
1379            id: "b".to_owned(),
1380            ..Default::default()
1381        }]);
1382        assert!(outcome.changed);
1383        assert_eq!(outcome.removed_ids, vec!["a".to_owned()]);
1384        assert_eq!(state.devices.len(), 1);
1385        assert_eq!(state.selected_device_ids, vec!["b".to_owned()]);
1386    }
1387}