prremote 0.2.0 → 0.2.1
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 +4 -4
- data/README.md +21 -13
- data/VERSION +1 -1
- data/lib/prremote/cli.rb +19 -6
- data/lib/prremote/commands/deploy.rb +1 -1
- data/lib/prremote/commands/install.rb +6 -5
- data/lib/prremote/commands/run.rb +1 -1
- data/lib/prremote/commands/serial_helpers.rb +19 -0
- data/lib/prremote/esp_flasher.rb +131 -83
- data/lib/prremote/runtime_manager.rb +6 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c315e2104e35b23c87d7565249c2695f0bc8b9f7d0075564dda98b8fcc11491
|
|
4
|
+
data.tar.gz: 3e8ae2b89ca93b33f7830d45413dadef6f7c2d1d0a19d51499649214d626f1a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8bc5946b3d296fd7cfac88817b7e212f9713b13fa4a1f1748cacf84e278b0e269b0552f46b9eb0804e2ca319e50fd9e1bb48a4d81583c96f21bbcf468aa9d735
|
|
7
|
+
data.tar.gz: 8a467d706edf4600859d7563ab4dae1a29efd3eaaeff7d4f274d0be57412129eb44777fe099620733d0e773b0d17517751c7cb41e76714655803e038e8c5629a
|
data/README.md
CHANGED
|
@@ -17,9 +17,10 @@ Inspired by [mpremote](https://docs.micropython.org/en/latest/reference/mpremote
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
19
|
- Ruby 3.4 or later
|
|
20
|
-
-
|
|
20
|
+
- Supported boards:
|
|
21
21
|
- Raspberry Pi Pico W / Pico
|
|
22
22
|
- ESP32 (classic) — e.g. M5GO / M5Stack Core gen1, generic dev boards
|
|
23
|
+
- ESP32-C6 (RISC-V) — e.g. Seeed Studio XIAO ESP32C6
|
|
23
24
|
- `mrbc` (mruby 4.x) for `run`, `deploy`, and `eval`
|
|
24
25
|
- macOS: `brew install mruby`
|
|
25
26
|
- Linux: build from source — [github.com/mruby/mruby/releases](https://github.com/mruby/mruby/releases)
|
|
@@ -56,21 +57,24 @@ prremote run app.rb
|
|
|
56
57
|
|
|
57
58
|
### `install`
|
|
58
59
|
|
|
59
|
-
Flash the prremote runtime firmware to a
|
|
60
|
+
Flash the prremote runtime firmware to a supported board.
|
|
60
61
|
|
|
61
62
|
```bash
|
|
62
|
-
prremote install #
|
|
63
|
-
prremote install
|
|
64
|
-
prremote install
|
|
65
|
-
prremote install
|
|
66
|
-
prremote install
|
|
63
|
+
prremote install # show supported boards
|
|
64
|
+
prremote install -b picow # Pico W
|
|
65
|
+
prremote install -b pico # Pico (no wireless)
|
|
66
|
+
prremote install -b esp32 # ESP32 (M5GO / M5Stack Core, etc.)
|
|
67
|
+
prremote install -b esp32c6 # ESP32-C6 (e.g. XIAO ESP32C6)
|
|
68
|
+
prremote install -b picow --version 0.1.1 # specify a runtime version
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
`-b` / `--board` selects the target board. Running `install` without `--board` prints the list of supported boards.
|
|
72
|
+
|
|
69
73
|
The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
|
|
70
74
|
|
|
71
75
|
Pico boards: put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
|
|
72
76
|
|
|
73
|
-
ESP32 boards: no button dance and no extra tools needed — the firmware is written over the serial port by prremote's pure-Ruby implementation of the Espressif bootloader protocol (the chip is reset into its boot ROM automatically, and the write is verified with an on-chip MD5). Reflashing the runtime does not erase a deployed script.
|
|
77
|
+
ESP32 / ESP32-C6 boards: no button dance and no extra tools needed — the firmware is written over the serial port by prremote's pure-Ruby implementation of the Espressif bootloader protocol (the chip is reset into its boot ROM automatically, and the write is verified with an on-chip MD5). Reflashing the runtime does not erase a deployed script.
|
|
74
78
|
|
|
75
79
|
---
|
|
76
80
|
|
|
@@ -91,7 +95,7 @@ prremote run lib.rb main.rb
|
|
|
91
95
|
|
|
92
96
|
The device responds with `RUNNING`, streams any output, then `DONE`.
|
|
93
97
|
|
|
94
|
-
You can find some examples in [
|
|
98
|
+
You can find some examples in [examples](examples/).
|
|
95
99
|
|
|
96
100
|
---
|
|
97
101
|
|
|
@@ -168,8 +172,8 @@ Show the gem version, mrbc version, and the connected device's runtime version.
|
|
|
168
172
|
|
|
169
173
|
```bash
|
|
170
174
|
prremote version
|
|
171
|
-
# prremote: 0.2.
|
|
172
|
-
# runtime: 0.2.
|
|
175
|
+
# prremote: 0.2.1
|
|
176
|
+
# runtime: 0.2.1 (/dev/tty.usbmodem101)
|
|
173
177
|
# mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
|
|
174
178
|
```
|
|
175
179
|
|
|
@@ -180,7 +184,7 @@ prremote version
|
|
|
180
184
|
| Option | Description |
|
|
181
185
|
|---|---|
|
|
182
186
|
| `--port`, `-p PORT` | Serial port (default: auto-detect) |
|
|
183
|
-
| `--baud
|
|
187
|
+
| `--baud N` | Baud rate (default: `115200`) |
|
|
184
188
|
|
|
185
189
|
---
|
|
186
190
|
|
|
@@ -188,7 +192,7 @@ prremote version
|
|
|
188
192
|
|
|
189
193
|
```bash
|
|
190
194
|
# First-time setup
|
|
191
|
-
prremote install
|
|
195
|
+
prremote install -b picow # or: pico / esp32 / esp32c6
|
|
192
196
|
|
|
193
197
|
# Manual cycle
|
|
194
198
|
prremote run app.rb # compile + run (one-shot)
|
|
@@ -281,3 +285,7 @@ bundle exec rake test
|
|
|
281
285
|
- [mruby/c](https://github.com/mrubyc/mrubyc) — Lightweight mruby implementation used in the runtime
|
|
282
286
|
- [picotool](https://github.com/raspberrypi/picotool) — Official Raspberry Pi tool for inspecting and managing Pico devices; useful for checking what's on flash or force-rebooting outside of prremote
|
|
283
287
|
- [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) — MicroPython equivalent (inspiration)
|
|
288
|
+
|
|
289
|
+
https://wiki.seeedstudio.com/ja/xiao_esp32c6_getting_started/
|
|
290
|
+
|
|
291
|
+
brew install esptool
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.1
|
data/lib/prremote/cli.rb
CHANGED
|
@@ -16,7 +16,7 @@ require_relative 'commands/watch'
|
|
|
16
16
|
module Prremote
|
|
17
17
|
class CLI < Thor
|
|
18
18
|
class_option :port, aliases: '-p', desc: 'Serial port (default: auto-detect)'
|
|
19
|
-
class_option :baud,
|
|
19
|
+
class_option :baud, type: :numeric, default: 115_200, desc: 'Baud rate'
|
|
20
20
|
|
|
21
21
|
def self.exit_on_failure?
|
|
22
22
|
true
|
|
@@ -24,17 +24,30 @@ module Prremote
|
|
|
24
24
|
|
|
25
25
|
remove_command :tree
|
|
26
26
|
|
|
27
|
-
desc 'install', 'Flash prremote runtime firmware to
|
|
27
|
+
desc 'install', 'Flash prremote runtime firmware to a supported board'
|
|
28
28
|
option :version, type: :string, desc: "Firmware version to install (default: #{VERSION})"
|
|
29
|
-
option :board,
|
|
29
|
+
option :board, aliases: '-b', type: :string,
|
|
30
|
+
desc: "Board type: #{RuntimeManager::BOARDS.join(', ')}"
|
|
31
|
+
option :verbose, aliases: '-V', type: :boolean, default: false,
|
|
32
|
+
desc: 'Print step-by-step flash diagnostics'
|
|
30
33
|
def install
|
|
31
34
|
version = options[:version] || VERSION
|
|
32
|
-
board = options[:board]
|
|
35
|
+
board = options[:board]
|
|
36
|
+
|
|
37
|
+
unless board
|
|
38
|
+
puts "Specify a board with --board / -b. Supported boards:"
|
|
39
|
+
RuntimeManager::BOARDS.each { |b| puts " #{b}" }
|
|
40
|
+
puts
|
|
41
|
+
puts "Example: prremote install -b esp32c6"
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
33
45
|
unless RuntimeManager::BOARDS.include?(board)
|
|
34
|
-
raise Thor::Error, "Unknown board '#{board}'.
|
|
46
|
+
raise Thor::Error, "Unknown board '#{board}'. Supported boards: #{RuntimeManager::BOARDS.join(', ')}"
|
|
35
47
|
end
|
|
36
48
|
|
|
37
|
-
Commands::Install.new(version: version, board: board, port: options[:port]
|
|
49
|
+
Commands::Install.new(version: version, board: board, port: options[:port],
|
|
50
|
+
verbose: options[:verbose]).call
|
|
38
51
|
rescue StandardError => e
|
|
39
52
|
raise Thor::Error, e.message
|
|
40
53
|
end
|
|
@@ -43,7 +43,7 @@ module Prremote
|
|
|
43
43
|
def deploy_to_device(mrb_data, rb_paths)
|
|
44
44
|
serial = Serial.new(@port, @baud)
|
|
45
45
|
wait_for_ready(serial)
|
|
46
|
-
serial
|
|
46
|
+
write_chunked(serial, DEPLOY_MAGIC + build_meta_packet(rb_paths) + mrb_data)
|
|
47
47
|
wait_for_deployed(serial)
|
|
48
48
|
ensure
|
|
49
49
|
serial&.close
|
|
@@ -3,14 +3,15 @@ require 'fileutils'
|
|
|
3
3
|
module Prremote
|
|
4
4
|
module Commands
|
|
5
5
|
class Install
|
|
6
|
-
def initialize(version: VERSION, board: 'picow', port: nil)
|
|
6
|
+
def initialize(version: VERSION, board: 'picow', port: nil, verbose: false)
|
|
7
7
|
@version = version
|
|
8
|
-
@board
|
|
9
|
-
@port
|
|
8
|
+
@board = board
|
|
9
|
+
@port = port
|
|
10
|
+
@verbose = verbose
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def call
|
|
13
|
-
return install_esp32 if @board
|
|
14
|
+
return install_esp32 if RuntimeManager::ESP32_BOARDS.include?(@board)
|
|
14
15
|
|
|
15
16
|
uf2_path = RuntimeManager.fetch(@version, @board)
|
|
16
17
|
|
|
@@ -42,7 +43,7 @@ module Prremote
|
|
|
42
43
|
raise 'No serial device found. Connect the board or pass --port.' unless port
|
|
43
44
|
|
|
44
45
|
puts "Flashing #{File.basename(image)} to #{port}..."
|
|
45
|
-
EspFlasher.flash(port: port, image_path: image)
|
|
46
|
+
EspFlasher.flash(port: port, image_path: image, board: @board, verbose: @verbose)
|
|
46
47
|
puts "Done. Runtime #{@version} installed."
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -42,7 +42,7 @@ module Prremote
|
|
|
42
42
|
serial = Serial.new(@port, @baud)
|
|
43
43
|
wait_for_ready(serial)
|
|
44
44
|
|
|
45
|
-
serial
|
|
45
|
+
write_chunked(serial, mrb_data)
|
|
46
46
|
debug "sent #{mrb_data.bytesize} bytes (first 4: #{mrb_data[0, 4].inspect})"
|
|
47
47
|
|
|
48
48
|
post_running = wait_for_running(serial)
|
|
@@ -34,6 +34,25 @@ module Prremote
|
|
|
34
34
|
str.gsub("\r\n", "\n").gsub("\r", '')
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# ESP32-C6's native USB Serial/JTAG drops bytes when a large payload is
|
|
38
|
+
# written in a single burst: its 256-byte driver RX ring buffer overflows
|
|
39
|
+
# faster than the firmware drains it byte-by-byte, and the controller does
|
|
40
|
+
# not apply USB backpressure. Writing in <=256-byte chunks with a short
|
|
41
|
+
# gap keeps the device from overrunning. The pacing is negligible on the
|
|
42
|
+
# baud-limited transports (Pico USB-CDC / ESP32 UART bridge), where a
|
|
43
|
+
# 256-byte chunk already takes ~22 ms to clock out at 115200 baud.
|
|
44
|
+
SERIAL_CHUNK_SIZE = 256
|
|
45
|
+
SERIAL_CHUNK_DELAY = 0.004
|
|
46
|
+
|
|
47
|
+
def write_chunked(serial, data)
|
|
48
|
+
i = 0
|
|
49
|
+
while i < data.bytesize
|
|
50
|
+
serial.write(data.byteslice(i, SERIAL_CHUNK_SIZE))
|
|
51
|
+
i += SERIAL_CHUNK_SIZE
|
|
52
|
+
sleep SERIAL_CHUNK_DELAY if i < data.bytesize
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
37
56
|
# Wraps serial.read so that a device disconnect (e.g. ENXIO on macOS when
|
|
38
57
|
# the Pico resets) surfaces as a human-readable error instead of a bare errno name.
|
|
39
58
|
def safe_read(serial, size)
|
data/lib/prremote/esp_flasher.rb
CHANGED
|
@@ -3,20 +3,19 @@ require 'digest'
|
|
|
3
3
|
require 'rbconfig'
|
|
4
4
|
|
|
5
5
|
module Prremote
|
|
6
|
-
# Pure-Ruby
|
|
7
|
-
#
|
|
6
|
+
# Pure-Ruby flasher for classic ESP32 (Xtensa) boards.
|
|
7
|
+
# Speaks the Espressif serial bootloader protocol directly so
|
|
8
|
+
# `install --board esp32` needs no external tools.
|
|
9
|
+
#
|
|
10
|
+
# For ESP32-C6 and other USB-JTAG/Serial chips the ROM does not support
|
|
11
|
+
# direct flash write/erase (FLASH_BEGIN returns error 0x38 regardless of
|
|
12
|
+
# parameters). esptool uploads a RAM stub before writing; we delegate those
|
|
13
|
+
# boards to the `esptool` CLI instead of reimplementing the stub protocol.
|
|
8
14
|
#
|
|
9
|
-
# Talks to the ESP32 (classic) ROM loader only (no stub upload): SYNC,
|
|
10
|
-
# SPI_ATTACH, SPI_SET_PARAMS, CHANGE_BAUDRATE, FLASH_BEGIN/DATA/END and
|
|
11
|
-
# SPI_FLASH_MD5 — enough to write a merged image at offset 0x0 and verify it.
|
|
12
15
|
# Protocol reference:
|
|
13
16
|
# https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html
|
|
14
|
-
#
|
|
15
|
-
# The chip is put into (and out of) the boot ROM by toggling DTR/RTS through
|
|
16
|
-
# the USB-UART bridge's auto-reset circuit, via ioctl on the serial fd —
|
|
17
|
-
# macOS and Linux only.
|
|
18
17
|
class EspFlasher
|
|
19
|
-
# Command opcodes
|
|
18
|
+
# Command opcodes
|
|
20
19
|
FLASH_BEGIN = 0x02
|
|
21
20
|
FLASH_DATA = 0x03
|
|
22
21
|
FLASH_END = 0x04
|
|
@@ -26,41 +25,58 @@ module Prremote
|
|
|
26
25
|
CHANGE_BAUD = 0x0F
|
|
27
26
|
SPI_FLASH_MD5 = 0x13
|
|
28
27
|
|
|
29
|
-
FLASH_WRITE_SIZE = 0x400 #
|
|
30
|
-
STATUS_BYTES = 4 # ESP32 ROM appends 4 status bytes to responses
|
|
28
|
+
FLASH_WRITE_SIZE = 0x400 # max data per FLASH_DATA packet
|
|
31
29
|
CHECKSUM_SEED = 0xEF
|
|
30
|
+
ROM_BAUD = 115_200
|
|
31
|
+
|
|
32
|
+
# Boards with built-in USB Serial/JTAG — flashed via esptool subprocess.
|
|
33
|
+
USB_JTAG_SERIAL_BOARDS = %w[esp32c6].freeze
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
# ROM status-byte count: classic ESP32 appends 4 bytes, RISC-V chips 2.
|
|
36
|
+
STATUS_BYTES_BY_BOARD = Hash.new(4).freeze
|
|
37
|
+
|
|
38
|
+
# Classic ESP32 SPI_ATTACH takes [hspi_arg, extended_arg] (8 bytes);
|
|
39
|
+
# newer RISC-V chips take only [hspi_arg] (4 bytes).
|
|
40
|
+
SPI_ATTACH_LEGACY_BOARDS = %w[esp32].freeze
|
|
41
|
+
|
|
42
|
+
MD5_HEX_LENGTH = 32
|
|
43
|
+
MD5_RAW_LENGTH = 16
|
|
34
44
|
|
|
35
45
|
# ioctl modem-control constants
|
|
36
46
|
TIOCM_DTR = 0x0002
|
|
37
47
|
TIOCM_RTS = 0x0004
|
|
38
48
|
DARWIN = RbConfig::CONFIG['host_os'] =~ /darwin/ ? true : false
|
|
39
49
|
TIOCMGET = DARWIN ? 0x4004746A : 0x5415
|
|
40
|
-
|
|
50
|
+
TIOCMBIS = DARWIN ? 0x8004746E : 0x5416
|
|
51
|
+
TIOCMBIC = DARWIN ? 0x8004746F : 0x5417
|
|
41
52
|
|
|
42
53
|
class Error < RuntimeError; end
|
|
43
54
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
# Entry point. Routes USB-JTAG/Serial boards through esptool; handles
|
|
56
|
+
# classic ESP32 with the pure-Ruby protocol implementation.
|
|
57
|
+
def self.flash(port:, image_path:, baud: 230_400, board: nil, verbose: false)
|
|
58
|
+
if USB_JTAG_SERIAL_BOARDS.include?(board)
|
|
59
|
+
return flash_via_esptool(port: port, image_path: image_path, board: board,
|
|
60
|
+
verbose: verbose)
|
|
61
|
+
end
|
|
62
|
+
|
|
48
63
|
unless RbConfig::CONFIG['host_os'] =~ /darwin|linux/
|
|
49
64
|
raise Error, 'pure-Ruby flashing supports macOS/Linux only; ' \
|
|
50
|
-
'on other systems
|
|
51
|
-
"esptool
|
|
65
|
+
'on other systems use esptool: ' \
|
|
66
|
+
"esptool write-flash 0x0 #{image_path}"
|
|
52
67
|
end
|
|
53
68
|
|
|
54
|
-
image
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
image = File.binread(image_path)
|
|
70
|
+
status_bytes = STATUS_BYTES_BY_BOARD[board]
|
|
71
|
+
serial = Serial.new(port, ROM_BAUD)
|
|
72
|
+
flasher = new(serial, fd: serial.instance_variable_get(:@fd),
|
|
73
|
+
status_bytes: status_bytes, board: board, verbose: verbose)
|
|
57
74
|
begin
|
|
58
75
|
flasher.enter_bootloader
|
|
59
76
|
flasher.sync!
|
|
60
|
-
|
|
77
|
+
upgrade = baud != ROM_BAUD && baud_supported?(baud)
|
|
78
|
+
serial = flasher.upgrade_baud(port, baud) if upgrade
|
|
61
79
|
flasher.write_flash(image, offset: 0)
|
|
62
|
-
# Verify before FLASH_END: the ROM loader has been seen going quiet
|
|
63
|
-
# after that command, while MD5 right after the last block is reliable.
|
|
64
80
|
flasher.verify_md5(image, offset: 0)
|
|
65
81
|
flasher.finish_flash
|
|
66
82
|
flasher.hard_reset
|
|
@@ -69,28 +85,68 @@ module Prremote
|
|
|
69
85
|
end
|
|
70
86
|
end
|
|
71
87
|
|
|
72
|
-
# The local termios layer must support the baud before CHANGE_BAUDRATE is
|
|
73
|
-
# sent to the chip — once the chip switches, there is no way back without
|
|
74
|
-
# a re-sync, so never request a speed we cannot reopen at.
|
|
75
88
|
def self.baud_supported?(baud)
|
|
76
89
|
RubySerial::Posix::BAUDE_RATES.key?(baud)
|
|
77
90
|
rescue NameError
|
|
78
91
|
false
|
|
79
92
|
end
|
|
80
93
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
# Flash via the `esptool` CLI (required for USB-JTAG/Serial boards whose
|
|
95
|
+
# ROM does not support direct write). esptool handles stub upload
|
|
96
|
+
# internally; we just need it installed.
|
|
97
|
+
def self.flash_via_esptool(port:, image_path:, board:, verbose:)
|
|
98
|
+
esptool = find_esptool
|
|
99
|
+
unless esptool
|
|
100
|
+
raise Error, <<~MSG.strip
|
|
101
|
+
Flashing #{board} requires esptool.
|
|
102
|
+
Install: brew install esptool
|
|
103
|
+
or: pip3 install esptool
|
|
104
|
+
MSG
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
warn ''
|
|
108
|
+
warn 'Put the board in bootloader mode while "Connecting..." is shown:'
|
|
109
|
+
warn ' XIAO ESP32C6: hold BOOT, press RST, release both.'
|
|
110
|
+
|
|
111
|
+
cmd = [*esptool,
|
|
112
|
+
'--chip', board, '--port', port,
|
|
113
|
+
'--before', 'no-reset', '--after', 'no-reset',
|
|
114
|
+
'write-flash', '0x0', image_path]
|
|
115
|
+
warn "[flash] #{cmd.join(' ')}" if verbose
|
|
116
|
+
system(*cmd) or raise Error, 'esptool exited with an error'
|
|
117
|
+
|
|
118
|
+
warn ''
|
|
119
|
+
warn 'Flash complete. Press RST to start the firmware.'
|
|
87
120
|
end
|
|
88
121
|
|
|
89
|
-
|
|
122
|
+
def self.find_esptool
|
|
123
|
+
dirs = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
|
|
124
|
+
%w[esptool esptool.py].each do |exe|
|
|
125
|
+
return [exe] if dirs.any? { |d| (f = File.join(d, exe)) && File.executable?(f) && !File.directory?(f) }
|
|
126
|
+
end
|
|
127
|
+
# python3 -m esptool fallback: must actually import the module to verify
|
|
128
|
+
return ['python3', '-m', 'esptool'] if
|
|
129
|
+
system('python3', '-c', 'import esptool', out: File::NULL, err: File::NULL)
|
|
130
|
+
|
|
131
|
+
nil
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ── instance ──────────────────────────────────────────────────────────
|
|
90
137
|
|
|
138
|
+
# `serial` needs #read/#write; `fd` enables DTR/RTS control.
|
|
139
|
+
def initialize(serial, fd: nil, status_bytes: 4, board: nil, verbose: false)
|
|
140
|
+
@serial = serial
|
|
141
|
+
@fd = fd
|
|
142
|
+
@rxbuf = +''.b
|
|
143
|
+
@status_bytes = status_bytes
|
|
144
|
+
@board = board.to_s
|
|
145
|
+
@verbose = verbose
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Classic auto-reset: RTS→EN, DTR→IO0 (via external UART bridge).
|
|
91
149
|
def enter_bootloader
|
|
92
|
-
# esptool's "classic reset": hold EN low, release it with IO0 low so
|
|
93
|
-
# the chip starts the ROM loader, then release IO0.
|
|
94
150
|
set_lines(dtr: false, rts: true)
|
|
95
151
|
sleep 0.1
|
|
96
152
|
set_lines(dtr: true, rts: false)
|
|
@@ -108,27 +164,28 @@ module Prremote
|
|
|
108
164
|
|
|
109
165
|
def sync!
|
|
110
166
|
payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32')
|
|
111
|
-
synced = 8.times.any? do
|
|
167
|
+
synced = 8.times.any? do |i|
|
|
112
168
|
@rxbuf.clear
|
|
113
169
|
begin
|
|
170
|
+
vlog format('sync: attempt %<n>d/8 (status_bytes=%<sb>d)', n: i + 1, sb: @status_bytes)
|
|
114
171
|
command(SYNC, payload, timeout: 0.5)
|
|
115
172
|
drain_responses
|
|
173
|
+
vlog 'sync: success'
|
|
116
174
|
true
|
|
117
|
-
rescue Error
|
|
175
|
+
rescue Error => e
|
|
176
|
+
vlog format('sync: attempt %<n>d failed (%<msg>s)', n: i + 1, msg: e.message)
|
|
118
177
|
false
|
|
119
178
|
end
|
|
120
179
|
end
|
|
121
|
-
raise Error, 'could not sync with the
|
|
180
|
+
raise Error, 'could not sync with the ESP boot ROM' unless synced
|
|
122
181
|
end
|
|
123
182
|
|
|
124
|
-
# CHANGE_BAUDRATE, then reopen the port at the new speed.
|
|
125
|
-
# not touch DTR/RTS (verified with rubyserial), so the chip stays in the
|
|
126
|
-
# bootloader across the reopen.
|
|
183
|
+
# CHANGE_BAUDRATE, then reopen the port at the new speed.
|
|
127
184
|
def upgrade_baud(port, baud)
|
|
128
185
|
command(CHANGE_BAUD, [baud, 0].pack('V2'))
|
|
129
186
|
@serial.close
|
|
130
187
|
sleep 0.05
|
|
131
|
-
serial
|
|
188
|
+
serial = Serial.new(port, baud)
|
|
132
189
|
@serial = serial
|
|
133
190
|
@fd = serial.instance_variable_get(:@fd)
|
|
134
191
|
@rxbuf.clear
|
|
@@ -136,36 +193,33 @@ module Prremote
|
|
|
136
193
|
end
|
|
137
194
|
|
|
138
195
|
def write_flash(image, offset: 0)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
blocks
|
|
145
|
-
|
|
146
|
-
erase_timeout = 30 * (1 + (
|
|
147
|
-
command(FLASH_BEGIN,
|
|
148
|
-
[image.bytesize, blocks, FLASH_WRITE_SIZE, offset].pack('V4'),
|
|
196
|
+
spi_attach_payload = SPI_ATTACH_LEGACY_BOARDS.include?(@board) ? [0, 0].pack('V2') : [0].pack('V')
|
|
197
|
+
command(SPI_ATTACH, spi_attach_payload)
|
|
198
|
+
# id, total_size, block_size, sector_size, page_size, status_mask
|
|
199
|
+
command(SPI_SET_PARAMS, [0, 4 * 1024 * 1024, 64 * 1024, 4096, 256, 0xFFFF].pack('V6'))
|
|
200
|
+
|
|
201
|
+
blocks = (image.bytesize + FLASH_WRITE_SIZE - 1) / FLASH_WRITE_SIZE
|
|
202
|
+
erase_size = blocks * FLASH_WRITE_SIZE
|
|
203
|
+
erase_timeout = 30 * (1 + (erase_size / (1024 * 1024)))
|
|
204
|
+
command(FLASH_BEGIN, [erase_size, blocks, FLASH_WRITE_SIZE, offset].pack('V4'),
|
|
149
205
|
timeout: erase_timeout)
|
|
150
206
|
stream_blocks(image, blocks)
|
|
151
207
|
end
|
|
152
208
|
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
# anyway). The ESP32 ROM has been seen answering it with error 0x06 —
|
|
156
|
-
# ignore it; the MD5 check is the source of truth.
|
|
209
|
+
# FLASH_END is a courtesy; hard_reset resets the chip anyway.
|
|
210
|
+
# The ROM has been seen returning error 0x06 here — ignore it.
|
|
157
211
|
def finish_flash
|
|
158
|
-
command(FLASH_END, [1].pack('V'))
|
|
212
|
+
command(FLASH_END, [1].pack('V'))
|
|
159
213
|
rescue Error
|
|
160
214
|
nil
|
|
161
215
|
end
|
|
162
216
|
|
|
163
217
|
def verify_md5(image, offset: 0)
|
|
164
|
-
timeout
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
device_md5 = data[0,
|
|
218
|
+
timeout = 8 * (1 + (image.bytesize / (1024 * 1024)))
|
|
219
|
+
_v, data = command(SPI_FLASH_MD5, [offset, image.bytesize, 0, 0].pack('V4'),
|
|
220
|
+
timeout: timeout)
|
|
221
|
+
# Classic ESP32 ROM returns 32 hex ASCII chars; detect by length.
|
|
222
|
+
device_md5 = data.bytesize >= MD5_HEX_LENGTH ? data[0, MD5_HEX_LENGTH] : data[0, MD5_RAW_LENGTH].unpack1('H*')
|
|
169
223
|
local_md5 = Digest::MD5.hexdigest(image)
|
|
170
224
|
return if device_md5 == local_md5
|
|
171
225
|
|
|
@@ -174,8 +228,6 @@ module Prremote
|
|
|
174
228
|
|
|
175
229
|
# ── request/response plumbing ──────────────────────────────────────────
|
|
176
230
|
|
|
177
|
-
# Sends one command packet and waits for its response.
|
|
178
|
-
# Returns [value, data] from the response (status bytes stripped).
|
|
179
231
|
def command(op, payload, checksum: 0, timeout: 3)
|
|
180
232
|
packet = [0x00, op, payload.bytesize].pack('CCv') +
|
|
181
233
|
[checksum].pack('V') + payload
|
|
@@ -206,6 +258,10 @@ module Prremote
|
|
|
206
258
|
|
|
207
259
|
private
|
|
208
260
|
|
|
261
|
+
def vlog(msg)
|
|
262
|
+
warn "[flash] #{msg}" if @verbose
|
|
263
|
+
end
|
|
264
|
+
|
|
209
265
|
def stream_blocks(image, blocks)
|
|
210
266
|
blocks.times do |seq|
|
|
211
267
|
block = image.byteslice(seq * FLASH_WRITE_SIZE, FLASH_WRITE_SIZE)
|
|
@@ -218,25 +274,21 @@ module Prremote
|
|
|
218
274
|
$stderr.print "\n"
|
|
219
275
|
end
|
|
220
276
|
|
|
221
|
-
# Returns [value, data] when the frame is the response to `op`, raises on
|
|
222
|
-
# an error status, and returns nil for unrelated frames (stale responses).
|
|
223
277
|
def parse_response(frame, op)
|
|
224
|
-
return nil if frame.bytesize < 8 +
|
|
278
|
+
return nil if frame.bytesize < 8 + @status_bytes || frame.getbyte(0) != 0x01 ||
|
|
225
279
|
frame.getbyte(1) != op
|
|
226
280
|
|
|
227
281
|
value = frame.byteslice(4, 4).unpack1('V')
|
|
228
282
|
body = frame.byteslice(8..)
|
|
229
|
-
status = body.byteslice(
|
|
283
|
+
status = body.byteslice(-@status_bytes, @status_bytes)
|
|
230
284
|
if status.getbyte(0) != 0
|
|
231
285
|
raise Error, format('command 0x%<op>02x failed (error 0x%<err>02x)',
|
|
232
286
|
op: op, err: status.getbyte(1))
|
|
233
287
|
end
|
|
234
288
|
|
|
235
|
-
[value, body.byteslice(0
|
|
289
|
+
[value, body.byteslice(0...-@status_bytes)]
|
|
236
290
|
end
|
|
237
291
|
|
|
238
|
-
# Reads from the serial port until a complete 0xC0 ... 0xC0 frame is
|
|
239
|
-
# available or the deadline passes. Returns the decoded frame or nil.
|
|
240
292
|
def read_frame(deadline)
|
|
241
293
|
loop do
|
|
242
294
|
start = @rxbuf.index("\xC0".b)
|
|
@@ -245,7 +297,7 @@ module Prremote
|
|
|
245
297
|
if stop
|
|
246
298
|
frame = @rxbuf.byteslice((start + 1)...stop)
|
|
247
299
|
@rxbuf = @rxbuf.byteslice((stop + 1)..) || +''.b
|
|
248
|
-
next if frame.empty?
|
|
300
|
+
next if frame.empty?
|
|
249
301
|
|
|
250
302
|
return slip_decode(frame)
|
|
251
303
|
end
|
|
@@ -257,8 +309,6 @@ module Prremote
|
|
|
257
309
|
end
|
|
258
310
|
end
|
|
259
311
|
|
|
260
|
-
# The ROM answers a successful SYNC with a burst of identical responses;
|
|
261
|
-
# swallow them so they are not mistaken for the next command's reply.
|
|
262
312
|
def drain_responses
|
|
263
313
|
deadline = Time.now + 0.3
|
|
264
314
|
loop { break if read_frame(deadline).nil? }
|
|
@@ -268,12 +318,10 @@ module Prremote
|
|
|
268
318
|
return if @fd.nil?
|
|
269
319
|
|
|
270
320
|
io = IO.for_fd(@fd, autoclose: false)
|
|
271
|
-
|
|
272
|
-
io.ioctl(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
bits = rts ? (bits | TIOCM_RTS) : (bits & ~TIOCM_RTS)
|
|
276
|
-
io.ioctl(TIOCMSET, [bits].pack('L'))
|
|
321
|
+
io.ioctl(dtr ? TIOCMBIS : TIOCMBIC, [TIOCM_DTR].pack('L'))
|
|
322
|
+
io.ioctl(rts ? TIOCMBIS : TIOCMBIC, [TIOCM_RTS].pack('L'))
|
|
323
|
+
rescue StandardError
|
|
324
|
+
nil
|
|
277
325
|
end
|
|
278
326
|
|
|
279
327
|
def progress(done, total)
|
|
@@ -4,13 +4,14 @@ require 'fileutils'
|
|
|
4
4
|
|
|
5
5
|
module Prremote
|
|
6
6
|
module RuntimeManager
|
|
7
|
-
BOARDS = %w[pico picow esp32].freeze
|
|
7
|
+
BOARDS = %w[pico picow esp32 esp32c6].freeze
|
|
8
|
+
ESP32_BOARDS = %w[esp32 esp32c6].freeze
|
|
8
9
|
|
|
9
|
-
# Pico boards ship as UF2 (copied to the BOOTSEL drive); ESP32 ships
|
|
10
|
-
# single merged .bin (bootloader + partition table + app) flashed at
|
|
11
|
-
# with esptool.
|
|
10
|
+
# Pico boards ship as UF2 (copied to the BOOTSEL drive); ESP32 family ships
|
|
11
|
+
# as a single merged .bin (bootloader + partition table + app) flashed at
|
|
12
|
+
# 0x0 with esptool.
|
|
12
13
|
def self.artifact_filename(version, board)
|
|
13
|
-
ext = board
|
|
14
|
+
ext = ESP32_BOARDS.include?(board) ? 'bin' : 'uf2'
|
|
14
15
|
"prremote-#{board}-runtime-#{version}.#{ext}"
|
|
15
16
|
end
|
|
16
17
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prremote
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ITO Yosei
|
|
@@ -139,7 +139,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
139
139
|
- !ruby/object:Gem::Version
|
|
140
140
|
version: '0'
|
|
141
141
|
requirements: []
|
|
142
|
-
rubygems_version: 4.0.
|
|
142
|
+
rubygems_version: 4.0.10
|
|
143
143
|
specification_version: 4
|
|
144
144
|
summary: CLI tool for deploying and running mruby/c scripts on a Raspberry Pi Pico
|
|
145
145
|
W over USB serial
|