Class: Prremote::EspFlasher
- Inherits:
-
Object
- Object
- Prremote::EspFlasher
- Defined in:
- lib/prremote/esp_flasher.rb
Overview
Pure-Ruby flasher for classic ESP32 (Xtensa) boards.
Speaks the Espressif serial bootloader protocol directly so
install --board esp32 needs no external tools.
For ESP32-C6 and other USB-JTAG/Serial chips the ROM does not support
direct flash write/erase (FLASH_BEGIN returns error 0x38 regardless of
parameters). esptool uploads a RAM stub before writing; we delegate those
boards to the esptool CLI instead of reimplementing the stub protocol.
Protocol reference: https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html
Defined Under Namespace
Classes: Error
Constant Summary collapse
- FLASH_BEGIN =
Command opcodes
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 =
max data per FLASH_DATA packet
0x400- CHECKSUM_SEED =
0xEF- ROM_BAUD =
115_200- USB_JTAG_SERIAL_BOARDS =
Boards with built-in USB Serial/JTAG — flashed via esptool subprocess.
%w[esp32c6].freeze
- STATUS_BYTES_BY_BOARD =
ROM status-byte count: classic ESP32 appends 4 bytes, RISC-V chips 2.
Hash.new(4).freeze
- SPI_ATTACH_LEGACY_BOARDS =
Classic ESP32 SPI_ATTACH takes [hspi_arg, extended_arg] (8 bytes); newer RISC-V chips take only [hspi_arg] (4 bytes).
%w[esp32].freeze
- MD5_HEX_LENGTH =
32- MD5_RAW_LENGTH =
16- TIOCM_DTR =
ioctl modem-control constants
0x0002- TIOCM_RTS =
0x0004- DARWIN =
RbConfig::CONFIG['host_os'] =~ /darwin/ ? true : false
- TIOCMGET =
DARWIN ? 0x4004746A : 0x5415
- TIOCMBIS =
DARWIN ? 0x8004746E : 0x5416
- TIOCMBIC =
DARWIN ? 0x8004746F : 0x5417
Class Method Summary collapse
- .baud_supported?(baud) ⇒ Boolean
-
.esptool_write(ctx, before:, after:, connect_attempts:) ⇒ Object
Runs one
esptool write-flash 0x0 <image>pass, returning esptool's success boolean. - .find_esptool ⇒ Object
-
.flash(port:, image_path:, baud: 230_400, board: nil, verbose: false) ⇒ Object
Entry point.
-
.flash_via_esptool(port:, image_path:, board:, verbose:) ⇒ Object
Flash via the
esptoolCLI (required for USB-JTAG/Serial boards whose ROM does not support direct write).
Instance Method Summary collapse
- #checksum(data) ⇒ Object
-
#command(op, payload, checksum: 0, timeout: 3) ⇒ Object
── request/response plumbing ──────────────────────────────────────────.
-
#enter_bootloader ⇒ Object
Classic auto-reset: RTS→EN, DTR→IO0 (via external UART bridge).
-
#finish_flash ⇒ Object
FLASH_END is a courtesy; hard_reset resets the chip anyway.
- #hard_reset ⇒ Object
-
#initialize(serial, fd: nil, status_bytes: 4, board: nil, verbose: false) ⇒ EspFlasher
constructor
serialneeds #read/#write;fdenables DTR/RTS control. - #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, status_bytes: 4, board: nil, verbose: false) ⇒ EspFlasher
serial needs #read/#write; fd enables DTR/RTS control.
160 161 162 163 164 165 166 167 |
# File 'lib/prremote/esp_flasher.rb', line 160 def initialize(serial, fd: nil, status_bytes: 4, board: nil, verbose: false) @serial = serial @fd = fd @rxbuf = +''.b @status_bytes = status_bytes @board = board.to_s @verbose = verbose end |
Class Method Details
.baud_supported?(baud) ⇒ Boolean
88 89 90 91 92 |
# File 'lib/prremote/esp_flasher.rb', line 88 def self.baud_supported?(baud) RubySerial::Posix::BAUDE_RATES.key?(baud) rescue NameError false end |
.esptool_write(ctx, before:, after:, connect_attempts:) ⇒ Object
Runs one esptool write-flash 0x0 <image> pass, returning esptool's
success boolean. connect_attempts 0 = retry the connect forever.
ctx carries the fixed invocation context (esptool/port/image_path/board/verbose).
133 134 135 136 137 138 139 140 141 |
# File 'lib/prremote/esp_flasher.rb', line 133 def self.esptool_write(ctx, before:, after:, connect_attempts:) cmd = [*ctx[:esptool], '--chip', ctx[:board], '--port', ctx[:port], '--connect-attempts', connect_attempts.to_s, '--before', before, '--after', after, 'write-flash', '0x0', ctx[:image_path]] warn "[flash] #{cmd.join(' ')}" if ctx[:verbose] system(*cmd) end |
.find_esptool ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/prremote/esp_flasher.rb', line 143 def self.find_esptool dirs = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR) %w[esptool esptool.py].each do |exe| return [exe] if dirs.any? { |d| (f = File.join(d, exe)) && File.executable?(f) && !File.directory?(f) } end # python3 -m esptool fallback: must actually import the module to verify return ['python3', '-m', 'esptool'] if system('python3', '-c', 'import esptool', out: File::NULL, err: File::NULL) nil rescue StandardError nil end |
.flash(port:, image_path:, baud: 230_400, board: nil, verbose: false) ⇒ Object
Entry point. Routes USB-JTAG/Serial boards through esptool; handles classic ESP32 with the pure-Ruby protocol implementation.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/prremote/esp_flasher.rb', line 57 def self.flash(port:, image_path:, baud: 230_400, board: nil, verbose: false) if USB_JTAG_SERIAL_BOARDS.include?(board) return flash_via_esptool(port: port, image_path: image_path, board: board, verbose: verbose) end unless RbConfig::CONFIG['host_os'] =~ /darwin|linux/ raise Error, 'pure-Ruby flashing supports macOS/Linux only; ' \ 'on other systems use esptool: ' \ "esptool write-flash 0x0 #{image_path}" end image = File.binread(image_path) status_bytes = STATUS_BYTES_BY_BOARD[board] serial = Serial.new(port, ROM_BAUD) flasher = new(serial, fd: serial.instance_variable_get(:@fd), status_bytes: status_bytes, board: board, verbose: verbose) begin flasher.enter_bootloader flasher.sync! upgrade = baud != ROM_BAUD && baud_supported?(baud) serial = flasher.upgrade_baud(port, baud) if upgrade flasher.write_flash(image, offset: 0) flasher.verify_md5(image, offset: 0) flasher.finish_flash flasher.hard_reset ensure serial.close end end |
.flash_via_esptool(port:, image_path:, board:, verbose:) ⇒ Object
Flash via the esptool CLI (required for USB-JTAG/Serial boards whose
ROM does not support direct write). esptool handles stub upload
internally; we just need it installed.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/prremote/esp_flasher.rb', line 97 def self.flash_via_esptool(port:, image_path:, board:, verbose:) esptool = find_esptool unless esptool raise Error, <<~MSG.strip Flashing #{board} requires esptool. Install: brew install esptool or: pip3 install esptool MSG end ctx = { esptool: esptool, port: port, image_path: image_path, board: board, verbose: verbose } # USB-Serial/JTAG chips (e.g. XIAO ESP32C6) can be dropped into the # download ROM over USB, so esptool's usb-reset flashes hands-free — no # BOOT/RST button dance. --after hard-reset then reboots straight into the # freshly flashed firmware. Verified on a physical XIAO ESP32C6. return if esptool_write(ctx, before: 'usb-reset', after: 'hard-reset', connect_attempts: 7) # Fallback for hosts/boards where usb-reset doesn't take: enter the # bootloader by hand and let esptool retry forever. --before no-reset never # resets the chip, so each attempt just re-sends SYNC until the manual # BOOT/RST lands the board in download mode — no timeout to race against. warn '' warn "Couldn't reset #{board} automatically. Put it in bootloader mode by hand:" warn ' XIAO ESP32C6: hold BOOT, press RST, release both.' warn 'No rush — esptool keeps retrying the SYNC handshake until it connects.' esptool_write(ctx, before: 'no-reset', after: 'no-reset', connect_attempts: 0) or raise Error, 'esptool exited with an error' warn '' warn 'Flash complete. Press RST to start the firmware.' end |
Instance Method Details
#checksum(data) ⇒ Object
267 268 269 |
# File 'lib/prremote/esp_flasher.rb', line 267 def checksum(data) data.bytes.reduce(CHECKSUM_SEED) { |acc, b| acc ^ b } end |
#command(op, payload, checksum: 0, timeout: 3) ⇒ Object
── request/response plumbing ──────────────────────────────────────────
252 253 254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/prremote/esp_flasher.rb', line 252 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
Classic auto-reset: RTS→EN, DTR→IO0 (via external UART bridge).
170 171 172 173 174 175 176 |
# File 'lib/prremote/esp_flasher.rb', line 170 def enter_bootloader 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
FLASH_END is a courtesy; hard_reset resets the chip anyway. The ROM has been seen returning error 0x06 here — ignore it.
232 233 234 235 236 |
# File 'lib/prremote/esp_flasher.rb', line 232 def finish_flash command(FLASH_END, [1].pack('V')) rescue Error nil end |
#hard_reset ⇒ Object
178 179 180 181 182 |
# File 'lib/prremote/esp_flasher.rb', line 178 def hard_reset set_lines(dtr: false, rts: true) sleep 0.1 set_lines(dtr: false, rts: false) end |
#slip_decode(frame) ⇒ Object
276 277 278 |
# File 'lib/prremote/esp_flasher.rb', line 276 def slip_decode(frame) frame.gsub("\xDB\xDC".b, "\xC0".b).gsub("\xDB\xDD".b, "\xDB".b) end |
#slip_encode(packet) ⇒ Object
271 272 273 274 |
# File 'lib/prremote/esp_flasher.rb', line 271 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 ─────────────────────────────────────────────────────
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/prremote/esp_flasher.rb', line 186 def sync! payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32') synced = 8.times.any? do |i| @rxbuf.clear begin vlog format('sync: attempt %<n>d/8 (status_bytes=%<sb>d)', n: i + 1, sb: @status_bytes) command(SYNC, payload, timeout: 0.5) drain_responses vlog 'sync: success' true rescue Error => e vlog format('sync: attempt %<n>d failed (%<msg>s)', n: i + 1, msg: e.) false end end raise Error, 'could not sync with the ESP boot ROM' unless synced end |
#upgrade_baud(port, baud) ⇒ Object
CHANGE_BAUDRATE, then reopen the port at the new speed.
205 206 207 208 209 210 211 212 213 214 |
# File 'lib/prremote/esp_flasher.rb', line 205 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
238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/prremote/esp_flasher.rb', line 238 def verify_md5(image, offset: 0) timeout = 8 * (1 + (image.bytesize / (1024 * 1024))) _v, data = command(SPI_FLASH_MD5, [offset, image.bytesize, 0, 0].pack('V4'), timeout: timeout) # Classic ESP32 ROM returns 32 hex ASCII chars; detect by length. device_md5 = data.bytesize >= MD5_HEX_LENGTH ? data[0, MD5_HEX_LENGTH] : data[0, MD5_RAW_LENGTH].unpack1('H*') 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
216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/prremote/esp_flasher.rb', line 216 def write_flash(image, offset: 0) spi_attach_payload = SPI_ATTACH_LEGACY_BOARDS.include?(@board) ? [0, 0].pack('V2') : [0].pack('V') command(SPI_ATTACH, spi_attach_payload) # 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 erase_size = blocks * FLASH_WRITE_SIZE erase_timeout = 30 * (1 + (erase_size / (1024 * 1024))) command(FLASH_BEGIN, [erase_size, blocks, FLASH_WRITE_SIZE, offset].pack('V4'), timeout: erase_timeout) stream_blocks(image, blocks) end |