Skip to main content

csi_webserver_core/
state.rs

1//! Shared application state used by Axum route handlers.
2//!
3//! Each attached ESP32 is represented by a [`DeviceHandle`] that owns the
4//! channels and runtime flags coordinating route requests with that device's
5//! long-running serial background task. [`AppState`] is a thin registry mapping
6//! a stable device id to its handle; the device set is mutated at runtime by
7//! the hotplug supervisor (see [`crate::supervisor::run_supervisor`]) or via
8//! [`DeviceRegistry::attach`].
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::sync::RwLock;
13use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
14use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch};
15use tokio_util::sync::CancellationToken;
16
17use crate::models::{DeviceConfig, DeviceInfo, OutputMode};
18use crate::profile::{CsiProfile, StandardCsiProfile};
19use crate::serial;
20
21/// Specification for attaching one ESP32 device to the registry.
22#[derive(Debug, Clone)]
23pub struct DeviceAttachSpec {
24    pub id: String,
25    pub port_path: String,
26    pub baud_rate: u32,
27    pub native_usb: bool,
28    pub mac: Option<String>,
29    /// Carried across handle respawns when a native-USB board re-enumerates.
30    pub recovery_cycles: u32,
31    /// Declared fault from a prior connection attempt, if any.
32    pub fault: Option<String>,
33}
34
35impl Default for DeviceAttachSpec {
36    fn default() -> Self {
37        Self {
38            id: String::new(),
39            port_path: String::new(),
40            baud_rate: 115_200,
41            native_usb: false,
42            mac: None,
43            recovery_cycles: 0,
44            fault: None,
45        }
46    }
47}
48
49/// One-shot reply channel for an in-flight `info` exchange.
50pub type InfoResponder = oneshot::Sender<Result<DeviceInfo, String>>;
51
52/// All per-device runtime state for one attached ESP32.
53///
54/// Wrapped in an `Arc` and shared between the device's serial task and every
55/// route handler that resolves it from the registry. The inner atomics,
56/// mutexes, and watch senders are plain (not individually `Arc`-wrapped): the
57/// single `Arc<DeviceHandle>` provides the sharing.
58pub struct DeviceHandle {
59    /// Stable identifier used in URL paths. Derived from the board's MAC
60    /// (`D0-CF-13-E2-90-E8`) when known, so it survives a `ttyACMx`
61    /// renumbering; falls back to a CLI alias or the sanitized port basename.
62    pub id: String,
63    /// Stable hardware identity: the board's MAC as reported by its USB
64    /// `iSerialNumber` descriptor (`AA:BB:CC:DD:EE:FF`), read at scan time
65    /// without opening the port. `None` for adapters that expose no serial
66    /// number (most CP210x/CH340 UART bridges). This is what lets the
67    /// supervisor follow a board across a re-enumeration that changes its
68    /// `/dev/ttyACMx` number — see [`crate::supervisor::run_supervisor`]. The
69    /// firmware also echoes it in the `info` block (`mac=`) as confirmation.
70    pub mac: Option<String>,
71    /// USB serial port path used to reach the ESP32 (e.g. `/dev/ttyUSB0`).
72    /// Pinned for the lifetime of the per-device task; if the board
73    /// re-enumerates under a different node the supervisor tears this task
74    /// down and respawns it (keyed by [`Self::mac`]) at the new path.
75    pub port_path: String,
76    /// Baud rate negotiated at startup. The serial task and the RTS-reset
77    /// handler both read this so a single source of truth governs the link.
78    pub baud_rate: u32,
79    /// True when this port is an Espressif native USB-Serial-JTAG endpoint
80    /// (VID `0x303A`). Such chips re-enumerate their USB device when reset, so
81    /// the serial task must NOT pulse RTS/DTR on connect — doing so drops the
82    /// `/dev/ttyACMx` node (often returning under a different number) and the
83    /// pinned-path reconnect loop can then never re-verify the device.
84    pub native_usb: bool,
85    /// Whether the serial task currently has an open and healthy ESP32 link.
86    pub serial_connected: AtomicBool,
87    /// Best-effort flag: true after successful `start`, false after reset/disconnect.
88    pub collection_running: AtomicBool,
89    /// `true` once the serial task (or an explicit `/api/.../info` call) has
90    /// observed a valid `ESP-CSI-CLI/<version>` magic block from the device.
91    /// Cleared on disconnect, on `reset` (until the post-reset re-verification
92    /// completes), and on a failed verification. Command endpoints refuse to
93    /// send while this is `false`.
94    pub firmware_verified: AtomicBool,
95    /// Send CLI command strings to the serial background task.
96    pub cmd_tx: mpsc::Sender<String>,
97    /// Broadcast raw CSI frame bytes (COBS-framed postcard) to this device's
98    /// WebSocket clients.
99    pub csi_tx: broadcast::Sender<Vec<u8>>,
100    /// Notify the serial task of output-mode changes (stream / dump / both).
101    pub output_mode_tx: watch::Sender<OutputMode>,
102    /// Signal the serial task of the current session's dump file path.
103    /// `Some(path)` → open/reuse that file; `None` → session ended, close file.
104    pub session_file_tx: watch::Sender<Option<String>>,
105    /// Issue an `info` command on the device and capture the magic block.
106    /// The serial task synchronously consumes the responder.
107    pub info_request_tx: mpsc::Sender<InfoResponder>,
108    /// Cached view of this device's configuration.
109    pub config: Mutex<DeviceConfig>,
110    /// Last successfully parsed firmware identification block. `None` until
111    /// the first verification succeeds; cleared alongside `firmware_verified`.
112    pub device_info: Mutex<Option<DeviceInfo>>,
113    /// Detected chip fault (e.g. the ESP32-C5/C6 USB-JTAG reset-loop wedge or
114    /// a ROM boot loop), with the recovery action, set by the serial task when
115    /// firmware verification keeps failing with a recognizable boot signature.
116    /// Cleared on successful verification. Surfaced via `GET /api/devices`.
117    pub fault: Mutex<Option<String>>,
118    /// Completed recovery attempts (the firmware `restart` rung, whose reboot
119    /// re-enumerates native USB and thus restarts the per-connection
120    /// escalation ladder). Carried across handle respawns (re-enumeration
121    /// replaces the handle!) via `RecoveryCarryOver` so an unrecoverable
122    /// device is restarted at most once and then declared faulted, instead
123    /// of being bounced through reboots forever. Cleared on successful
124    /// verification.
125    pub recovery_cycles: AtomicU32,
126    /// Cancelled by the supervisor when the device is unplugged, signalling the
127    /// serial task to tear down cleanly.
128    pub shutdown: CancellationToken,
129    /// Optional capability extensions (extra protocols/presets/format labels).
130    /// Inherited from the owning [`DeviceRegistry`]; the per-device serial task
131    /// consults it when labelling Parquet `data_format`.
132    pub profile: Arc<dyn CsiProfile>,
133}
134
135impl DeviceHandle {
136    /// Returns an early-return tuple suitable for handlers when the firmware
137    /// has not yet been verified as `esp-csi-cli-rs`. Use this to short-circuit
138    /// any endpoint that issues a CLI command — sending commands to an
139    /// unverified device may interact with whatever bootloader/firmware is
140    /// listening in unintended ways.
141    pub fn require_firmware(
142        &self,
143    ) -> Option<(axum::http::StatusCode, axum::Json<crate::models::ApiResponse>)> {
144        if self.firmware_verified.load(Ordering::SeqCst) {
145            None
146        } else {
147            Some((
148                axum::http::StatusCode::PRECONDITION_FAILED,
149                axum::Json(crate::models::ApiResponse {
150                    success: false,
151                    message:
152                        "Firmware not verified as esp-csi-cli-rs. Call GET .../info to verify, \
153                         or POST .../control/reset to power-cycle and re-check."
154                            .to_string(),
155                }),
156            ))
157        }
158    }
159}
160
161/// Runtime registry of attached devices, keyed by [`DeviceHandle::id`].
162///
163/// Uses a `std::sync::RwLock`: every critical section clones an `Arc` out and
164/// drops the guard immediately — the lock is never held across an `.await`, so
165/// an async lock would only add cost. The supervisor is the only writer.
166pub struct DeviceRegistry {
167    map: RwLock<HashMap<String, Arc<DeviceHandle>>>,
168    /// Capability profile handed to every device spawned by this registry.
169    pub profile: Arc<dyn CsiProfile>,
170}
171
172impl Default for DeviceRegistry {
173    fn default() -> Self {
174        Self {
175            map: RwLock::new(HashMap::new()),
176            profile: Arc::new(StandardCsiProfile),
177        }
178    }
179}
180
181impl DeviceRegistry {
182    /// A registry whose spawned devices carry the given capability profile.
183    pub fn with_profile(profile: Arc<dyn CsiProfile>) -> Self {
184        Self {
185            map: RwLock::new(HashMap::new()),
186            profile,
187        }
188    }
189
190    /// Look up a device by id, returning an owned `Arc` clone.
191    pub fn get(&self, id: &str) -> Option<Arc<DeviceHandle>> {
192        self.map.read().unwrap().get(id).cloned()
193    }
194
195    /// Insert a newly discovered device. Returns the previous handle for this
196    /// id, if any.
197    pub fn insert(&self, dev: Arc<DeviceHandle>) -> Option<Arc<DeviceHandle>> {
198        self.map.write().unwrap().insert(dev.id.clone(), dev)
199    }
200
201    /// Remove a device by id, returning its handle if present.
202    pub fn remove(&self, id: &str) -> Option<Arc<DeviceHandle>> {
203        self.map.write().unwrap().remove(id)
204    }
205
206    /// Snapshot of all current devices, sorted by id for stable listings.
207    pub fn snapshot(&self) -> Vec<Arc<DeviceHandle>> {
208        let mut devices: Vec<Arc<DeviceHandle>> = self.map.read().unwrap().values().cloned().collect();
209        devices.sort_by(|a, b| a.id.cmp(&b.id));
210        devices
211    }
212
213    /// Spawn a serial worker for `spec` and register it. Returns the new handle.
214    pub fn attach(&self, spec: DeviceAttachSpec) -> Arc<DeviceHandle> {
215        let handle = serial::spawn_device(&spec, self.profile.clone());
216        self.insert(handle.clone());
217        handle
218    }
219
220    /// Remove a device, cancel its serial task, and return its handle.
221    pub fn detach(&self, id: &str) -> Option<Arc<DeviceHandle>> {
222        let handle = self.remove(id)?;
223        handle.shutdown.cancel();
224        Some(handle)
225    }
226}
227
228/// Shared application state, cheaply cloned into every route handler via Axum's
229/// `State` extractor. Holds only the device registry; all per-device state
230/// lives behind [`DeviceRegistry`].
231#[derive(Clone)]
232pub struct AppState {
233    pub devices: Arc<DeviceRegistry>,
234}
235
236impl AppState {
237    /// Empty registry with the default no-op [`StandardCsiProfile`]; populate
238    /// via [`DeviceRegistry::attach`] or [`crate::supervisor::run_supervisor`].
239    pub fn new() -> Self {
240        Self {
241            devices: Arc::new(DeviceRegistry::default()),
242        }
243    }
244
245    /// Empty registry whose devices carry a custom capability [`CsiProfile`].
246    /// An embedder supplies its own profile to extend the accepted protocols,
247    /// CSI presets, and `data_format` labels.
248    pub fn with_profile(profile: Arc<dyn CsiProfile>) -> Self {
249        Self {
250            devices: Arc::new(DeviceRegistry::with_profile(profile)),
251        }
252    }
253
254    /// The capability profile shared by this state's devices.
255    pub fn profile(&self) -> &Arc<dyn CsiProfile> {
256        &self.devices.profile
257    }
258}
259
260impl Default for AppState {
261    fn default() -> Self {
262        Self::new()
263    }
264}