esp_flash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 50f62f253aa6b84339218e9068ee71e6520c4aabab1ee0d3cf2bf0d620e79389
4
+ data.tar.gz: cd6dbc3b007949f43864d5711754c5dd83ce8bbe19992bb607ea3b493219e638
5
+ SHA512:
6
+ metadata.gz: 0cfc138a83ea59bddc5f6b937a390948169c843b4bb592b9828a9672ef8c74a4b8d49268779f38c14f136d3c73d0cf050f7ebee01359de148ac8d1b381cb54e5
7
+ data.tar.gz: fdc1c0f052cd531009bf91a305252c2b630c04325b915ff1368ae3ee45b9c0157a52f85da43eb1bfc8cdccc6036fea9159722f2549aed416affacfa69ceb5101
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Okazaki Yuhei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # esp_flash
2
+
3
+ Flash and monitor ESP32-family chips from plain Ruby: a client of the
4
+ chip's ROM loader (running Espressif's flasher stub for speed) and a serial
5
+ monitor. No esptool, no Python, no C extension, no other gem; it needs only
6
+ Ruby's standard library.
7
+
8
+ Supported chips with the flasher stub: ESP32, ESP32-S3, ESP32-C3, ESP32-C6,
9
+ ESP32-H2. Others are written through the ROM loader. Serial ports work on
10
+ Linux, macOS and Windows, and `socket://HOST:PORT` connects to a TCP port
11
+ instead (QEMU's UART, for one).
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ gem install esp_flash
17
+ ```
18
+
19
+ or, in a Gemfile:
20
+
21
+ ```ruby
22
+ gem "esp_flash"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Flashing the images of an ESP-IDF build, as `build/flasher_args.json`
28
+ lists them:
29
+
30
+ ```ruby
31
+ require "esp_flash"
32
+ require "json"
33
+
34
+ args = JSON.parse(File.read("build/flasher_args.json"))
35
+ port = EspFlash::SerialPort.resolve(ENV["PORT"]) # PORT, or the one connected board
36
+
37
+ loader = EspFlash::Loader.open(port, chip: args.dig("extra_esptool_args", "chip"),
38
+ baud: 460_800,
39
+ flash_size: EspFlash.parse_size(args.dig("flash_settings", "flash_size")))
40
+ begin
41
+ args["flash_files"].each do |addr, file|
42
+ loader.write_flash(Integer(addr, 16), File.binread(File.join("build", file)), name: file)
43
+ end
44
+ loader.hard_reset
45
+ ensure
46
+ loader.close
47
+ end
48
+ ```
49
+
50
+ `stub: false` writes through the ROM loader instead of the flasher stub.
51
+
52
+ Monitoring the console:
53
+
54
+ ```ruby
55
+ EspFlash::Monitor.new(port, baud: 115_200, target: "esp32s3", elf: "build/app.elf").run
56
+ ```
57
+
58
+ In the monitor, Ctrl-] quits, Ctrl-T Ctrl-R resets the chip and Ctrl-T
59
+ Ctrl-H lists the keys. It resets the chip first unless given `reset: false`.
60
+ With an ELF and the target's `addr2line` on `PATH`, a panic's backtrace is
61
+ decoded.
62
+
63
+ `EspFlash.merge_images` builds one flash image from `{ offset => data }`,
64
+ like `esptool merge_bin`. Errors are raised as `EspFlash::Error`.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ bundle install
70
+ bundle exec rake test
71
+ ```
72
+
73
+ Releasing: bump `EspFlash::VERSION` in `lib/esp_flash/version.rb`, commit,
74
+ and run `bundle exec rake release`, which tags `v<version>`, pushes the tag
75
+ and pushes the gem to RubyGems.
76
+
77
+ ## License
78
+
79
+ MIT (`LICENSE`). The flasher stubs in `lib/esp_flash/stub` are the binaries
80
+ of [esp-flasher-stub](https://github.com/espressif/esp-flasher-stub) v1.2.2,
81
+ under the Apache License 2.0 or the MIT license (see that directory).
82
+
83
+ This library started in [R2P2-ESP32](https://github.com/picoruby/R2P2-ESP32)
84
+ as `lib/esp_flash`.
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+ require "json"
5
+ require "zlib"
6
+
7
+ module EspFlash
8
+ # Writes images to the SPI flash through the serial bootloader in the chip's ROM (the protocol esptool
9
+ # speaks: https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html).
10
+ #
11
+ # Once connected, the loader uploads the flasher stub of esp-flasher-stub (stub/, MIT or Apache-2.0) into
12
+ # the chip's RAM and talks to that instead, as esptool does: it takes 16 KB blocks and erases as it
13
+ # writes, which is much faster than the ROM loader. With stub: false (or when there is no stub for the
14
+ # chip, or over TCP to QEMU, which cannot run it) the ROM loader does the work itself.
15
+ # Images are written as they are; their headers are not patched for flash mode/size/frequency, since
16
+ # ESP-IDF builds them with the values from sdkconfig (the ones flasher_args.json gives to esptool).
17
+ class Loader
18
+ FLASH_BEGIN = 0x02
19
+ FLASH_DATA = 0x03
20
+ FLASH_END = 0x04
21
+ MEM_BEGIN = 0x05
22
+ MEM_END = 0x06
23
+ MEM_DATA = 0x07
24
+ SYNC = 0x08
25
+ WRITE_REG = 0x09
26
+ READ_REG = 0x0a
27
+ SPI_SET_PARAMS = 0x0b
28
+ SPI_ATTACH = 0x0d
29
+ CHANGE_BAUDRATE = 0x0f
30
+ FLASH_DEFL_BEGIN = 0x10
31
+ FLASH_DEFL_DATA = 0x11
32
+ FLASH_DEFL_END = 0x12
33
+ SPI_FLASH_MD5 = 0x13
34
+ GET_SECURITY_INFO = 0x14
35
+
36
+ ROM_BAUD = 115_200
37
+ BLOCK_SIZE = 0x400 # the ROM loader's FLASH_WRITE_SIZE
38
+ STUB_BLOCK_SIZE = 0x4000 # the stub's
39
+ RAM_BLOCK_SIZE = 0x1800
40
+ STUB_DIR = File.join(__dir__, "stub")
41
+ CHECKSUM_SEED = 0xef
42
+ CHIP_DETECT_MAGIC_REG = 0x40001000
43
+
44
+ # Chip => [image chip id (GET_SECURITY_INFO), values of CHIP_DETECT_MAGIC_REG]
45
+ CHIPS = {
46
+ "esp32" => [0, [0x00f01d83]],
47
+ "esp32s2" => [2, [0x000007c6]],
48
+ "esp32c3" => [5, [0x6921506f, 0x1b31506f, 0x4881606f, 0x4361606f]],
49
+ "esp32s3" => [9, [0x9]],
50
+ "esp32c2" => [12, [0x6f51306f, 0x7c41a06f]],
51
+ "esp32c6" => [13, [0x2ce0806f]],
52
+ "esp32h2" => [16, [0xd7b73e80]],
53
+ "esp32p4" => [18, [0x0, 0x0addbad0]],
54
+ "esp32c5" => [23, []],
55
+ }.freeze
56
+
57
+ # Chip => [RTC WDT write protect, RTC WDT config0, SWD write protect, SWD config, SWD auto feed bit, SWD key].
58
+ # Over the USB-Serial/JTAG nothing feeds these watchdogs while the chip waits in the download mode, and
59
+ # they can reset it in the middle of flashing; esptool disables them the same way.
60
+ WATCHDOGS = {
61
+ "esp32c3" => [0x600080a8, 0x60008090, 0x600080b0, 0x600080ac, 1 << 31, 0x8f1d312a],
62
+ "esp32s3" => [0x600080b0, 0x60008098, 0x600080b8, 0x600080b4, 1 << 31, 0x8f1d312a],
63
+ "esp32c6" => [0x600b1c18, 0x600b1c00, 0x600b1c20, 0x600b1c1c, 1 << 18, 0x50d83aa1],
64
+ "esp32h2" => [0x600b1c1c, 0x600b1c00, 0x600b1c24, 0x600b1c20, 1 << 18, 0x50d83aa1],
65
+ "esp32p4" => [0x50116018, 0x50116000, 0x50116020, 0x5011601c, 1 << 18, 0x50d83aa1],
66
+ }.freeze
67
+ WDT_KEY = 0x50d83aa1
68
+
69
+ ROM_ERRORS = {
70
+ 0x05 => "received message is invalid",
71
+ 0x06 => "failed to act on received message",
72
+ 0x07 => "invalid CRC in message",
73
+ 0x08 => "flash write error",
74
+ 0x09 => "flash read error",
75
+ 0x0a => "flash read length error",
76
+ 0x0b => "deflate error",
77
+ }.freeze
78
+
79
+ attr_reader :chip
80
+
81
+ # Opens +port+, resets the chip into its ROM loader, syncs with it and starts the flasher stub (unless
82
+ # +stub+ is false). +chip+ (e.g. "esp32s3") is what the image was built for; flashing another chip is
83
+ # refused. +log+ receives progress messages.
84
+ def self.open(port, chip:, baud: 460_800, flash_size: nil, reset: true, stub: true, log: $stdout)
85
+ loader = new(SerialPort.open(port, ROM_BAUD), log: log)
86
+ begin
87
+ loader.connect(reset: reset)
88
+ loader.check_chip(chip)
89
+ loader.disable_watchdogs
90
+ loader.run_stub if stub
91
+ loader.attach_flash(flash_size)
92
+ loader.change_baud(baud) if baud && baud != ROM_BAUD
93
+ loader
94
+ rescue StandardError
95
+ loader.close
96
+ raise
97
+ end
98
+ end
99
+
100
+ def initialize(port, log: $stdout)
101
+ @port = port
102
+ @log = log
103
+ @buffer = "".b
104
+ end
105
+
106
+ # Resets into the ROM loader (when the port can) and syncs, trying the reset sequences esptool uses.
107
+ def connect(reset: true, attempts: 7)
108
+ @log.print "Connecting to #{@port.path}..."
109
+ strategies = reset && @port.resettable? ? reset_strategies : [nil]
110
+ attempts.times do |i|
111
+ strategy = strategies[i % strategies.size]
112
+ send(*strategy) if strategy
113
+ @port.discard_input
114
+ @buffer.clear
115
+ if sync
116
+ @log.puts " connected."
117
+ return
118
+ end
119
+ @log.print "."
120
+ end
121
+ @log.puts
122
+ raise Error, "Failed to connect to the chip on #{@port.path}. " \
123
+ "Hold BOOT (GPIO0) while pressing RESET to enter the download mode, and try again."
124
+ end
125
+
126
+ # The chip the ROM reports, checked against what the image was built for.
127
+ def check_chip(expected)
128
+ @chip = detect_chip
129
+ @log.puts "Chip is #{@chip || "unknown"}"
130
+ return if expected.nil? || @chip.nil? || @chip == expected
131
+
132
+ raise Error, "This image is for #{expected}, but the connected chip is #{@chip}."
133
+ end
134
+
135
+ def disable_watchdogs
136
+ return unless @port.usb_jtag_serial? != false && (regs = WATCHDOGS[@chip])
137
+
138
+ wdt_protect, wdt_config, swd_protect, swd_config, swd_auto_feed, swd_key = regs
139
+ write_reg(wdt_protect, WDT_KEY)
140
+ write_reg(wdt_config, 0)
141
+ write_reg(wdt_protect, 0)
142
+ write_reg(swd_protect, swd_key)
143
+ write_reg(swd_config, read_reg(swd_config) | swd_auto_feed)
144
+ write_reg(swd_protect, 0)
145
+ end
146
+
147
+ # Uploads the flasher stub for the chip into its RAM and runs it. Nothing happens when there is no stub
148
+ # for the chip, or the port is a TCP socket (QEMU, which cannot run the stub).
149
+ def run_stub
150
+ return if @stub || !@port.resettable? || @chip.nil?
151
+
152
+ file = File.join(STUB_DIR, "#{@chip}.json")
153
+ return unless File.exist?(file)
154
+
155
+ stub = JSON.parse(File.read(file))
156
+ %w[text data].each do |segment|
157
+ next unless stub[segment]
158
+
159
+ upload_to_ram(stub[segment].unpack1("m"), stub["#{segment}_start"])
160
+ end
161
+ # The response to MEM_END may be lost as the stub takes over the UART; its greeting is what counts.
162
+ send_packet(MEM_END, [0, stub["entry"]].pack("VV"))
163
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
164
+ loop do
165
+ break if read_packet(deadline) == "OHAI"
166
+ end
167
+ @stub = true
168
+ @log.puts "Stub flasher running."
169
+ rescue TimeoutError
170
+ raise Error, "The flasher stub did not start. Try again with NO_STUB=1 (writes through the ROM loader)."
171
+ end
172
+
173
+ # Enables the default SPI flash pins and tells the flash geometry.
174
+ def attach_flash(flash_size)
175
+ # The ROM takes an extra "is legacy" word
176
+ command(SPI_ATTACH, @stub ? [0].pack("V") : [0, 0].pack("VV"))
177
+ size = flash_size || 16 * 1024 * 1024
178
+ command(SPI_SET_PARAMS, [0, size, 64 * 1024, 4 * 1024, 256, 0xffff].pack("V6"))
179
+ end
180
+
181
+ def change_baud(baud)
182
+ return unless @port.resettable? # a TCP socket has no baud rate
183
+
184
+ # The stub also takes the current baud rate
185
+ command(CHANGE_BAUDRATE, [baud, @stub ? ROM_BAUD : 0].pack("VV"))
186
+ @port.baud = baud
187
+ sleep 0.05
188
+ @port.discard_input
189
+ @buffer.clear
190
+ @log.puts "Changed baud rate to #{baud}"
191
+ end
192
+
193
+ # Writes +data+ at +offset+ compressed, then checks it against the MD5 the chip computes.
194
+ def write_flash(offset, data, name: nil)
195
+ data = data.b
196
+ data += "\xff".b * (-data.bytesize % 4)
197
+ compressed = Zlib::Deflate.deflate(data, Zlib::BEST_COMPRESSION)
198
+ block_size = @stub ? STUB_BLOCK_SIZE : BLOCK_SIZE
199
+ blocks = (compressed.bytesize + block_size - 1) / block_size
200
+ label = format("0x%08x", offset)
201
+ label += " (#{name})" if name
202
+
203
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
204
+ if @stub
205
+ # The stub erases as it writes
206
+ command(FLASH_DEFL_BEGIN, [data.bytesize, blocks, block_size, offset, 0].pack("V5"))
207
+ else
208
+ # The ROM erases the whole region first
209
+ erase_size = (data.bytesize + block_size - 1) / block_size * block_size
210
+ params = [erase_size, blocks, block_size, offset].pack("V4")
211
+ params += [0].pack("V") if encrypted_flag?
212
+ command(FLASH_DEFL_BEGIN, params, timeout: timeout_per_mb(30, erase_size))
213
+ end
214
+
215
+ blocks.times do |seq|
216
+ chunk = compressed.byteslice(seq * block_size, block_size)
217
+ command(FLASH_DEFL_DATA, [chunk.bytesize, seq, 0, 0].pack("V4") + chunk,
218
+ checksum: checksum(chunk), timeout: 10)
219
+ progress(label, seq + 1, blocks)
220
+ end
221
+ # The stub acknowledges a block before writing it; this returns once the last one is written.
222
+ command(FLASH_DEFL_END, [1].pack("V"), timeout: 10) if @stub
223
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
224
+ @log.puts format("%sWrote %d bytes (%d compressed) at %s in %.1f s", tty? ? "\r" : "", data.bytesize,
225
+ compressed.bytesize, label, elapsed)
226
+
227
+ verify(offset, data)
228
+ end
229
+
230
+ # Resets the chip into the application (the ROM loader is left as it is when the port cannot reset).
231
+ def hard_reset
232
+ return unless @port.resettable?
233
+
234
+ @log.puts "Hard resetting via RTS pin..."
235
+ EspFlash.hard_reset(@port)
236
+ end
237
+
238
+ def close
239
+ @port.close
240
+ end
241
+
242
+ private
243
+
244
+ def reset_strategies
245
+ usb_jtag = @port.usb_jtag_serial?
246
+ return [[:usb_jtag_reset]] if usb_jtag
247
+
248
+ tight = @port.is_a?(SerialPort::PosixPort)
249
+ list = [0.05, 0.55].flat_map do |delay|
250
+ [([:tight_reset, delay] if tight), [:classic_reset, delay]].compact
251
+ end
252
+ usb_jtag.nil? ? list + [[:usb_jtag_reset]] : list
253
+ end
254
+
255
+ # The reset sequences of esptool (esptool/reset.py). On a board's auto-reset circuit DTR drives
256
+ # GPIO0 and RTS drives EN; "true" asserts a line, which pulls the pin low.
257
+ def classic_reset(delay)
258
+ @port.set_lines(dtr: false)
259
+ @port.set_lines(rts: true) # EN low: in reset
260
+ sleep 0.1
261
+ @port.set_lines(dtr: true) # GPIO0 low
262
+ @port.set_lines(rts: false) # EN high: out of reset, into the download mode
263
+ sleep delay
264
+ @port.set_lines(dtr: false)
265
+ end
266
+
267
+ # Like classic_reset, with both lines changed at once (some USB-UART bridges need that).
268
+ def tight_reset(delay)
269
+ @port.set_lines(dtr: false, rts: false)
270
+ @port.set_lines(dtr: true, rts: true)
271
+ @port.set_lines(dtr: false, rts: true)
272
+ sleep 0.1
273
+ @port.set_lines(dtr: true, rts: false)
274
+ sleep delay
275
+ @port.set_lines(dtr: false, rts: false)
276
+ @port.set_lines(dtr: false)
277
+ end
278
+
279
+ # For the USB-Serial/JTAG controller, which must go through (1, 1) instead of (0, 0).
280
+ def usb_jtag_reset
281
+ @port.set_lines(rts: false)
282
+ @port.set_lines(dtr: false)
283
+ sleep 0.1
284
+ @port.set_lines(dtr: true)
285
+ @port.set_lines(rts: false)
286
+ sleep 0.1
287
+ @port.set_lines(rts: true)
288
+ @port.set_lines(dtr: false)
289
+ @port.set_lines(rts: true)
290
+ sleep 0.1
291
+ @port.set_lines(dtr: false)
292
+ @port.set_lines(rts: false)
293
+ end
294
+
295
+ def sync
296
+ payload = [0x07, 0x07, 0x12, 0x20].pack("C4") + ("\x55".b * 32)
297
+ 5.times do
298
+ begin
299
+ command(SYNC, payload, timeout: 0.1)
300
+ # The ROM answers a SYNC several times; drop the rest.
301
+ sleep 0.05
302
+ @port.discard_input
303
+ @buffer.clear
304
+ return true
305
+ rescue Error
306
+ next
307
+ end
308
+ end
309
+ false
310
+ end
311
+
312
+ def detect_chip
313
+ info = command(GET_SECURITY_INFO, "", timeout: 1)
314
+ if info.bytesize >= 20
315
+ chip_id = info.byteslice(12, 4).unpack1("V")
316
+ found = CHIPS.find { |_name, (id, _magic)| id == chip_id }
317
+ return found.first if found
318
+ end
319
+ magic_chip
320
+ rescue Error
321
+ magic_chip # the ESP32 ROM does not know GET_SECURITY_INFO
322
+ end
323
+
324
+ def magic_chip
325
+ magic = read_reg(CHIP_DETECT_MAGIC_REG)
326
+ CHIPS.find { |_name, (_id, values)| values.include?(magic) }&.first
327
+ end
328
+
329
+ def read_reg(address)
330
+ command(READ_REG, [address].pack("V"), want_value: true)
331
+ end
332
+
333
+ def write_reg(address, value)
334
+ command(WRITE_REG, [address, value, 0xffffffff, 0].pack("V4"))
335
+ end
336
+
337
+ def upload_to_ram(data, address)
338
+ blocks = (data.bytesize + RAM_BLOCK_SIZE - 1) / RAM_BLOCK_SIZE
339
+ command(MEM_BEGIN, [data.bytesize, blocks, RAM_BLOCK_SIZE, address].pack("V4"))
340
+ blocks.times do |seq|
341
+ chunk = data.byteslice(seq * RAM_BLOCK_SIZE, RAM_BLOCK_SIZE)
342
+ command(MEM_DATA, [chunk.bytesize, seq, 0, 0].pack("V4") + chunk, checksum: checksum(chunk))
343
+ end
344
+ end
345
+
346
+ # The ROM loader ends every response with 4 status bytes, the stub with 2.
347
+ def status_bytes
348
+ @stub ? 2 : 4
349
+ end
350
+
351
+ def encrypted_flag?
352
+ # Every ROM loader after the ESP32's takes an extra "encrypted" word in FLASH_(DEFL_)BEGIN.
353
+ @chip != "esp32"
354
+ end
355
+
356
+ def verify(offset, data)
357
+ response = command(SPI_FLASH_MD5, [offset, data.bytesize, 0, 0].pack("V4"),
358
+ timeout: timeout_per_mb(8, data.bytesize))
359
+ # The stub answers with the 16 bytes of the MD5, the ROM with them in hex
360
+ actual = @stub ? response.byteslice(0, 16).unpack1("H*") : response.byteslice(0, 32).downcase
361
+ expected = Digest::MD5.hexdigest(data)
362
+ raise Error, format("Verification failed at 0x%08x: flash has MD5 %s, expected %s", offset, actual, expected) \
363
+ unless actual == expected
364
+
365
+ @log.puts "Hash of data verified."
366
+ end
367
+
368
+ def checksum(data)
369
+ data.each_byte.reduce(CHECKSUM_SEED) { |sum, byte| sum ^ byte }
370
+ end
371
+
372
+ def timeout_per_mb(seconds, size)
373
+ [3.0, seconds * size / 1_000_000.0].max
374
+ end
375
+
376
+ def tty?
377
+ @log.respond_to?(:tty?) && @log.tty?
378
+ end
379
+
380
+ def progress(label, done, total)
381
+ return unless tty?
382
+ return unless done == total || (done % 16).zero?
383
+
384
+ @log.print format("\rWriting at %s... (%d %%)", label, done * 100 / total)
385
+ end
386
+
387
+ # Sends a command and returns the data of its response (or its 32-bit value when +want_value+).
388
+ def command(op, data, checksum: 0, timeout: 3, want_value: false)
389
+ send_packet(op, data, checksum)
390
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
391
+ loop do
392
+ packet = read_packet(deadline)
393
+ next if packet.bytesize < 8
394
+
395
+ direction, resp_op, _size, value = packet.unpack("CCvV")
396
+ next unless direction == 0x01 && resp_op == op
397
+
398
+ body = packet.byteslice(8..)
399
+ status = body.byteslice(-status_bytes, status_bytes) if body.bytesize >= status_bytes
400
+ raise Error, format("Command 0x%02x: no status in the response", op) unless status
401
+ if status.getbyte(0) != 0
402
+ code = status.getbyte(1)
403
+ raise Error, format("Command 0x%02x failed: %s (0x%02x)", op, ROM_ERRORS.fetch(code, "unknown error"), code)
404
+ end
405
+
406
+ return want_value ? value : body.byteslice(0, body.bytesize - status_bytes)
407
+ end
408
+ end
409
+
410
+ def send_packet(op, data, checksum = 0)
411
+ @port.write(slip_encode([0x00, op, data.bytesize, checksum].pack("CCvV") + data))
412
+ end
413
+
414
+ def slip_encode(packet)
415
+ "\xc0".b + packet.b.gsub("\xdb".b, "\xdb\xdd".b).gsub("\xc0".b, "\xdb\xdc".b) + "\xc0".b
416
+ end
417
+
418
+ # The next SLIP frame, skipping anything outside frames (such as the boot log).
419
+ def read_packet(deadline)
420
+ loop do
421
+ if (start = @buffer.index("\xc0".b))
422
+ if (stop = @buffer.index("\xc0".b, start + 1))
423
+ frame = @buffer.byteslice(start + 1, stop - start - 1)
424
+ @buffer = @buffer.byteslice(stop..) # the closing 0xc0 may open the next frame
425
+ next if frame.empty?
426
+
427
+ return frame.gsub(/\xdb[\xdc\xdd]/n) { |escape| escape.getbyte(1) == 0xdc ? "\xc0".b : "\xdb".b }
428
+ end
429
+ @buffer = @buffer.byteslice(start..)
430
+ else
431
+ @buffer.clear
432
+ end
433
+
434
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
435
+ raise TimeoutError, "Timed out waiting for a response from the chip" if remaining <= 0
436
+
437
+ @buffer << @port.read(4096, remaining)
438
+ end
439
+ end
440
+ end
441
+ end