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:
@@ -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<FlashJob>) -> Result<()> {
|
||||
async fn esptool_erase_and_write(
|
||||
path: &str,
|
||||
image: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
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<FlashJob>)
|
||||
/// 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<FlashJob>) -> Result<()> {
|
||||
async fn esptool_with_retry(
|
||||
path: &str,
|
||||
subcommand: &[&str],
|
||||
job: &Arc<FlashJob>,
|
||||
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<FlashJob>
|
||||
))
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user