Class: Prremote::EspFlasher

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

Instance Method Summary collapse

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.

Returns:

  • (Boolean)


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_bootloaderObject

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

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_resetObject



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

Raises:



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

Raises:



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