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",
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user