Skip to main content

csi_webserver_core/
supervisor.rs

1//! USB hotplug supervisor and port discovery.
2//!
3//! Polls attached ESP32 serial ports and registers or removes devices in the
4//! shared [`DeviceRegistry`](crate::state::DeviceRegistry).
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8use std::sync::atomic::Ordering;
9
10use tokio::time::{Duration, sleep};
11use tokio_serial::SerialPortType;
12
13use crate::state::{DeviceAttachSpec, DeviceHandle, DeviceRegistry};
14
15/// Espressif's USB vendor id, used by the built-in USB-Serial-JTAG controller
16/// on ESP32-S3 / C3 / C6.
17pub const ESPRESSIF_NATIVE_USB_VID: u16 = 0x303A;
18
19/// Known ESP32 USB-UART adapter Vendor IDs.
20const ESP_USB_VIDS: &[u16] = &[
21    0x10C4,                    // Silicon Labs CP210x
22    0x1A86,                    // WCH CH340 / CH341
23    ESPRESSIF_NATIVE_USB_VID, // Espressif built-in USB
24];
25
26/// Metadata about a serial port, read from USB enumeration without opening it.
27#[derive(Debug, Clone)]
28pub struct PortInfo {
29    pub port_path: String,
30    pub native_usb: bool,
31    pub mac: Option<String>,
32}
33
34/// Probe one port path against the current USB enumeration.
35pub fn probe_port(port_path: &str) -> Option<PortInfo> {
36    let all_ports = tokio_serial::available_ports().ok()?;
37    let info = all_ports.iter().find(|p| p.port_name == port_path)?;
38    let native_usb = matches!(
39        info.port_type,
40        SerialPortType::UsbPort(ref usb) if usb.vid == ESPRESSIF_NATIVE_USB_VID
41    );
42    let mac = match &info.port_type {
43        SerialPortType::UsbPort(usb) => usb.serial_number.clone(),
44        _ => None,
45    };
46    Some(PortInfo {
47        port_path: port_path.to_string(),
48        native_usb,
49        mac,
50    })
51}
52
53/// Configuration for the hotplug supervisor loop.
54pub struct SupervisorConfig {
55    pub registry: Arc<DeviceRegistry>,
56    pub baud_rate: u32,
57    pub scan_interval: Duration,
58    pub aliases: Vec<(String, String)>,
59}
60
61/// Recovery state carried across a device-handle respawn. Re-enumeration to a
62/// new port path tears the handle down and spawns a fresh one — but the
63/// destructive recovery rungs themselves cause that re-enumeration, so their
64/// once-per-device caps and any declared fault must survive the respawn.
65#[derive(Default)]
66pub struct RecoveryCarryOver {
67    pub recovery_cycles: u32,
68    pub fault: Option<String>,
69}
70
71impl RecoveryCarryOver {
72    /// Snapshot the carry-over state from a handle about to be torn down.
73    pub async fn from_handle(dev: &DeviceHandle) -> Self {
74        Self {
75            recovery_cycles: dev.recovery_cycles.load(Ordering::SeqCst),
76            fault: dev.fault.lock().await.clone(),
77        }
78    }
79}
80
81/// Detect *all* available ESP32 USB serial port paths, sorted so device-id
82/// assignment is deterministic across scans.
83pub fn detect_esp_ports() -> Vec<String> {
84    if let Ok(port) = std::env::var("CSI_SERIAL_PORT") {
85        tracing::debug!("Using CSI_SERIAL_PORT override: {port}");
86        return vec![port];
87    }
88
89    let ports = match tokio_serial::available_ports() {
90        Ok(p) => p,
91        Err(e) => {
92            tracing::warn!("Failed to enumerate serial ports: {e}");
93            return Vec::new();
94        }
95    };
96
97    let mut matched: Vec<String> = Vec::new();
98    for port in &ports {
99        if let SerialPortType::UsbPort(ref info) = port.port_type {
100            let name_ok = port.port_name.contains("usbserial")
101                || port.port_name.contains("usbmodem")
102                || port.port_name.contains("ttyUSB")
103                || port.port_name.contains("ttyACM");
104
105            let vid_ok = ESP_USB_VIDS.contains(&info.vid);
106
107            if name_ok || vid_ok {
108                matched.push(port.port_name.clone());
109            }
110        }
111    }
112
113    if matched.is_empty() {
114        let usb: Vec<&tokio_serial::SerialPortInfo> = ports
115            .iter()
116            .filter(|p| matches!(p.port_type, SerialPortType::UsbPort(_)))
117            .collect();
118        if usb.len() == 1 {
119            tracing::warn!(
120                "No known ESP port found — using the only USB port: {}",
121                usb[0].port_name
122            );
123            matched.push(usb[0].port_name.clone());
124        }
125    }
126
127    matched.sort();
128    matched
129}
130
131fn sanitize_id(s: &str) -> String {
132    s.chars()
133        .map(|c| {
134            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
135                c
136            } else {
137                '-'
138            }
139        })
140        .collect()
141}
142
143fn device_id(port_path: &str, mac: Option<&str>, aliases: &[(String, String)]) -> String {
144    for (alias, key) in aliases {
145        if key == port_path || Some(key.as_str()) == mac {
146            return alias.clone();
147        }
148    }
149    if let Some(mac) = mac {
150        return sanitize_id(mac);
151    }
152    sanitize_id(port_path.rsplit('/').next().unwrap_or(port_path))
153}
154
155struct PortCandidate {
156    id: String,
157    path: String,
158    native_usb: bool,
159    mac: Option<String>,
160}
161
162fn scan_ports(aliases: &[(String, String)]) -> (Vec<PortCandidate>, HashSet<String>) {
163    let detected = detect_esp_ports();
164    let all_ports = tokio_serial::available_ports().unwrap_or_default();
165    let existing: HashSet<String> = all_ports.iter().map(|p| p.port_name.clone()).collect();
166
167    let is_native = |path: &str| {
168        all_ports.iter().any(|p| {
169            p.port_name == path
170                && matches!(
171                    p.port_type,
172                    SerialPortType::UsbPort(ref info) if info.vid == ESPRESSIF_NATIVE_USB_VID
173                )
174        })
175    };
176
177    let mac_of = |path: &str| -> Option<String> {
178        all_ports.iter().find_map(|p| match &p.port_type {
179            SerialPortType::UsbPort(info) if p.port_name == path => info.serial_number.clone(),
180            _ => None,
181        })
182    };
183
184    let mut candidates: Vec<PortCandidate> = detected
185        .into_iter()
186        .map(|path| {
187            let mac = mac_of(&path);
188            PortCandidate {
189                id: device_id(&path, mac.as_deref(), aliases),
190                native_usb: is_native(&path),
191                mac,
192                path,
193            }
194        })
195        .collect();
196
197    for (alias, path) in aliases {
198        if existing.contains(path) && !candidates.iter().any(|c| &c.path == path) {
199            candidates.push(PortCandidate {
200                id: alias.clone(),
201                native_usb: is_native(path),
202                mac: mac_of(path),
203                path: path.clone(),
204            });
205        }
206    }
207
208    (candidates, existing)
209}
210
211fn attach_candidate(registry: &DeviceRegistry, c: &PortCandidate, baud: u32, carry: RecoveryCarryOver) {
212    registry.attach(DeviceAttachSpec {
213        id: c.id.clone(),
214        port_path: c.path.clone(),
215        baud_rate: baud,
216        native_usb: c.native_usb,
217        mac: c.mac.clone(),
218        recovery_cycles: carry.recovery_cycles,
219        fault: carry.fault,
220    });
221}
222
223/// Hotplug supervisor: the single authority on which devices exist.
224pub async fn run_supervisor(config: SupervisorConfig) {
225    const DEBOUNCE: u32 = 3;
226
227    let SupervisorConfig {
228        registry,
229        baud_rate,
230        scan_interval,
231        aliases,
232    } = config;
233
234    let mut missing: HashMap<String, u32> = HashMap::new();
235
236    loop {
237        let aliases_scan = aliases.clone();
238        let (candidates, _existing) =
239            match tokio::task::spawn_blocking(move || scan_ports(&aliases_scan)).await {
240                Ok(scan) => scan,
241                Err(e) => {
242                    tracing::error!("Port enumeration task failed: {e}");
243                    sleep(scan_interval).await;
244                    continue;
245                }
246            };
247
248        let present_ids: HashSet<&str> = candidates.iter().map(|c| c.id.as_str()).collect();
249
250        for c in &candidates {
251            missing.remove(&c.id);
252            match registry.get(&c.id) {
253                None => {
254                    tracing::info!("Device added: {} ({})", c.id, c.path);
255                    attach_candidate(&registry, c, baud_rate, RecoveryCarryOver::default());
256                }
257                Some(dev) if dev.port_path != c.path => {
258                    tracing::info!(
259                        "Device {} re-enumerated: {} → {} (following by MAC)",
260                        c.id,
261                        dev.port_path,
262                        c.path,
263                    );
264                    let carry = RecoveryCarryOver::from_handle(&dev).await;
265                    dev.shutdown.cancel();
266                    attach_candidate(&registry, c, baud_rate, carry);
267                }
268                Some(_) => {}
269            }
270        }
271
272        for dev in registry.snapshot() {
273            if present_ids.contains(dev.id.as_str()) {
274                missing.remove(&dev.id);
275                continue;
276            }
277            let count = missing.entry(dev.id.clone()).or_insert(0);
278            *count += 1;
279            if *count >= DEBOUNCE {
280                tracing::info!("Device removed: {} ({})", dev.id, dev.port_path);
281                registry.detach(&dev.id);
282                missing.remove(&dev.id);
283            }
284        }
285
286        sleep(scan_interval).await;
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn sanitize_id_replaces_slashes() {
296        assert_eq!(sanitize_id("/dev/ttyUSB0"), "-dev-ttyUSB0");
297        assert_eq!(sanitize_id("D0:CF:13:E2:90:E8"), "D0-CF-13-E2-90-E8");
298    }
299
300    #[test]
301    fn device_id_prefers_mac_over_path() {
302        let id = device_id("/dev/ttyACM0", Some("AA:BB:CC:DD:EE:FF"), &[]);
303        assert_eq!(id, "AA-BB-CC-DD-EE-FF");
304    }
305
306    #[test]
307    fn device_id_honours_alias_by_mac() {
308        let aliases = vec![("lab1".into(), "AA:BB:CC:DD:EE:FF".into())];
309        let id = device_id("/dev/ttyACM0", Some("AA:BB:CC:DD:EE:FF"), &aliases);
310        assert_eq!(id, "lab1");
311    }
312}