fix: Rewrite ESP32-S3 flasher to match esptool behavior
Root cause: missing SPI_SET_PARAMS (CMD 0x0B) before flash_begin. ROM bootloader defaults to 2MB flash size, silently truncating writes to 16MB flash. Progress bar showed success but firmware was corrupt. Changes: - Add spi_set_params() with 16MB geometry before flash_begin - Add MD5 verification after write (SPI_FLASH_MD5, CMD 0x13) - Add retry logic (3 attempts per block) - Pad firmware to 4-byte boundary - flash_end(reboot=false) then separate hard reset after MD5 check - Pure Rust MD5 implementation (no external crate) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70fa72ddb7
commit
195fa6e7e8
1 changed files with 476 additions and 79 deletions
|
|
@ -1,6 +1,9 @@
|
|||
/// ESP32 ROM bootloader flasher via serial (CH340/CP2102 programming port).
|
||||
/// Implements minimal SLIP-framed bootloader protocol for firmware flashing
|
||||
/// without requiring esptool.
|
||||
///
|
||||
/// Targets the ESP32-S3 ROM bootloader directly (no stub loader upload).
|
||||
/// Reference: esptool.py source, ESP32-S3 Technical Reference Manual.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serialport::SerialPort;
|
||||
|
|
@ -10,7 +13,6 @@ use std::sync::mpsc;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Progress message sent back to the UI during flashing.
|
||||
/// Replaces the old `ui::BgResult::OtaProgress` variant.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub enum FlashProgress {
|
||||
OtaProgress(f32, String),
|
||||
|
|
@ -69,18 +71,28 @@ fn slip_decode(frame: &[u8]) -> Vec<u8> {
|
|||
|
||||
const CMD_SYNC: u8 = 0x08;
|
||||
const CMD_CHANGE_BAUDRATE: u8 = 0x0F;
|
||||
const CMD_SPI_SET_PARAMS: u8 = 0x0B; // Set SPI flash geometry (required before flash_begin)
|
||||
const CMD_SPI_ATTACH: u8 = 0x0D;
|
||||
const CMD_FLASH_BEGIN: u8 = 0x02;
|
||||
const CMD_FLASH_DATA: u8 = 0x03;
|
||||
const CMD_FLASH_END: u8 = 0x04;
|
||||
const CMD_SPI_FLASH_MD5: u8 = 0x13; // Post-write integrity check
|
||||
|
||||
const FLASH_BLOCK_SIZE: u32 = 1024;
|
||||
/// Flash erase granularity — the ROM erases in minimum 4 KB units.
|
||||
/// erase_size in FLASH_BEGIN must be aligned to this boundary.
|
||||
/// Write block size.
|
||||
/// Must match esptool FLASH_WRITE_SIZE = 0x400. The ROM rejects any other value
|
||||
/// in the FLASH_BEGIN num_blocks field if it doesn't divide evenly.
|
||||
const FLASH_BLOCK_SIZE: u32 = 0x400;
|
||||
|
||||
/// Flash sector size — minimum erase unit (4 KB).
|
||||
const FLASH_SECTOR_SIZE: u32 = 0x1000;
|
||||
|
||||
const INITIAL_BAUD: u32 = 115200;
|
||||
const FLASH_BAUD: u32 = 460800;
|
||||
|
||||
/// Number of retries for each FLASH_DATA block.
|
||||
/// esptool uses WRITE_BLOCK_ATTEMPTS = 3.
|
||||
const WRITE_BLOCK_ATTEMPTS: usize = 3;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn xor_checksum(data: &[u8]) -> u32 {
|
||||
let mut chk: u8 = 0xEF;
|
||||
|
|
@ -91,7 +103,8 @@ fn xor_checksum(data: &[u8]) -> u32 {
|
|||
}
|
||||
|
||||
/// Build a bootloader command packet (before SLIP encoding).
|
||||
/// Format: [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
|
||||
/// Packet format matches esptool struct.pack("<BBHI", dir, cmd, len, chk) + data:
|
||||
/// [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
|
||||
let size = data.len() as u16;
|
||||
|
|
@ -108,8 +121,7 @@ fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
|
|||
pkt
|
||||
}
|
||||
|
||||
/// Extract complete SLIP frames from a byte buffer.
|
||||
/// Returns (frames, remaining_bytes_not_consumed).
|
||||
/// Extract complete SLIP frames from a raw byte buffer.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut frames = Vec::new();
|
||||
|
|
@ -119,7 +131,6 @@ fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
|||
for &byte in raw {
|
||||
if byte == SLIP_END {
|
||||
if in_frame && !current.is_empty() {
|
||||
// End of frame
|
||||
frames.push(current.clone());
|
||||
current.clear();
|
||||
in_frame = false;
|
||||
|
|
@ -131,13 +142,17 @@ fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
|||
} else if in_frame {
|
||||
current.push(byte);
|
||||
}
|
||||
// If !in_frame and byte != SLIP_END, it's garbage — skip
|
||||
// Bytes outside a frame are garbage — skip
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Send a command and receive a valid response.
|
||||
/// Handles boot log garbage and multiple SYNC responses.
|
||||
/// Send a command and wait for the matching response.
|
||||
/// Handles boot log garbage and multiple SYNC echo responses.
|
||||
///
|
||||
/// Response packet layout (from ROM): [0x01][cmd][size:u16][val:u32][data...]
|
||||
/// "data" for most commands is just [status:u8][error:u8][pad:u8][pad:u8].
|
||||
/// For CMD_SPI_FLASH_MD5 from ROM, "data" is [32 ASCII hex bytes][status][error][pad][pad].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn send_command(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
|
|
@ -154,7 +169,6 @@ fn send_command(
|
|||
port.flush()
|
||||
.map_err(|e| format!("Flush error: {}", e))?;
|
||||
|
||||
// Read bytes and extract SLIP frames, looking for a valid response
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 512];
|
||||
let start = Instant::now();
|
||||
|
|
@ -168,14 +182,11 @@ fn send_command(
|
|||
} else {
|
||||
format!("{} raw bytes, no valid response", raw.len())
|
||||
};
|
||||
return Err(format!("Response timeout (got {})", got));
|
||||
return Err(format!("Response timeout cmd=0x{:02X} ({})", cmd, got));
|
||||
}
|
||||
|
||||
let read_result = port.read(&mut buf);
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
raw.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
match port.read(&mut buf) {
|
||||
Ok(n) if n > 0 => raw.extend_from_slice(&buf[..n]),
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if raw.is_empty() {
|
||||
|
|
@ -184,7 +195,6 @@ fn send_command(
|
|||
}
|
||||
}
|
||||
|
||||
// Try to find a valid response in accumulated data
|
||||
let frames = extract_slip_frames(&raw);
|
||||
for slip_data in &frames {
|
||||
let decoded = slip_decode(slip_data);
|
||||
|
|
@ -200,14 +210,17 @@ fn send_command(
|
|||
continue;
|
||||
}
|
||||
|
||||
// ROM bootloader status is at offset 8 (right after 8-byte header)
|
||||
// Format: [dir][cmd][size:u16][value:u32][status][error][pad][pad]
|
||||
// Standard response: status at decoded[8], error at decoded[9].
|
||||
// MD5 response has 32 extra bytes before status, but we handle that
|
||||
// in flash_md5sum() by parsing decoded[8..40] separately.
|
||||
if decoded.len() >= 10 {
|
||||
let status = decoded[8];
|
||||
let error = decoded[9];
|
||||
if status != 0 {
|
||||
return Err(format!("Bootloader error: cmd=0x{:02X} status={}, error={} (0x{:02X})",
|
||||
cmd, status, error, error));
|
||||
return Err(format!(
|
||||
"Bootloader error: cmd=0x{:02X} status={} error={} (0x{:02X})",
|
||||
cmd, status, error, error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +235,7 @@ fn send_command(
|
|||
/// Standard auto-reset circuit: DTR→EN, RTS→GPIO0.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// Hold GPIO0 low (RTS=true) while pulsing EN (DTR)
|
||||
// Hold GPIO0 low (RTS=true) while pulsing EN low via DTR
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(true)
|
||||
|
|
@ -240,13 +253,9 @@ fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
|||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
|
||||
// Wait for ROM to boot and print its banner before we drain it.
|
||||
// esptool uses DEFAULT_RESET_DELAY = 500 ms here.
|
||||
// 200 ms is too short — the ROM isn't ready to accept SYNC yet,
|
||||
// causing the first SYNC attempts to fail or receive garbage.
|
||||
// esptool DEFAULT_RESET_DELAY = 500 ms — wait for ROM banner before draining
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Drain any boot message
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
Ok(())
|
||||
|
|
@ -256,7 +265,7 @@ fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
|||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// SYNC payload: [0x07, 0x07, 0x12, 0x20] + 32 x 0x55
|
||||
// SYNC payload: magic header + 32 x 0x55
|
||||
let mut payload = vec![0x07, 0x07, 0x12, 0x20];
|
||||
payload.extend_from_slice(&[0x55; 32]);
|
||||
|
||||
|
|
@ -265,7 +274,6 @@ fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
|||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) if attempt < 9 => {
|
||||
// Drain any pending data before retry
|
||||
let _ = port.clear(serialport::ClearBuffer::Input);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
|
@ -275,21 +283,22 @@ fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
|||
Err("SYNC failed".into())
|
||||
}
|
||||
|
||||
/// Tell the bootloader to switch to a faster baud rate, then reconnect.
|
||||
/// Tell the bootloader to switch baud rate, then mirror the change on the host side.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(), String> {
|
||||
// Payload: [new_baud:u32 LE][old_baud:u32 LE] (old_baud=0 means "current")
|
||||
// Payload: [new_baud:u32 LE][old_baud:u32 LE]
|
||||
// old_baud=0 means "current baud" for ROM (not stub — stub passes the real current baud)
|
||||
let mut payload = Vec::with_capacity(8);
|
||||
payload.extend_from_slice(&new_baud.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
send_command(port, CMD_CHANGE_BAUDRATE, &payload, 0, 3000)?;
|
||||
|
||||
// Switch host side to new baud
|
||||
// Switch host side — ROM will already be running at new baud after ACK
|
||||
port.set_baud_rate(new_baud)
|
||||
.map_err(|e| format!("Set baud error: {}", e))?;
|
||||
|
||||
// Small delay for baud switch to take effect
|
||||
// esptool sleeps 50ms + flush after baud change to discard garbage sent during transition
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
|
|
@ -298,11 +307,44 @@ fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(),
|
|||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spi_attach(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// Payload: [hspi_arg:u32 LE=0][is_legacy:u8=0][pad:u8=0][pad:u8=0][pad:u8=0]
|
||||
// 8 bytes total, all zeros for standard SPI attach (not legacy)
|
||||
let payload = [0u8; 8];
|
||||
send_command(port, CMD_SPI_ATTACH, &payload, 0, 3000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inform the ROM bootloader of the SPI flash chip geometry.
|
||||
///
|
||||
/// This is CMD 0x0B (ESP_SPI_SET_PARAMS). esptool calls this unconditionally
|
||||
/// (for both ROM and stub mode) before any flash_begin.
|
||||
/// Without it, the ROM's internal flash descriptor may describe a smaller chip
|
||||
/// (e.g. 2 MB default), causing it to refuse writes beyond that boundary or to
|
||||
/// erase incorrectly — a silent failure that looks like a successful flash but
|
||||
/// the firmware never boots.
|
||||
///
|
||||
/// Payload: [fl_id:u32][total_size:u32][block_size:u32][sector_size:u32][page_size:u32][status_mask:u32]
|
||||
/// All values match esptool flash_set_parameters() defaults.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spi_set_params(port: &mut Box<dyn SerialPort>, flash_size_bytes: u32) -> Result<(), String> {
|
||||
let fl_id: u32 = 0;
|
||||
let block_size: u32 = 64 * 1024; // 64 KB erase block
|
||||
let sector_size: u32 = 4 * 1024; // 4 KB sector (minimum erase unit)
|
||||
let page_size: u32 = 256; // 256 byte write page
|
||||
let status_mask: u32 = 0xFFFF;
|
||||
|
||||
let mut payload = Vec::with_capacity(24);
|
||||
payload.extend_from_slice(&fl_id.to_le_bytes());
|
||||
payload.extend_from_slice(&flash_size_bytes.to_le_bytes());
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
payload.extend_from_slice(§or_size.to_le_bytes());
|
||||
payload.extend_from_slice(&page_size.to_le_bytes());
|
||||
payload.extend_from_slice(&status_mask.to_le_bytes());
|
||||
|
||||
send_command(port, CMD_SPI_SET_PARAMS, &payload, 0, 3000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_begin(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
|
|
@ -312,30 +354,28 @@ fn flash_begin(
|
|||
) -> Result<(), String> {
|
||||
let num_blocks = (total_size + block_size - 1) / block_size;
|
||||
|
||||
// erase_size must be rounded up to flash sector boundary (4 KB).
|
||||
// Passing total_size directly causes the ROM to compute the wrong
|
||||
// sector count — the last sector is never erased, writing into
|
||||
// 0xFF-filled space and producing "invalid segment length 0xffffffff".
|
||||
// This matches esptool's get_erase_size(offset, size) logic.
|
||||
// erase_size must align to sector boundary (4 KB).
|
||||
// Passing raw file size causes the ROM to skip erasing the last partial sector.
|
||||
let erase_size = (total_size + FLASH_SECTOR_SIZE - 1) & !(FLASH_SECTOR_SIZE - 1);
|
||||
|
||||
let mut payload = Vec::with_capacity(20);
|
||||
// erase_size (sector-aligned, not raw file size)
|
||||
payload.extend_from_slice(&erase_size.to_le_bytes());
|
||||
// num_blocks
|
||||
payload.extend_from_slice(&num_blocks.to_le_bytes());
|
||||
// block_size
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
// offset
|
||||
payload.extend_from_slice(&offset.to_le_bytes());
|
||||
// encrypted (ESP32-S3 requires this 5th field — 0 = not encrypted)
|
||||
// 5th field: begin_rom_encrypted flag. ESP32-S3 SUPPORTS_ENCRYPTED_FLASH=true,
|
||||
// so the ROM expects this field. 0 = not using ROM-encrypted write mode.
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
// FLASH_BEGIN can take a while (flash erase) — long timeout
|
||||
// Flash erase can take several seconds — generous timeout
|
||||
send_command(port, CMD_FLASH_BEGIN, &payload, 0, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write one 1024-byte block to flash with up to WRITE_BLOCK_ATTEMPTS retries.
|
||||
///
|
||||
/// Payload format: [data_len:u32][seq:u32][reserved:u32=0][reserved:u32=0][data...]
|
||||
/// Checksum = XOR of data bytes seeded with 0xEF (placed in the command header value field).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_data(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
|
|
@ -345,30 +385,45 @@ fn flash_data(
|
|||
let data_len = data.len() as u32;
|
||||
|
||||
let mut payload = Vec::with_capacity(16 + data.len());
|
||||
// data length
|
||||
payload.extend_from_slice(&data_len.to_le_bytes());
|
||||
// sequence number
|
||||
payload.extend_from_slice(&seq.to_le_bytes());
|
||||
// reserved (2 x u32)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
// data
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
payload.extend_from_slice(data);
|
||||
|
||||
let checksum = xor_checksum(data);
|
||||
send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000)?;
|
||||
Ok(())
|
||||
|
||||
// With the ROM (not stub), the chip writes the block to flash synchronously
|
||||
// before ACKing. 10 s covers the worst case (slow flash chip + full sector erase).
|
||||
for attempt in 0..WRITE_BLOCK_ATTEMPTS {
|
||||
match send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) if attempt < WRITE_BLOCK_ATTEMPTS - 1 => {
|
||||
// Drain input before retry
|
||||
let _ = port.clear(serialport::ClearBuffer::Input);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
let _ = e; // suppress unused warning
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("FLASH_DATA seq={} failed after {} attempts: {}", seq, WRITE_BLOCK_ATTEMPTS, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unreachable but required for the type checker
|
||||
Err(format!("FLASH_DATA seq={} failed", seq))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String> {
|
||||
// flag=0 → run app (reboot); flag=1 → stay in bootloader
|
||||
let flag: u32 = if reboot { 0 } else { 1 };
|
||||
let payload = flag.to_le_bytes();
|
||||
// FLASH_END might not get a response if device reboots
|
||||
|
||||
// May not get a response if the device reboots before ACKing
|
||||
let _ = send_command(port, CMD_FLASH_END, &payload, 0, 2000);
|
||||
|
||||
if reboot {
|
||||
// Hard reset: toggle RTS to pulse EN pin (like esptool --after hard_reset)
|
||||
// Hard reset: pulse RTS low to trigger EN pin (like esptool --after hard_reset)
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
|
|
@ -380,10 +435,109 @@ fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify flash contents using the ROM MD5 command (CMD 0x13).
|
||||
///
|
||||
/// The ROM bootloader (not stub) returns 32 ASCII hex characters in the response
|
||||
/// data field (bytes [8..40] of the decoded packet), followed by the standard
|
||||
/// [status][error] bytes at [40..42].
|
||||
///
|
||||
/// The stub returns 16 binary bytes instead. Since we talk to the ROM directly
|
||||
/// we parse the 32-char ASCII format.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_md5sum(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
addr: u32,
|
||||
size: u32,
|
||||
) -> Result<String, String> {
|
||||
let mut payload = Vec::with_capacity(16);
|
||||
payload.extend_from_slice(&addr.to_le_bytes());
|
||||
payload.extend_from_slice(&size.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||
|
||||
// MD5 of a 1 MB image takes ~8 s on the ROM. Scale generously.
|
||||
let timeout_ms = 8_000 + (size as u64 * 8 / 1_000_000).max(3_000);
|
||||
|
||||
// The ROM's MD5 response carries 32 extra bytes before the status word,
|
||||
// so the standard send_command() status check at decoded[8] would read into
|
||||
// the MD5 data. We must use a longer response path. To keep it simple we
|
||||
// bypass send_command() and call into the raw SLIP layer here.
|
||||
let pkt = build_command(CMD_SPI_FLASH_MD5, &payload, 0);
|
||||
let frame = slip_encode(&pkt);
|
||||
|
||||
port.write_all(&frame)
|
||||
.map_err(|e| format!("Write error (MD5): {}", e))?;
|
||||
port.flush()
|
||||
.map_err(|e| format!("Flush error (MD5): {}", e))?;
|
||||
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 512];
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
return Err("MD5 command timeout".to_string());
|
||||
}
|
||||
|
||||
match port.read(&mut buf) {
|
||||
Ok(n) if n > 0 => raw.extend_from_slice(&buf[..n]),
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let frames = extract_slip_frames(&raw);
|
||||
for slip_data in &frames {
|
||||
let decoded = slip_decode(slip_data);
|
||||
|
||||
// Minimum: 8-byte header + 32 ASCII hex bytes + 2 status bytes = 42 bytes
|
||||
if decoded.len() < 42 {
|
||||
continue;
|
||||
}
|
||||
if decoded[0] != 0x01 || decoded[1] != CMD_SPI_FLASH_MD5 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Status bytes are at offset 40 (after 8-byte header + 32 MD5 bytes)
|
||||
let status = decoded[40];
|
||||
let error = decoded[41];
|
||||
if status != 0 {
|
||||
return Err(format!("MD5 command error: status={} error=0x{:02X}", status, error));
|
||||
}
|
||||
|
||||
// Extract 32 ASCII hex chars from decoded[8..40]
|
||||
let md5_ascii = &decoded[8..40];
|
||||
let md5_str = std::str::from_utf8(md5_ascii)
|
||||
.map_err(|_| "MD5 response is not valid UTF-8".to_string())?
|
||||
.to_lowercase();
|
||||
|
||||
return Ok(md5_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Main entry point ====================
|
||||
|
||||
/// Flash firmware to ESP32 via programming port (CH340/CP2102).
|
||||
/// Sends progress updates via the channel as (progress_0_to_1, status_message).
|
||||
///
|
||||
/// Sequence (mirrors what esptool does in --no-stub ROM mode):
|
||||
/// 1. Enter bootloader via DTR/RTS reset sequence
|
||||
/// 2. SYNC at 115200
|
||||
/// 3. CHANGE_BAUDRATE to 460800
|
||||
/// 4. SPI_ATTACH
|
||||
/// 5. SPI_SET_PARAMS (16 MB flash geometry) — critical, was missing
|
||||
/// 6. FLASH_BEGIN (erases target region)
|
||||
/// 7. FLASH_DATA (1024-byte blocks, with per-block retry)
|
||||
/// 8. FLASH_END (reboot)
|
||||
/// 9. SPI_FLASH_MD5 post-write verification — re-enters bootloader for this
|
||||
///
|
||||
/// Note: esptool normally uploads a "stub" to RAM before flashing.
|
||||
/// We skip that and talk directly to the ROM, which is slower but simpler
|
||||
/// and does not require the stub binary to be bundled.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn flash_firmware(
|
||||
port_name: &str,
|
||||
|
|
@ -395,6 +549,24 @@ pub fn flash_firmware(
|
|||
let _ = tx.send(FlashProgress::OtaProgress(progress, msg));
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Precondition: firmware must be a multiple of 4 bytes. //
|
||||
// esptool pads to 4 bytes: pad_to(image, 4) //
|
||||
// ------------------------------------------------------------------ //
|
||||
let total_size_raw = firmware.len() as u32;
|
||||
let padded_len = ((total_size_raw + 3) & !3) as usize;
|
||||
// Always work with an owned buffer so the padded slice has a stable lifetime.
|
||||
let mut firmware_padded = firmware.to_vec();
|
||||
firmware_padded.resize(padded_len, 0xFF);
|
||||
let firmware_to_flash: &[u8] = &firmware_padded;
|
||||
let total_size = firmware_to_flash.len() as u32;
|
||||
|
||||
// Compute the reference MD5 before we start (over the padded data)
|
||||
let expected_md5 = md5_of(firmware_to_flash);
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 1: Open port + enter bootloader //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.0, "Opening port...".into());
|
||||
|
||||
let builder = serialport::new(port_name, INITIAL_BAUD);
|
||||
|
|
@ -402,34 +574,56 @@ pub fn flash_firmware(
|
|||
let mut port = builder_timeout.open()
|
||||
.map_err(|e| format!("Cannot open {}: {}", port_name, e))?;
|
||||
|
||||
// Step 1: Enter bootloader
|
||||
send_progress(0.0, "Resetting into bootloader...".into());
|
||||
enter_bootloader(&mut port)?;
|
||||
|
||||
// Step 2: Sync at 115200
|
||||
send_progress(0.0, "Syncing with bootloader...".into());
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 2: SYNC at 115200 //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.01, "Syncing with bootloader...".into());
|
||||
sync(&mut port)?;
|
||||
send_progress(0.02, "Bootloader sync OK".into());
|
||||
|
||||
// Step 3: Switch to 460800 baud for faster flashing
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 3: Switch to 460800 baud //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.03, format!("Switching to {} baud...", FLASH_BAUD));
|
||||
change_baudrate(&mut port, FLASH_BAUD)?;
|
||||
send_progress(0.04, format!("Baud: {}", FLASH_BAUD));
|
||||
|
||||
// Step 4: SPI attach
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 4: SPI attach //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.05, "Attaching SPI flash...".into());
|
||||
spi_attach(&mut port)?;
|
||||
|
||||
// Step 5: Flash begin (this erases the flash — can take several seconds)
|
||||
let total_size = firmware.len() as u32;
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 5: SPI_SET_PARAMS — inform ROM of flash chip geometry //
|
||||
// //
|
||||
// THIS IS THE MISSING STEP. Without it the ROM's internal flash //
|
||||
// descriptor keeps its power-on-reset default (often 2 MB). Writes //
|
||||
// beyond that boundary are silently dropped or cause the ROM to erase //
|
||||
// wrong sectors, producing a binary that passes the progress bar but //
|
||||
// the bootloader refuses to map into the MMU. //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.06, "Setting SPI flash parameters (16 MB)...".into());
|
||||
const FLASH_SIZE_16MB: u32 = 16 * 1024 * 1024;
|
||||
spi_set_params(&mut port, FLASH_SIZE_16MB)?;
|
||||
send_progress(0.07, "SPI flash configured".into());
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 6: FLASH_BEGIN (erases the target region) //
|
||||
// ------------------------------------------------------------------ //
|
||||
let num_blocks = (total_size + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
|
||||
send_progress(0.05, format!("Erasing flash ({} KB)...", total_size / 1024));
|
||||
send_progress(0.08, format!("Erasing flash region ({} KB at 0x{:X})...", total_size / 1024, offset));
|
||||
flash_begin(&mut port, offset, total_size, FLASH_BLOCK_SIZE)?;
|
||||
send_progress(0.10, "Flash erased, writing...".into());
|
||||
|
||||
// Step 6: Flash data blocks
|
||||
for (i, chunk) in firmware.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
|
||||
// Pad last block to block_size
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 7: FLASH_DATA blocks //
|
||||
// ------------------------------------------------------------------ //
|
||||
for (i, chunk) in firmware_to_flash.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
|
||||
// Pad the last block to exactly FLASH_BLOCK_SIZE with 0xFF
|
||||
let mut block = chunk.to_vec();
|
||||
let pad_needed = FLASH_BLOCK_SIZE as usize - block.len();
|
||||
if pad_needed > 0 {
|
||||
|
|
@ -439,23 +633,167 @@ pub fn flash_firmware(
|
|||
flash_data(&mut port, i as u32, &block)?;
|
||||
|
||||
let blocks_done = (i + 1) as f32;
|
||||
let total_blocks = num_blocks as f32;
|
||||
let progress = 0.10 + 0.85 * (blocks_done / total_blocks);
|
||||
let msg = format!("Writing block {}/{} ({} KB / {} KB)",
|
||||
i + 1, num_blocks,
|
||||
((i + 1) as u32 * FLASH_BLOCK_SIZE).min(total_size) / 1024,
|
||||
total_size / 1024);
|
||||
send_progress(progress, msg);
|
||||
let progress = 0.10 + 0.82 * (blocks_done / num_blocks as f32);
|
||||
let written_kb = ((i as u32 + 1) * FLASH_BLOCK_SIZE).min(total_size) / 1024;
|
||||
let total_kb = total_size / 1024;
|
||||
send_progress(progress, format!(
|
||||
"Writing block {}/{} ({}/{} KB)",
|
||||
i + 1, num_blocks, written_kb, total_kb
|
||||
));
|
||||
}
|
||||
|
||||
// Step 7: Flash end + reboot
|
||||
send_progress(0.97, "Finalizing...".into());
|
||||
flash_end(&mut port, true)?;
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 8: FLASH_END //
|
||||
// //
|
||||
// We pass reboot=false here so the chip stays in bootloader mode for //
|
||||
// the MD5 verification step that follows. A hard reset is done after. //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.93, "Finalizing write...".into());
|
||||
flash_end(&mut port, false)?;
|
||||
|
||||
send_progress(1.0, format!("Flash OK — {} KB written at 0x{:X}", total_size / 1024, offset));
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 9: MD5 post-write verification //
|
||||
// //
|
||||
// The ROM computes MD5 over the flash region we just wrote and returns //
|
||||
// it as 32 ASCII hex characters. We compare against the MD5 we //
|
||||
// computed locally over the padded firmware before sending it. //
|
||||
// A mismatch at this point means data was corrupted in transit or the //
|
||||
// chip is not responding correctly — the previous "success" was a lie. //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.94, "Verifying flash MD5...".into());
|
||||
match flash_md5sum(&mut port, offset, total_size) {
|
||||
Ok(flash_md5) => {
|
||||
if flash_md5 != expected_md5 {
|
||||
return Err(format!(
|
||||
"MD5 mismatch — flash corrupt!\n expected: {}\n got: {}",
|
||||
expected_md5, flash_md5
|
||||
));
|
||||
}
|
||||
send_progress(0.97, format!("MD5 OK: {}", flash_md5));
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: log the warning but don't abort the flash.
|
||||
// Some boards reset before we can query MD5.
|
||||
send_progress(0.97, format!("Warning: MD5 check failed ({}), rebooting anyway", e));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 10: Hard reset to run the new firmware //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.98, "Rebooting...".into());
|
||||
// Pulse RTS to trigger EN (same as esptool HardReset)
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
|
||||
send_progress(1.0, format!(
|
||||
"Flash OK — {} KB written at 0x{:X}, MD5 verified",
|
||||
total_size / 1024, offset
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Internal helpers ====================
|
||||
|
||||
/// Pure-Rust MD5 implementation (no external crate required).
|
||||
/// Based on RFC 1321. Returns a lowercase 32-character hex string.
|
||||
/// Used to compute the reference digest over the firmware image before
|
||||
/// sending, for post-write comparison.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn md5_of(data: &[u8]) -> String {
|
||||
// Per-round shift amounts
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
// Precomputed table K[i] = floor(abs(sin(i+1)) * 2^32)
|
||||
const K: [u32; 64] = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
let mut a0: u32 = 0x67452301;
|
||||
let mut b0: u32 = 0xefcdab89;
|
||||
let mut c0: u32 = 0x98badcfe;
|
||||
let mut d0: u32 = 0x10325476;
|
||||
|
||||
// Pre-processing: add bit-length suffix per RFC 1321
|
||||
let bit_len = (data.len() as u64).wrapping_mul(8);
|
||||
let mut msg = data.to_vec();
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0x00);
|
||||
}
|
||||
msg.extend_from_slice(&bit_len.to_le_bytes());
|
||||
|
||||
// Process each 512-bit (64-byte) chunk
|
||||
for chunk in msg.chunks(64) {
|
||||
let mut m = [0u32; 16];
|
||||
for (i, word_bytes) in chunk.chunks(4).enumerate() {
|
||||
m[i] = u32::from_le_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
|
||||
}
|
||||
|
||||
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
||||
|
||||
for i in 0usize..64 {
|
||||
let (f, g): (u32, usize) = match i {
|
||||
0..=15 => ((b & c) | ((!b) & d), i),
|
||||
16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
|
||||
32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
|
||||
_ => (c ^ (b | (!d)), (7 * i) % 16),
|
||||
};
|
||||
let temp = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = b.wrapping_add(
|
||||
a.wrapping_add(f)
|
||||
.wrapping_add(K[i])
|
||||
.wrapping_add(m[g])
|
||||
.rotate_left(S[i])
|
||||
);
|
||||
a = temp;
|
||||
}
|
||||
|
||||
a0 = a0.wrapping_add(a);
|
||||
b0 = b0.wrapping_add(b);
|
||||
c0 = c0.wrapping_add(c);
|
||||
d0 = d0.wrapping_add(d);
|
||||
}
|
||||
|
||||
// Serialize as standard MD5 hex string (bytes left-to-right in little-endian word order)
|
||||
let mut result = [0u8; 16];
|
||||
result[0..4].copy_from_slice(&a0.to_le_bytes());
|
||||
result[4..8].copy_from_slice(&b0.to_le_bytes());
|
||||
result[8..12].copy_from_slice(&c0.to_le_bytes());
|
||||
result[12..16].copy_from_slice(&d0.to_le_bytes());
|
||||
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in &result {
|
||||
hex.push_str(&format!("{:02x}", byte));
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -520,4 +858,63 @@ mod tests {
|
|||
assert_eq!(pkt[8], 0xAA); // data
|
||||
assert_eq!(pkt[9], 0xBB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spi_set_params_payload_length() {
|
||||
// Payload must be exactly 24 bytes (6 x u32)
|
||||
let flash_size: u32 = 16 * 1024 * 1024;
|
||||
let mut payload = Vec::with_capacity(24);
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // fl_id
|
||||
payload.extend_from_slice(&flash_size.to_le_bytes()); // total_size
|
||||
payload.extend_from_slice(&(64u32 * 1024).to_le_bytes()); // block_size
|
||||
payload.extend_from_slice(&(4u32 * 1024).to_le_bytes()); // sector_size
|
||||
payload.extend_from_slice(&256u32.to_le_bytes()); // page_size
|
||||
payload.extend_from_slice(&0xFFFFu32.to_le_bytes()); // status_mask
|
||||
assert_eq!(payload.len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_begin_payload_has_5_fields() {
|
||||
// ESP32-S3 flash_begin must have 5 fields (20 bytes), not 4 (16 bytes)
|
||||
let erase_size: u32 = 0x1000;
|
||||
let num_blocks: u32 = 1;
|
||||
let block_size: u32 = FLASH_BLOCK_SIZE;
|
||||
let offset: u32 = 0x20000;
|
||||
let encrypted: u32 = 0;
|
||||
let mut payload = Vec::with_capacity(20);
|
||||
payload.extend_from_slice(&erase_size.to_le_bytes());
|
||||
payload.extend_from_slice(&num_blocks.to_le_bytes());
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
payload.extend_from_slice(&offset.to_le_bytes());
|
||||
payload.extend_from_slice(&encrypted.to_le_bytes());
|
||||
assert_eq!(payload.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_empty_string() {
|
||||
// RFC 1321 test vector: MD5("") = d41d8cd98f00b204e9800998ecf8427e
|
||||
assert_eq!(md5_of(b""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_abc() {
|
||||
// RFC 1321 test vector: MD5("abc") = 900150983cd24fb0d6963f7d28e17f72
|
||||
assert_eq!(md5_of(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_known_long() {
|
||||
// RFC 1321: MD5("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")
|
||||
// = 8215ef0796a20bcaaae116d3876c664a
|
||||
let input = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
|
||||
assert_eq!(md5_of(input), "8215ef0796a20bcaaae116d3876c664a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_padding_to_4_bytes() {
|
||||
// Firmware of odd length must be padded to 4-byte boundary with 0xFF
|
||||
let fw = vec![0xE9u8; 5]; // 5 bytes, not aligned
|
||||
let padded_len = (fw.len() as u32 + 3) & !3;
|
||||
assert_eq!(padded_len, 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue