fix(mesh): use usb_reset for Heltec V4 esptool flashing

Heltec V4's native USB-CDC peripheral doesn't implement the
SET_CONTROL_LINE_STATE request esptool's default DTR/RTS reset
depends on, so every write_flash attempt (and its fallback-baud
retry) failed identically with OSError: [Errno 71] Protocol error
on the TIOCMSET ioctl. Confirmed live on allshookup: firmware
downloaded fine but flashing failed every time, leaving the mesh
radio stuck unconfigured. Pass --before usb_reset for V4 (V3 keeps
default_reset, since its CP2102 UART bridge wires DTR/RTS through
correctly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:30:13 +00:00
co-authored by Claude Sonnet 5
parent 3b9b74dae5
commit cb5415ea5c
+47 -7
View File
@@ -47,6 +47,28 @@ impl FlashBoard {
Self::HeltecV4 => "heltec-v4", 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 /// Map a detected USB vid:pid to a known flashable board, using the same
@@ -515,7 +537,7 @@ async fn run_flash(
match family { match family {
DeviceType::Meshtastic | DeviceType::Meshcore => { DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?; 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 => { DeviceType::Reticulum => {
let lora_region = super::load_config(data_dir) 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 /// 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, /// esptool's --erase-all is implemented as the same full-chip-erase command,
/// not a per-sector loop. /// not a per-sector loop.
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> { async fn esptool_erase_and_write(
path: &str,
image: &Path,
job: &Arc<FlashJob>,
board: FlashBoard,
) -> Result<()> {
job.set_stage(FlashStage::Writing).await; job.set_stage(FlashStage::Writing).await;
let image_str = image.to_string_lossy().to_string(); let image_str = image.to_string_lossy().to_string();
esptool_with_retry( esptool_with_retry(
path, path,
&["write_flash", "--erase-all", "0x0", &image_str], &["write_flash", "--erase-all", "0x0", &image_str],
job, job,
board,
) )
.await .await
.context("esptool write_flash failed")?; .context("esptool write_flash failed")?;
@@ -786,8 +814,15 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>)
/// so --no-stub broke our "always erase before write" default outright /// so --no-stub broke our "always erase before write" default outright
/// rather than just being slower. Restoring the real stub file is the /// rather than just being slower. Restoring the real stub file is the
/// correct fix, not routing around its absence. /// correct fix, not routing around its absence.
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> { 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]; let mut args = vec![
"--chip",
ESPTOOL_CHIP,
"--port",
path,
"--before",
board.esptool_before_reset(),
];
if let Some(b) = baud { if let Some(b) = baud {
args.push("--baud"); args.push("--baud");
args.push(b); args.push(b);
@@ -795,9 +830,14 @@ fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str>
args args
} }
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> { async fn esptool_with_retry(
path: &str,
subcommand: &[&str],
job: &Arc<FlashJob>,
board: FlashBoard,
) -> Result<()> {
let mut cmd = Command::new("esptool"); let mut cmd = Command::new("esptool");
cmd.args(esptool_global_args(path, None)); cmd.args(esptool_global_args(path, None, board));
cmd.args(subcommand); cmd.args(subcommand);
match run_streamed(cmd, None, job).await { match run_streamed(cmd, None, job).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
@@ -807,7 +847,7 @@ async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>
)) ))
.await; .await;
let mut retry = Command::new("esptool"); 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); retry.args(subcommand);
run_streamed(retry, None, job) run_streamed(retry, None, job)
.await .await