Class: Prremote::EspFlasher

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

Returns:

  • (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_esptoolObject



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_bootloaderObject

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_flashObject

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_resetObject



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 ─────────────────────────────────────────────────────

Raises:



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.message)
      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

Raises:



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