use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::time::{Duration, sleep};
use tokio_serial::{ClearBuffer, SerialPort, SerialPortBuilderExt};
use crate::csi::{self, ChipVariant};
use crate::models::{DeviceConfig, DeviceInfo, OutputMode};
use crate::parquet_sink::ParquetSink;
use crate::profile::CsiProfile;
use crate::state::{DeviceAttachSpec, DeviceHandle, InfoResponder};
#[derive(Debug)]
enum InfoExchangeError {
Soft(String),
Hard(String),
BootFault(String),
}
impl InfoExchangeError {
fn message(&self) -> &str {
match self {
Self::Soft(m) | Self::Hard(m) | Self::BootFault(m) => m,
}
}
}
fn detect_boot_fault(rx: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(rx);
let usb_wedge_resets =
text.matches("USB_UART_HPSYS").count() + text.matches("USB_UART_CHIP_RESET").count();
if usb_wedge_resets >= 2 {
return Some(
"USB-JTAG reset loop (rst:0x15 USB_UART_HPSYS) — known ESP32-C5/C6 post-flash \
wedge; software reset cannot recover it. Power-cycle the USB port: replug the \
board, or `uhubctl -a cycle` on a power-switchable hub."
.to_string(),
);
}
if usb_wedge_resets == 1 && !text.contains("ESP-CSI-CLI") {
return Some(
"chip halted after a USB-JTAG-triggered reset (rst:0x15 USB_UART_HPSYS) — known \
ESP32-C5/C6 wedge; software reset cannot recover it. Power-cycle the USB port \
(replug, or `uhubctl -a cycle`); if it persists across a power cycle, reflash \
the firmware."
.to_string(),
);
}
if text.contains("waiting for download") {
return Some(
"chip is stuck in ROM download mode (waiting for download) — reflash it or \
reset out of download mode"
.to_string(),
);
}
if text.matches("ESP-ROM:").count() >= 3 {
return Some(
"boot loop: ROM banner repeating; firmware never reaches the CLI — reflash a \
known-good image or power-cycle the board"
.to_string(),
);
}
None
}
const INFO_RESPONSE_TIMEOUT: Duration = Duration::from_millis(2000);
const REVERIFY_INTERVAL: Duration = Duration::from_secs(3);
const CSI_BROADCAST_CAPACITY: usize = 1024;
pub fn spawn_device(spec: &DeviceAttachSpec, profile: Arc<dyn CsiProfile>) -> Arc<DeviceHandle> {
let (cmd_tx, cmd_rx) = mpsc::channel::<String>(64);
let (csi_tx, _) = broadcast::channel::<Vec<u8>>(CSI_BROADCAST_CAPACITY);
let (output_mode_tx, output_mode_rx) = watch::channel(OutputMode::default());
let (session_file_tx, session_file_rx) = watch::channel::<Option<String>>(None);
let (info_request_tx, info_request_rx) = mpsc::channel::<InfoResponder>(4);
let dev = Arc::new(DeviceHandle {
id: spec.id.clone(),
mac: spec.mac.clone(),
port_path: spec.port_path.clone(),
baud_rate: spec.baud_rate,
native_usb: spec.native_usb,
serial_connected: AtomicBool::new(false),
collection_running: AtomicBool::new(false),
firmware_verified: AtomicBool::new(false),
cmd_tx,
csi_tx,
output_mode_tx,
session_file_tx,
info_request_tx,
config: tokio::sync::Mutex::new(DeviceConfig::default()),
device_info: tokio::sync::Mutex::new(None),
fault: tokio::sync::Mutex::new(spec.fault.clone()),
recovery_cycles: std::sync::atomic::AtomicU32::new(spec.recovery_cycles),
shutdown: tokio_util::sync::CancellationToken::new(),
profile,
});
tokio::spawn(run_serial_task(
dev.clone(),
cmd_rx,
output_mode_rx,
session_file_rx,
info_request_rx,
));
dev
}
pub async fn run_serial_task(
dev: Arc<DeviceHandle>,
mut cmd_rx: mpsc::Receiver<String>,
mut output_mode_rx: watch::Receiver<OutputMode>,
mut session_file_rx: watch::Receiver<Option<String>>,
mut info_request_rx: mpsc::Receiver<InfoResponder>,
) {
let port_path = dev.port_path.clone();
let baud = dev.baud_rate;
const RECONNECT_DELAY: Duration = Duration::from_millis(800);
loop {
if dev.shutdown.is_cancelled() {
break;
}
let mut stream = match tokio_serial::new(&port_path, baud).open_native_async() {
Ok(s) => s,
Err(e) => {
dev.serial_connected.store(false, Ordering::SeqCst);
dev.collection_running.store(false, Ordering::SeqCst);
tracing::warn!("Failed to open serial port {port_path}: {e}. Retrying...");
tokio::select! {
_ = sleep(RECONNECT_DELAY) => continue,
_ = dev.shutdown.cancelled() => break,
}
}
};
#[cfg(unix)]
{
let _ = stream.set_exclusive(false);
}
if dev.native_usb {
let _ = stream.write_request_to_send(false);
let _ = stream.write_data_terminal_ready(false);
tracing::info!(
"Skipping RTS auto-reset on {port_path} (native USB-Serial-JTAG; reset would re-enumerate the port); RTS/DTR released (RTS first — DTR-first warm-resets the chip)"
);
} else {
let _ = stream.write_data_terminal_ready(false);
if let Err(e) = stream.write_request_to_send(true) {
tracing::warn!("Failed to assert RTS on {port_path}: {e}");
} else {
sleep(Duration::from_millis(100)).await;
if let Err(e) = stream.write_request_to_send(false) {
tracing::warn!("Failed to deassert RTS on {port_path}: {e}");
} else {
tracing::info!("ESP32 reset on connect via RTS ({port_path})");
}
}
}
if let Err(e) = stream.clear(ClearBuffer::All) {
tracing::warn!("Failed to clear stale port buffers on {port_path}: {e}");
}
dev.serial_connected.store(true, Ordering::SeqCst);
tracing::info!("Opened serial port {port_path} @ {baud} baud");
let exit = run_serial_connection(
&dev,
stream,
&mut cmd_rx,
&mut output_mode_rx,
&mut session_file_rx,
&mut info_request_rx,
)
.await;
dev.serial_connected.store(false, Ordering::SeqCst);
dev.collection_running.store(false, Ordering::SeqCst);
dev.firmware_verified.store(false, Ordering::SeqCst);
*dev.device_info.lock().await = None;
match exit {
ConnectionExit::Disconnected => {
tracing::warn!("ESP32 disconnected on {port_path}; waiting for reconnect...");
tokio::select! {
_ = sleep(RECONNECT_DELAY) => {}
_ = dev.shutdown.cancelled() => break,
}
}
ConnectionExit::CommandChannelClosed => {
tracing::info!("Command channel closed — shutting down serial task ({port_path})");
break;
}
ConnectionExit::Shutdown => {
tracing::info!("Device unplugged — shutting down serial task ({port_path})");
break;
}
}
}
}
enum ConnectionExit {
Disconnected,
CommandChannelClosed,
Shutdown,
}
struct ChipInfo {
name: String,
variant: ChipVariant,
}
impl ChipInfo {
fn from_info(info: &DeviceInfo) -> Option<Self> {
let name = info.chip.clone()?;
let variant = ChipVariant::from_chip_str(&name)?;
Some(ChipInfo { name, variant })
}
}
async fn run_serial_connection(
dev: &DeviceHandle,
stream: tokio_serial::SerialStream,
cmd_rx: &mut mpsc::Receiver<String>,
output_mode_rx: &mut watch::Receiver<OutputMode>,
session_file_rx: &mut watch::Receiver<Option<String>>,
info_request_rx: &mut mpsc::Receiver<InfoResponder>,
) -> ConnectionExit {
let port_path = dev.port_path.as_str();
let csi_tx = &dev.csi_tx;
let collection_running = &dev.collection_running;
let firmware_verified = &dev.firmware_verified;
let device_info = &dev.device_info;
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut buf = Vec::new();
let mut chip: Option<ChipInfo> = None;
sleep(Duration::from_millis(700)).await;
let boot_context = if dev.native_usb {
quiesce_stale_stream(&mut writer, &mut reader, port_path).await
} else {
Vec::new()
};
match do_info_exchange(&mut writer, &mut reader, &boot_context).await {
Ok(info) => {
tracing::info!(
"Firmware verified: esp-csi-cli-rs/{} ({})",
info.banner_version,
info.chip.as_deref().unwrap_or("unknown chip"),
);
chip = ChipInfo::from_info(&info);
firmware_verified.store(true, Ordering::SeqCst);
*device_info.lock().await = Some(info);
*dev.fault.lock().await = None;
dev.recovery_cycles.store(0, Ordering::SeqCst);
}
Err(e) => {
tracing::warn!(
"Firmware not verified on {port_path}: {}. Command endpoints will return 412 Precondition Failed until verification succeeds.",
e.message(),
);
firmware_verified.store(false, Ordering::SeqCst);
*device_info.lock().await = None;
if let InfoExchangeError::BootFault(fault) = &e {
tracing::error!("Chip fault detected on {port_path}: {fault}");
*dev.fault.lock().await = Some(fault.clone());
}
if matches!(e, InfoExchangeError::Hard(_)) {
return ConnectionExit::Disconnected;
}
}
}
let mut current_mode = output_mode_rx.borrow().clone();
let mut current_session_path = session_file_rx.borrow().clone();
let mut drop_next_chunk = true;
let mut sink: Option<ParquetSink> = None;
let mut decode_errors: u64 = 0;
sync_parquet_sink(¤t_mode, ¤t_session_path, chip.as_ref(), &mut sink, &dev.profile);
let mut frames_in: u64 = 0;
let mut frames_broadcast: u64 = 0;
let mut metrics = tokio::time::interval(Duration::from_secs(1));
metrics.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
metrics.tick().await;
let mut reverify = tokio::time::interval(REVERIFY_INTERVAL);
reverify.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
reverify.tick().await;
let mut verify_failures: u32 = 0;
let mut restart_sent = false;
let carried_cycles = dev.recovery_cycles.load(Ordering::SeqCst);
let mut hw_reset_done = false;
loop {
let mode_changed = output_mode_rx.has_changed().unwrap_or(false);
let session_changed = session_file_rx.has_changed().unwrap_or(false);
if mode_changed {
current_mode = output_mode_rx.borrow_and_update().clone();
}
if session_changed {
match session_file_rx.borrow_and_update().clone() {
Some(path) => current_session_path = Some(path),
None => {
sink = None;
current_session_path = None;
tracing::info!("Session ended — Parquet file finalized");
}
}
}
if mode_changed || session_changed {
sync_parquet_sink(¤t_mode, ¤t_session_path, chip.as_ref(), &mut sink, &dev.profile);
}
const DELIMITER: u8 = b'\0';
tokio::select! {
_ = metrics.tick() => {
if collection_running.load(Ordering::SeqCst) {
tracing::debug!(
target: "csi_metrics",
"{port_path}: serial_in={frames_in}/s broadcast_out={frames_broadcast}/s ws_clients={}",
csi_tx.receiver_count(),
);
}
frames_in = 0;
frames_broadcast = 0;
}
_ = dev.shutdown.cancelled() => {
return ConnectionExit::Shutdown;
}
_ = reverify.tick(), if !firmware_verified.load(Ordering::SeqCst)
&& !collection_running.load(Ordering::SeqCst) =>
{
drop_next_chunk = true;
buf.clear();
let boot_context = if dev.native_usb {
quiesce_stale_stream(&mut writer, &mut reader, port_path).await
} else {
Vec::new()
};
let mut verify_failed = false;
match do_info_exchange(&mut writer, &mut reader, &boot_context).await {
Ok(info) => {
tracing::info!(
"Firmware verified on retry: esp-csi-cli-rs/{} ({})",
info.banner_version,
info.chip.as_deref().unwrap_or("unknown chip"),
);
chip = ChipInfo::from_info(&info);
firmware_verified.store(true, Ordering::SeqCst);
*device_info.lock().await = Some(info);
*dev.fault.lock().await = None;
dev.recovery_cycles.store(0, Ordering::SeqCst);
verify_failures = 0;
}
Err(InfoExchangeError::Soft(msg)) => {
tracing::debug!("Re-verify on {port_path} still failing: {msg}");
verify_failed = true;
}
Err(InfoExchangeError::BootFault(fault)) => {
let mut slot = dev.fault.lock().await;
if slot.as_deref() != Some(fault.as_str()) {
tracing::error!("Chip fault detected on {port_path}: {fault}");
*slot = Some(fault);
}
verify_failed = true;
}
Err(InfoExchangeError::Hard(msg)) => {
tracing::warn!("Serial link error during re-verify on {port_path}: {msg}");
return ConnectionExit::Disconnected;
}
}
if verify_failed {
verify_failures += 1;
if carried_cycles >= 1 && verify_failures >= 2 {
declare_terminal_fault(dev, port_path).await;
} else if verify_failures >= 2 && !restart_sent {
restart_sent = true;
dev.recovery_cycles.fetch_add(1, Ordering::SeqCst);
tracing::warn!(
"Device on {port_path} unverified after {verify_failures} attempts — sending firmware `restart`"
);
let _ = tokio::time::timeout(
Duration::from_secs(2),
writer.write_all(b"\r\nrestart\r\n"),
)
.await;
} else if verify_failures >= 4 && dev.native_usb && !hw_reset_done {
hw_reset_done = true;
tracing::warn!(
"Device on {port_path} still unverified — pulsing USB-JTAG DTR/RTS reset (best-effort)"
);
match usb_jtag_reset_pulse(port_path, dev.baud_rate).await {
Ok(()) => tracing::info!(
"USB-JTAG reset pulse sent on {port_path}; if the chip honors it, the port re-enumerates and re-verifies"
),
Err(e) => tracing::warn!(
"USB-JTAG reset pulse failed on {port_path}: {e}"
),
}
} else if verify_failures >= 6 && restart_sent {
declare_terminal_fault(dev, port_path).await;
}
}
}
result = reader.read_until(DELIMITER, &mut buf) => {
match result {
Ok(0) => {
tracing::warn!("Serial port {port_path} closed (EOF)");
return ConnectionExit::Disconnected;
}
Ok(_) => {
if drop_next_chunk {
drop_next_chunk = false;
buf.clear();
continue;
}
if buf.last() == Some(&DELIMITER) {
buf.pop();
}
let still_collecting = collection_running.load(Ordering::SeqCst);
if still_collecting && !buf.is_empty() {
frames_in += 1;
if matches!(current_mode, OutputMode::Dump | OutputMode::Both) {
if let (Some(sink), Some(chip)) = (sink.as_mut(), chip.as_ref()) {
match csi::decode(&buf, chip.variant) {
Ok(decoded) => {
let host_rx = chrono::Utc::now().timestamp_micros();
if let Err(e) = sink.push(decoded, host_rx) {
tracing::error!("Parquet write error: {e}");
}
}
Err(e) => {
decode_errors += 1;
if decode_errors <= 3 {
let hex: String = buf
.iter()
.map(|b| format!("{b:02x}"))
.collect();
tracing::warn!(
"Decode error #{decode_errors} on {port_path}: {e}; cobs_len={} frame_hex={hex}",
buf.len(),
);
} else if decode_errors.is_power_of_two() {
tracing::warn!(
"Failed to decode CSI frame on {port_path} ({} total); check firmware/chip wire compatibility",
decode_errors,
);
}
}
}
}
}
if matches!(current_mode, OutputMode::Stream | OutputMode::Both)
&& csi_tx.send(buf.clone()).is_ok()
{
frames_broadcast += 1;
}
}
buf.clear();
}
Err(e) => {
tracing::error!("Serial read error on {port_path}: {e}");
return ConnectionExit::Disconnected;
}
}
}
cmd = cmd_rx.recv() => {
match cmd {
Some(cmd) => {
tracing::debug!("→ ESP32: {cmd}");
drop_next_chunk = true;
let line = format!("{cmd}\r\n");
if let Err(e) = writer.write_all(line.as_bytes()).await {
tracing::error!("Serial write error: {e}");
return ConnectionExit::Disconnected;
}
}
None => {
return ConnectionExit::CommandChannelClosed;
}
}
}
req = info_request_rx.recv() => {
let Some(responder) = req else { continue };
if collection_running.load(Ordering::SeqCst) {
let _ = responder.send(Err(
"collection is running; CLI is locked until stop".to_string(),
));
continue;
}
drop_next_chunk = true;
buf.clear();
match do_info_exchange(&mut writer, &mut reader, &[]).await {
Ok(info) => {
chip = ChipInfo::from_info(&info);
firmware_verified.store(true, Ordering::SeqCst);
*device_info.lock().await = Some(info.clone());
*dev.fault.lock().await = None;
dev.recovery_cycles.store(0, Ordering::SeqCst);
let _ = responder.send(Ok(info));
}
Err(InfoExchangeError::Soft(msg)) => {
firmware_verified.store(false, Ordering::SeqCst);
*device_info.lock().await = None;
let _ = responder.send(Err(msg));
}
Err(InfoExchangeError::BootFault(fault)) => {
firmware_verified.store(false, Ordering::SeqCst);
*device_info.lock().await = None;
let mut slot = dev.fault.lock().await;
if slot.as_deref() != Some(fault.as_str()) {
tracing::error!("Chip fault detected on {port_path}: {fault}");
*slot = Some(fault.clone());
}
drop(slot);
let _ = responder.send(Err(fault));
}
Err(InfoExchangeError::Hard(msg)) => {
firmware_verified.store(false, Ordering::SeqCst);
*device_info.lock().await = None;
let _ = responder.send(Err(msg));
return ConnectionExit::Disconnected;
}
}
}
}
}
}
async fn usb_jtag_reset_pulse(port_path: &str, baud: u32) -> Result<(), String> {
let mut port = tokio_serial::new(port_path, baud)
.open_native_async()
.map_err(|e| format!("open for reset pulse failed: {e}"))?;
#[cfg(unix)]
{
let _ = port.set_exclusive(false);
}
let _ = port.write_data_terminal_ready(false);
port.write_request_to_send(true) .map_err(|e| format!("RTS assert failed: {e}"))?;
let _ = port.write_data_terminal_ready(false); sleep(Duration::from_millis(100)).await;
port.write_request_to_send(false) .map_err(|e| format!("RTS deassert failed: {e}"))?;
Ok(())
}
async fn declare_terminal_fault(dev: &DeviceHandle, port_path: &str) {
let mut slot = dev.fault.lock().await;
if slot.is_none() {
let fault = "unresponsive after every automated recovery step (firmware restart, \
USB-JTAG reset pulse) — the application does not boot. Press the board's \
EN/reset button, replug the cable, or power-cycle the port (e.g. `uhubctl -a \
cycle` on a power-switchable hub)."
.to_string();
tracing::error!("Chip fault declared on {port_path}: {fault}");
*slot = Some(fault);
}
}
async fn quiesce_stale_stream<W, R>(
writer: &mut W,
reader: &mut BufReader<R>,
port_path: &str,
) -> Vec<u8>
where
W: AsyncWrite + Unpin,
R: AsyncRead + Unpin,
{
const RETAIN_CAP: usize = 8 * 1024;
match tokio::time::timeout(Duration::from_secs(2), writer.write_all(b"q\r\n")).await {
Ok(_) => {}
Err(_) => {
tracing::warn!(
"Quiesce write on {port_path} timed out — USB endpoint may be wedged"
);
return Vec::new();
}
}
let mut scratch = [0u8; 2048];
let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
let mut drained = 0usize;
let mut retained: Vec<u8> = Vec::new();
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
break;
}
match tokio::time::timeout(deadline - now, reader.read(&mut scratch)).await {
Ok(Ok(0)) => break, Ok(Ok(n)) => {
if retained.len() < RETAIN_CAP {
let take = n.min(RETAIN_CAP - retained.len());
retained.extend_from_slice(&scratch[..take]);
}
drained += n;
}
Ok(Err(_)) => break,
Err(_) => break, }
}
if drained > 0 {
tracing::info!("Drained {drained} bytes of stale stream on {port_path} before verify");
}
retained
}
async fn do_info_exchange<W, R>(
writer: &mut W,
reader: &mut BufReader<R>,
boot_context: &[u8],
) -> Result<DeviceInfo, InfoExchangeError>
where
W: AsyncWrite + Unpin,
R: AsyncRead + Unpin,
{
match tokio::time::timeout(Duration::from_secs(2), writer.write_all(b"\r\ninfo\r\n")).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
return Err(InfoExchangeError::Hard(format!("Serial write error: {e}")));
}
Err(_) => {
return Err(InfoExchangeError::Hard(
"serial write timed out — USB endpoint may be wedged".to_string(),
));
}
}
let deadline = tokio::time::Instant::now() + INFO_RESPONSE_TIMEOUT;
let mut info_buf: Vec<u8> = Vec::new();
let classify_timeout = |rx: &[u8]| {
let mut all = Vec::with_capacity(boot_context.len() + rx.len());
all.extend_from_slice(boot_context);
all.extend_from_slice(rx);
match detect_boot_fault(&all) {
Some(fault) => InfoExchangeError::BootFault(fault),
None => InfoExchangeError::Soft(
"info command timed out; firmware may not be esp-csi-cli-rs".to_string(),
),
}
};
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(classify_timeout(&info_buf));
}
let remaining = deadline.saturating_duration_since(now);
let read_fut = reader.read_until(b'\n', &mut info_buf);
match tokio::time::timeout(remaining, read_fut).await {
Ok(Ok(0)) => {
return Err(InfoExchangeError::Hard(
"serial closed during info exchange".to_string(),
));
}
Ok(Ok(_)) => {
if find_subsequence(&info_buf, b"END-INFO").is_some() {
return parse_info_block(&info_buf).map_err(InfoExchangeError::Soft);
}
}
Ok(Err(e)) => {
return Err(InfoExchangeError::Hard(format!("Serial read error: {e}")));
}
Err(_) => {
return Err(classify_timeout(&info_buf));
}
}
}
}
fn parse_info_block(buf: &[u8]) -> Result<DeviceInfo, String> {
let text = String::from_utf8_lossy(buf);
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let start = lines
.iter()
.position(|l| l.starts_with("ESP-CSI-CLI/"))
.ok_or_else(|| {
"info magic prefix 'ESP-CSI-CLI/' not seen — firmware is not esp-csi-cli-rs"
.to_string()
})?;
let end = lines
.iter()
.skip(start)
.position(|l| *l == "END-INFO")
.map(|p| start + p)
.ok_or_else(|| "END-INFO sentinel not seen in info block".to_string())?;
let banner_version = lines[start]
.strip_prefix("ESP-CSI-CLI/")
.unwrap_or("")
.to_string();
let mut info = DeviceInfo {
banner_version,
name: None,
version: None,
chip: None,
mac: None,
protocol: None,
features: Vec::new(),
};
for line in &lines[start + 1..end] {
let Some((k, v)) = line.split_once('=') else {
continue;
};
match k {
"name" => info.name = Some(v.to_string()),
"version" => info.version = Some(v.to_string()),
"chip" => info.chip = Some(v.to_string()),
"mac" => info.mac = Some(v.to_string()),
"protocol" => info.protocol = v.parse().ok(),
"features" => {
info.features = v
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
_ => {}
}
}
Ok(info)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn sync_parquet_sink(
mode: &OutputMode,
session_path: &Option<String>,
chip: Option<&ChipInfo>,
sink: &mut Option<ParquetSink>,
profile: &Arc<dyn CsiProfile>,
) {
match mode {
OutputMode::Dump | OutputMode::Both => {
if sink.is_none() {
if let Some(path) = session_path {
let Some(chip) = chip else {
tracing::error!(
"Cannot open Parquet dump {path}: chip not identified or unsupported; \
frames cannot be decoded. Streaming (if enabled) is unaffected."
);
return;
};
match ParquetSink::open(path, &chip.name, profile.clone()) {
Ok(s) => {
tracing::info!("Opened Parquet dump: {path} (chip {})", chip.name);
*sink = Some(s);
}
Err(e) => {
tracing::error!("Failed to open Parquet dump {path}: {e}");
}
}
}
}
}
OutputMode::Stream => {
if sink.take().is_some() {
tracing::info!("Switched to stream mode — Parquet file finalized");
}
}
}
}
#[cfg(test)]
mod tests {
use super::detect_boot_fault;
const C5_WEDGE_BANNER: &str = "ESP-ROM:esp32c5-eco2-20250121\r\n\
Build:Jan 21 2025\r\n\
rst:0x15 (USB_UART_HPSYS),boot:0x10 (SPI_FAST_FLASH_BOOT)\r\n\
Core0 Saved PC:0x40038598\r\n";
#[test]
fn usb_jtag_reset_loop_detected_on_repeat() {
let two = format!("{C5_WEDGE_BANNER}{C5_WEDGE_BANNER}");
let fault = detect_boot_fault(two.as_bytes()).expect("wedge not detected");
assert!(fault.contains("USB-JTAG reset loop"));
assert!(fault.contains("power-cycle") || fault.contains("Power-cycle"));
}
#[test]
fn boot_banner_followed_by_cli_is_not_a_fault() {
let recovered = format!("{C5_WEDGE_BANNER}ESP-CSI-CLI/0.7.0\r\nchip=esp32c5\r\nEND-INFO\r\n");
assert!(detect_boot_fault(recovered.as_bytes()).is_none());
}
#[test]
fn lone_wedge_banner_after_timeout_is_a_fault() {
let fault = detect_boot_fault(C5_WEDGE_BANNER.as_bytes()).expect("lone wedge not detected");
assert!(fault.contains("USB-JTAG-triggered reset"));
}
#[test]
fn download_mode_detected() {
let rx = b"ESP-ROM:esp32c5-eco2-20250121\r\nwaiting for download\r\n";
let fault = detect_boot_fault(rx).expect("download mode not detected");
assert!(fault.contains("download mode"));
}
#[test]
fn generic_boot_loop_detected() {
let banner = "ESP-ROM:esp32c5-eco2-20250121\r\nrst:0x3 (RTC_SW_SYS_RST)\r\n";
let rx = banner.repeat(3);
let fault = detect_boot_fault(rx.as_bytes()).expect("boot loop not detected");
assert!(fault.contains("boot loop"));
}
#[test]
fn normal_cli_output_is_healthy() {
assert!(detect_boot_fault(b"ESP-CSI-CLI/0.7.0\r\nchip=esp32c5\r\nEND-INFO\r\n").is_none());
assert!(detect_boot_fault(b"").is_none());
}
}