diff --git a/core/archipelago/src/mesh/flash.rs b/core/archipelago/src/mesh/flash.rs index 8b8ade67..57f18dca 100644 --- a/core/archipelago/src/mesh/flash.rs +++ b/core/archipelago/src/mesh/flash.rs @@ -47,6 +47,28 @@ impl FlashBoard { Self::HeltecV4 => "heltec-v4", } } + + /// esptool's `--before` reset strategy to use when connecting. + /// + /// V3 exposes a CP2102 USB-UART bridge, which correctly wires DTR/RTS + /// through to the chip's reset/boot pins, so the classic + /// `default_reset` (toggle DTR/RTS over the serial port's ioctls) works. + /// + /// V4 has no UART bridge at all — the ESP32-S3's native USB-CDC + /// peripheral IS the serial port, and confirmed live against real + /// hardware (2026-09-17) it does not implement the USB CDC + /// SET_CONTROL_LINE_STATE request `default_reset` depends on: every + /// `TIOCMSET`/`TIOCMBIS` ioctl (pyserial's `setRTS`/`setDTR`) fails with + /// `OSError: [Errno 71] Protocol error`, and it fails identically on + /// retry since changing baud doesn't touch the DTR/RTS path at all. + /// `usb_reset` resets over a direct USB control transfer instead of + /// through the serial ioctls, which is what actually works here. + fn esptool_before_reset(self) -> &'static str { + match self { + Self::HeltecV3 => "default_reset", + Self::HeltecV4 => "usb_reset", + } + } } /// Map a detected USB vid:pid to a known flashable board, using the same @@ -515,7 +537,7 @@ async fn run_flash( match family { DeviceType::Meshtastic | DeviceType::Meshcore => { let image = fetch_esptool_image(board, family, data_dir, job).await?; - esptool_erase_and_write(path, &image, job).await + esptool_erase_and_write(path, &image, job, board).await } DeviceType::Reticulum => { let lora_region = super::load_config(data_dir) @@ -751,13 +773,19 @@ const ESPTOOL_FALLBACK_BAUD: &str = "115200"; /// does not support function erase_flash", confirmed live 2026-07-23), since /// esptool's --erase-all is implemented as the same full-chip-erase command, /// not a per-sector loop. -async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc) -> Result<()> { +async fn esptool_erase_and_write( + path: &str, + image: &Path, + job: &Arc, + board: FlashBoard, +) -> Result<()> { job.set_stage(FlashStage::Writing).await; let image_str = image.to_string_lossy().to_string(); esptool_with_retry( path, &["write_flash", "--erase-all", "0x0", &image_str], job, + board, ) .await .context("esptool write_flash failed")?; @@ -786,8 +814,15 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc) /// so --no-stub broke our "always erase before write" default outright /// rather than just being slower. Restoring the real stub file is the /// correct fix, not routing around its absence. -fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> { - let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path]; +fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>, board: FlashBoard) -> Vec<&'a str> { + let mut args = vec![ + "--chip", + ESPTOOL_CHIP, + "--port", + path, + "--before", + board.esptool_before_reset(), + ]; if let Some(b) = baud { args.push("--baud"); args.push(b); @@ -795,9 +830,14 @@ fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> args } -async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc) -> Result<()> { +async fn esptool_with_retry( + path: &str, + subcommand: &[&str], + job: &Arc, + board: FlashBoard, +) -> Result<()> { let mut cmd = Command::new("esptool"); - cmd.args(esptool_global_args(path, None)); + cmd.args(esptool_global_args(path, None, board)); cmd.args(subcommand); match run_streamed(cmd, None, job).await { Ok(()) => Ok(()), @@ -807,7 +847,7 @@ async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc )) .await; let mut retry = Command::new("esptool"); - retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD))); + retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD), board)); retry.args(subcommand); run_streamed(retry, None, job) .await