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.



139
140
141
142
143
144
145
146
# File 'lib/prremote/esp_flasher.rb', line 139

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

.find_esptoolObject



122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/prremote/esp_flasher.rb', line 122

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

  warn ''
  warn 'Put the board in bootloader mode while "Connecting..." is shown:'
  warn '  XIAO ESP32C6: hold BOOT, press RST, release both.'

  cmd = [*esptool,
         '--chip', board, '--port', port,
         '--before', 'no-reset', '--after', 'no-reset',
         'write-flash', '0x0', image_path]
  warn "[flash] #{cmd.join(' ')}" if verbose
  system(*cmd) 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



246
247
248
# File 'lib/prremote/esp_flasher.rb', line 246

def checksum(data)
  data.bytes.reduce(CHECKSUM_SEED) { |acc, b| acc ^ b }
end

#command(op, payload, checksum: 0, timeout: 3) ⇒ Object

── request/response plumbing ──────────────────────────────────────────



231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/prremote/esp_flasher.rb', line 231

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



149
150
151
152
153
154
155
# File 'lib/prremote/esp_flasher.rb', line 149

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.



211
212
213
214
215
# File 'lib/prremote/esp_flasher.rb', line 211

def finish_flash
  command(FLASH_END, [1].pack('V'))
rescue Error
  nil
end

#hard_resetObject



157
158
159
160
161
# File 'lib/prremote/esp_flasher.rb', line 157

def hard_reset
  set_lines(dtr: false, rts: true)
  sleep 0.1
  set_lines(dtr: false, rts: false)
end

#slip_decode(frame) ⇒ Object



255
256
257
# File 'lib/prremote/esp_flasher.rb', line 255

def slip_decode(frame)
  frame.gsub("\xDB\xDC".b, "\xC0".b).gsub("\xDB\xDD".b, "\xDB".b)
end

#slip_encode(packet) ⇒ Object



250
251
252
253
# File 'lib/prremote/esp_flasher.rb', line 250

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:



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/prremote/esp_flasher.rb', line 165

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.



184
185
186
187
188
189
190
191
192
193
# File 'lib/prremote/esp_flasher.rb', line 184

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:



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/prremote/esp_flasher.rb', line 217

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



195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/prremote/esp_flasher.rb', line 195

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