1use 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
15pub const ESPRESSIF_NATIVE_USB_VID: u16 = 0x303A;
18
19const ESP_USB_VIDS: &[u16] = &[
21 0x10C4, 0x1A86, ESPRESSIF_NATIVE_USB_VID, ];
25
26#[derive(Debug, Clone)]
28pub struct PortInfo {
29 pub port_path: String,
30 pub native_usb: bool,
31 pub mac: Option<String>,
32}
33
34pub 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
53pub 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#[derive(Default)]
66pub struct RecoveryCarryOver {
67 pub recovery_cycles: u32,
68 pub fault: Option<String>,
69}
70
71impl RecoveryCarryOver {
72 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
81pub 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
223pub 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(®istry, 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(®istry, 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}