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}