Class: Prremote::EspFlasher
- Inherits:
-
Object
- Object
- Prremote::EspFlasher
- Defined in:
- lib/prremote/esp_flasher.rb
Overview
Pure-Ruby ESP32 flasher — speaks the Espressif serial bootloader protocol directly so ‘install –board esp32` needs no esptool / Python.
Talks to the ESP32 (classic) ROM loader only (no stub upload): SYNC, SPI_ATTACH, SPI_SET_PARAMS, CHANGE_BAUDRATE, FLASH_BEGIN/DATA/END and SPI_FLASH_MD5 — enough to write a merged image at offset 0x0 and verify it. Protocol reference: docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html
The chip is put into (and out of) the boot ROM by toggling DTR/RTS through the USB-UART bridge’s auto-reset circuit, via ioctl on the serial fd —macOS and Linux only.
Defined Under Namespace
Classes: Error
Constant Summary collapse
- FLASH_BEGIN =
Command opcodes (ROM loader subset)
0x02- FLASH_DATA =
0x03- FLASH_END =
0x04- SYNC =
0x08- SPI_SET_PARAMS =
0x0B- SPI_ATTACH =
0x0D- CHANGE_BAUD =
0x0F- SPI_FLASH_MD5 =
0x13- FLASH_WRITE_SIZE =
ROM loader max data per FLASH_DATA packet
0x400- STATUS_BYTES =
ESP32 ROM appends 4 status bytes to responses
4- CHECKSUM_SEED =
0xEF- ROM_BAUD =
115_200- TIOCM_DTR =
ioctl modem-control constants
0x0002- TIOCM_RTS =
0x0004- DARWIN =
RbConfig::CONFIG['host_os'] =~ /darwin/ ? true : false
- TIOCMGET =
DARWIN ? 0x4004746A : 0x5415
- TIOCMSET =
DARWIN ? 0x8004746D : 0x5418
Class Method Summary collapse
-
.baud_supported?(baud) ⇒ Boolean
The local termios layer must support the baud before CHANGE_BAUDRATE is sent to the chip — once the chip switches, there is no way back without a re-sync, so never request a speed we cannot reopen at.
-
.flash(port:, image_path:, baud: 230_400) ⇒ Object
Flashes ‘image_path` at offset 0x0 and verifies it with an on-chip MD5.
Instance Method Summary collapse
- #checksum(data) ⇒ Object
-
#command(op, payload, checksum: 0, timeout: 3) ⇒ Object
Sends one command packet and waits for its response.
-
#enter_bootloader ⇒ Object
── chip reset control (DTR/RTS via the auto-download circuit) ────────.
-
#finish_flash ⇒ Object
Every block is already committed once its FLASH_DATA is acked, so FLASH_END is only a courtesy “done” (we leave the loader via hard_reset anyway).
- #hard_reset ⇒ Object
-
#initialize(serial, fd: nil) ⇒ EspFlasher
constructor
‘serial` needs #read/#write; `fd` enables DTR/RTS control and may be nil in tests.
- #slip_decode(frame) ⇒ Object
- #slip_encode(packet) ⇒ Object
-
#sync! ⇒ Object
── protocol steps ─────────────────────────────────────────────────────.
-
#upgrade_baud(port, baud) ⇒ Object
CHANGE_BAUDRATE, then reopen the port at the new speed.
- #verify_md5(image, offset: 0) ⇒ Object
- #write_flash(image, offset: 0) ⇒ Object
Constructor Details
#initialize(serial, fd: nil) ⇒ EspFlasher
‘serial` needs #read/#write; `fd` enables DTR/RTS control and may be nil in tests.
83 84 85 86 87 |
# File 'lib/prremote/esp_flasher.rb', line 83 def initialize(serial, fd: nil) @serial = serial @fd = fd @rxbuf = +''.b end |
Class Method Details
.baud_supported?(baud) ⇒ Boolean
The local termios layer must support the baud before CHANGE_BAUDRATE is sent to the chip — once the chip switches, there is no way back without a re-sync, so never request a speed we cannot reopen at.
75 76 77 78 79 |
# File 'lib/prremote/esp_flasher.rb', line 75 def self.baud_supported?(baud) RubySerial::Posix::BAUDE_RATES.key?(baud) rescue NameError false end |
.flash(port:, image_path:, baud: 230_400) ⇒ Object
Flashes ‘image_path` at offset 0x0 and verifies it with an on-chip MD5. The transfer runs at `baud` when rubyserial supports it locally (macOS termios caps out at 230400), otherwise at the ROM’s 115200.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/prremote/esp_flasher.rb', line 47 def self.flash(port:, image_path:, baud: 230_400) unless RbConfig::CONFIG['host_os'] =~ /darwin|linux/ raise Error, 'pure-Ruby flashing supports macOS/Linux only; ' \ 'on other systems flash with esptool: ' \ "esptool write_flash 0x0 #{image_path}" end image = File.binread(image_path) serial = Serial.new(port, ROM_BAUD) flasher = new(serial, fd: serial.instance_variable_get(:@fd)) begin flasher.enter_bootloader flasher.sync! serial = flasher.upgrade_baud(port, baud) if baud != ROM_BAUD && baud_supported?(baud) flasher.write_flash(image, offset: 0) # Verify before FLASH_END: the ROM loader has been seen going quiet # after that command, while MD5 right after the last block is reliable. flasher.verify_md5(image, offset: 0) flasher.finish_flash flasher.hard_reset ensure serial.close end end |
Instance Method Details
#checksum(data) ⇒ Object
194 195 196 |
# File 'lib/prremote/esp_flasher.rb', line 194 def checksum(data) data.bytes.reduce(CHECKSUM_SEED) { |acc, b| acc ^ b } end |
#command(op, payload, checksum: 0, timeout: 3) ⇒ Object
Sends one command packet and waits for its response. Returns [value, data] from the response (status bytes stripped).
179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/prremote/esp_flasher.rb', line 179 def command(op, payload, checksum: 0, timeout: 3) packet = [0x00, op, payload.bytesize].pack('CCv') + [checksum].pack('V') + payload @serial.write(slip_encode(packet)) deadline = Time.now + timeout loop do frame = read_frame(deadline) raise Error, format('timeout waiting for response to 0x%<op>02x', op: op) if frame.nil? result = parse_response(frame, op) return result if result end end |
#enter_bootloader ⇒ Object
── chip reset control (DTR/RTS via the auto-download circuit) ────────
91 92 93 94 95 96 97 98 99 |
# File 'lib/prremote/esp_flasher.rb', line 91 def enter_bootloader # esptool's "classic reset": hold EN low, release it with IO0 low so # the chip starts the ROM loader, then release IO0. set_lines(dtr: false, rts: true) sleep 0.1 set_lines(dtr: true, rts: false) sleep 0.05 set_lines(dtr: false, rts: false) end |
#finish_flash ⇒ Object
Every block is already committed once its FLASH_DATA is acked, so FLASH_END is only a courtesy “done” (we leave the loader via hard_reset anyway). The ESP32 ROM has been seen answering it with error 0x06 —ignore it; the MD5 check is the source of truth.
157 158 159 160 161 |
# File 'lib/prremote/esp_flasher.rb', line 157 def finish_flash command(FLASH_END, [1].pack('V')) # 1 = stay in the loader rescue Error nil end |
#hard_reset ⇒ Object
101 102 103 104 105 |
# File 'lib/prremote/esp_flasher.rb', line 101 def hard_reset set_lines(dtr: false, rts: true) sleep 0.1 set_lines(dtr: false, rts: false) end |
#slip_decode(frame) ⇒ Object
203 204 205 |
# File 'lib/prremote/esp_flasher.rb', line 203 def slip_decode(frame) frame.gsub("\xDB\xDC".b, "\xC0".b).gsub("\xDB\xDD".b, "\xDB".b) end |
#slip_encode(packet) ⇒ Object
198 199 200 201 |
# File 'lib/prremote/esp_flasher.rb', line 198 def slip_encode(packet) escaped = packet.gsub("\xDB".b, "\xDB\xDD".b).gsub("\xC0".b, "\xDB\xDC".b) "\xC0".b + escaped + "\xC0".b end |
#sync! ⇒ Object
── protocol steps ─────────────────────────────────────────────────────
109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/prremote/esp_flasher.rb', line 109 def sync! payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32') synced = 8.times.any? do @rxbuf.clear begin command(SYNC, payload, timeout: 0.5) drain_responses true rescue Error false end end raise Error, 'could not sync with the ESP32 boot ROM' unless synced end |
#upgrade_baud(port, baud) ⇒ Object
CHANGE_BAUDRATE, then reopen the port at the new speed. Plain open does not touch DTR/RTS (verified with rubyserial), so the chip stays in the bootloader across the reopen.
127 128 129 130 131 132 133 134 135 136 |
# File 'lib/prremote/esp_flasher.rb', line 127 def upgrade_baud(port, baud) command(CHANGE_BAUD, [baud, 0].pack('V2')) @serial.close sleep 0.05 serial = Serial.new(port, baud) @serial = serial @fd = serial.instance_variable_get(:@fd) @rxbuf.clear serial end |
#verify_md5(image, offset: 0) ⇒ Object
163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/prremote/esp_flasher.rb', line 163 def verify_md5(image, offset: 0) timeout = 8 * (1 + (image.bytesize / (1024 * 1024))) _value, data = command(SPI_FLASH_MD5, [offset, image.bytesize, 0, 0].pack('V4'), timeout: timeout) device_md5 = data[0, 32] # the ROM loader answers as 32 hex chars local_md5 = Digest::MD5.hexdigest(image) return if device_md5 == local_md5 raise Error, "MD5 mismatch after flashing (device #{device_md5}, local #{local_md5})" end |
#write_flash(image, offset: 0) ⇒ Object
138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/prremote/esp_flasher.rb', line 138 def write_flash(image, offset: 0) command(SPI_ATTACH, [0, 0].pack('V2')) # id, total size, block size, sector size, page size, status mask command(SPI_SET_PARAMS, [0, 4 * 1024 * 1024, 64 * 1024, 4096, 256, 0xFFFF].pack('V6')) blocks = (image.bytesize + FLASH_WRITE_SIZE - 1) / FLASH_WRITE_SIZE # The ROM erases the region inside FLASH_BEGIN; allow ~30 s per MB. erase_timeout = 30 * (1 + (image.bytesize / (1024 * 1024))) command(FLASH_BEGIN, [image.bytesize, blocks, FLASH_WRITE_SIZE, offset].pack('V4'), timeout: erase_timeout) stream_blocks(image, blocks) end |