prremote 0.2.0 → 0.3.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 +4 -4
- data/README.md +26 -18
- 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 +152 -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: cfce7ce99a7bd04bbfde40aad5c53640f14f1ad90da41998c7399e6366d25198
|
|
4
|
+
data.tar.gz: d60e5701e6d42baf78a82a6d7d919d3bb5664cd34cb9cb02cdf7a2a749368121
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fd70f1266d67f8f41aef2018c102d4827c53cb103f304a16d40f78ee618c99556bc45fbc94447f66ff4e157f5318ff5b62956e8edf5dd1d1acda5a53ac1d94d
|
|
7
|
+
data.tar.gz: 70372664611e75517b6ea974ff85b222ecb6b53944ffdc345f423e6c15720b4daeedc84cdba20e5338c3c754f6090ac1aeb605ef34f498f1a4a6849236b62c31
|
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
|
-
-
|
|
21
|
-
- Raspberry Pi Pico W / Pico
|
|
22
|
-
- ESP32 (classic) — e.g. M5GO / M5Stack Core gen1, generic dev boards
|
|
20
|
+
- Supported boards:
|
|
21
|
+
- [Raspberry Pi Pico W](https://www.raspberrypi.com/products/raspberry-pi-pico-w/) / [Pico](https://www.raspberrypi.com/products/raspberry-pi-pico/)
|
|
22
|
+
- ESP32 (classic) — e.g. [M5GO / M5Stack Core gen1](https://docs.m5stack.com/en/core/m5go), generic [ESP32](https://www.espressif.com/en/products/socs/esp32) dev boards
|
|
23
|
+
- ESP32-C6 (RISC-V) — e.g. [Seeed Studio XIAO ESP32C6](https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/)
|
|
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,28 @@ 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
|
|
77
|
+
ESP32 (classic): 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).
|
|
78
|
+
|
|
79
|
+
ESP32-C6: flashing is delegated to [esptool](https://docs.espressif.com/projects/esptool/en/latest/), because the C6 boot ROM rejects the direct write the classic ESP32 accepts. Install it first (`brew install esptool`, or `pip3 install esptool`), and put the board into bootloader mode when prompted — on the XIAO ESP32C6, hold **BOOT**, press **RST**, then release both.
|
|
80
|
+
|
|
81
|
+
Reflashing the runtime does not erase a deployed script on either chip.
|
|
74
82
|
|
|
75
83
|
---
|
|
76
84
|
|
|
@@ -91,7 +99,7 @@ prremote run lib.rb main.rb
|
|
|
91
99
|
|
|
92
100
|
The device responds with `RUNNING`, streams any output, then `DONE`.
|
|
93
101
|
|
|
94
|
-
You can find some examples in [
|
|
102
|
+
You can find some examples in [examples](examples/).
|
|
95
103
|
|
|
96
104
|
---
|
|
97
105
|
|
|
@@ -124,7 +132,7 @@ Evaluate a Ruby one-liner on the device.
|
|
|
124
132
|
|
|
125
133
|
```bash
|
|
126
134
|
prremote eval "puts 1 + 1"
|
|
127
|
-
prremote eval "
|
|
135
|
+
prremote eval "GPIO.led.write 1"
|
|
128
136
|
```
|
|
129
137
|
|
|
130
138
|
---
|
|
@@ -168,8 +176,8 @@ Show the gem version, mrbc version, and the connected device's runtime version.
|
|
|
168
176
|
|
|
169
177
|
```bash
|
|
170
178
|
prremote version
|
|
171
|
-
# prremote: 0.
|
|
172
|
-
# runtime: 0.
|
|
179
|
+
# prremote: 0.3.0
|
|
180
|
+
# runtime: 0.3.0 (/dev/tty.usbmodem101)
|
|
173
181
|
# mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
|
|
174
182
|
```
|
|
175
183
|
|
|
@@ -180,7 +188,7 @@ prremote version
|
|
|
180
188
|
| Option | Description |
|
|
181
189
|
|---|---|
|
|
182
190
|
| `--port`, `-p PORT` | Serial port (default: auto-detect) |
|
|
183
|
-
| `--baud
|
|
191
|
+
| `--baud N` | Baud rate (default: `115200`) |
|
|
184
192
|
|
|
185
193
|
---
|
|
186
194
|
|
|
@@ -188,7 +196,7 @@ prremote version
|
|
|
188
196
|
|
|
189
197
|
```bash
|
|
190
198
|
# First-time setup
|
|
191
|
-
prremote install
|
|
199
|
+
prremote install -b picow # or: pico / esp32 / esp32c6
|
|
192
200
|
|
|
193
201
|
# Manual cycle
|
|
194
202
|
prremote run app.rb # compile + run (one-shot)
|
|
@@ -214,7 +222,7 @@ prremote flashes a minimal C firmware (built on mruby/c) onto the Pico W. The fi
|
|
|
214
222
|
- `DPLY` + `.mrb` bytecode → save to flash and confirm with `DEPLOYED` (`deploy`)
|
|
215
223
|
3. Waits for the next command
|
|
216
224
|
|
|
217
|
-
Scripts saved via `deploy` are stored in flash and run automatically on every boot. GPIO / ADC / PWM / I2C / SPI bindings are available on all boards; WiFi
|
|
225
|
+
Scripts saved via `deploy` are stored in flash and run automatically on every boot. GPIO / ADC / PWM / I2C / SPI bindings are available on all boards; a `WiFi` module on boards with a radio (Pico W and ESP32); an `LCD` class (ILI9342C) on ESP32 / M5Stack.
|
|
218
226
|
|
|
219
227
|
---
|
|
220
228
|
|
|
@@ -280,4 +288,4 @@ bundle exec rake test
|
|
|
280
288
|
|
|
281
289
|
- [mruby/c](https://github.com/mrubyc/mrubyc) — Lightweight mruby implementation used in the runtime
|
|
282
290
|
- [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
|
-
- [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) — MicroPython equivalent (inspiration)
|
|
291
|
+
- [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) — MicroPython equivalent (inspiration)
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.3.0
|
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,89 @@ 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
|
+
ctx = { esptool: esptool, port: port, image_path: image_path, board: board, verbose: verbose }
|
|
108
|
+
|
|
109
|
+
# USB-Serial/JTAG chips (e.g. XIAO ESP32C6) can be dropped into the
|
|
110
|
+
# download ROM over USB, so esptool's usb-reset flashes hands-free — no
|
|
111
|
+
# BOOT/RST button dance. --after hard-reset then reboots straight into the
|
|
112
|
+
# freshly flashed firmware. Verified on a physical XIAO ESP32C6.
|
|
113
|
+
return if esptool_write(ctx, before: 'usb-reset', after: 'hard-reset', connect_attempts: 7)
|
|
114
|
+
|
|
115
|
+
# Fallback for hosts/boards where usb-reset doesn't take: enter the
|
|
116
|
+
# bootloader by hand and let esptool retry forever. --before no-reset never
|
|
117
|
+
# resets the chip, so each attempt just re-sends SYNC until the manual
|
|
118
|
+
# BOOT/RST lands the board in download mode — no timeout to race against.
|
|
119
|
+
warn ''
|
|
120
|
+
warn "Couldn't reset #{board} automatically. Put it in bootloader mode by hand:"
|
|
121
|
+
warn ' XIAO ESP32C6: hold BOOT, press RST, release both.'
|
|
122
|
+
warn 'No rush — esptool keeps retrying the SYNC handshake until it connects.'
|
|
123
|
+
esptool_write(ctx, before: 'no-reset', after: 'no-reset', connect_attempts: 0) or
|
|
124
|
+
raise Error, 'esptool exited with an error'
|
|
125
|
+
|
|
126
|
+
warn ''
|
|
127
|
+
warn 'Flash complete. Press RST to start the firmware.'
|
|
87
128
|
end
|
|
88
129
|
|
|
89
|
-
#
|
|
130
|
+
# Runs one `esptool write-flash 0x0 <image>` pass, returning esptool's
|
|
131
|
+
# success boolean. connect_attempts 0 = retry the connect forever.
|
|
132
|
+
# `ctx` carries the fixed invocation context (esptool/port/image_path/board/verbose).
|
|
133
|
+
def self.esptool_write(ctx, before:, after:, connect_attempts:)
|
|
134
|
+
cmd = [*ctx[:esptool],
|
|
135
|
+
'--chip', ctx[:board], '--port', ctx[:port],
|
|
136
|
+
'--connect-attempts', connect_attempts.to_s,
|
|
137
|
+
'--before', before, '--after', after,
|
|
138
|
+
'write-flash', '0x0', ctx[:image_path]]
|
|
139
|
+
warn "[flash] #{cmd.join(' ')}" if ctx[:verbose]
|
|
140
|
+
system(*cmd)
|
|
141
|
+
end
|
|
90
142
|
|
|
143
|
+
def self.find_esptool
|
|
144
|
+
dirs = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
|
|
145
|
+
%w[esptool esptool.py].each do |exe|
|
|
146
|
+
return [exe] if dirs.any? { |d| (f = File.join(d, exe)) && File.executable?(f) && !File.directory?(f) }
|
|
147
|
+
end
|
|
148
|
+
# python3 -m esptool fallback: must actually import the module to verify
|
|
149
|
+
return ['python3', '-m', 'esptool'] if
|
|
150
|
+
system('python3', '-c', 'import esptool', out: File::NULL, err: File::NULL)
|
|
151
|
+
|
|
152
|
+
nil
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ── instance ──────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
# `serial` needs #read/#write; `fd` enables DTR/RTS control.
|
|
160
|
+
def initialize(serial, fd: nil, status_bytes: 4, board: nil, verbose: false)
|
|
161
|
+
@serial = serial
|
|
162
|
+
@fd = fd
|
|
163
|
+
@rxbuf = +''.b
|
|
164
|
+
@status_bytes = status_bytes
|
|
165
|
+
@board = board.to_s
|
|
166
|
+
@verbose = verbose
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Classic auto-reset: RTS→EN, DTR→IO0 (via external UART bridge).
|
|
91
170
|
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
171
|
set_lines(dtr: false, rts: true)
|
|
95
172
|
sleep 0.1
|
|
96
173
|
set_lines(dtr: true, rts: false)
|
|
@@ -108,27 +185,28 @@ module Prremote
|
|
|
108
185
|
|
|
109
186
|
def sync!
|
|
110
187
|
payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32')
|
|
111
|
-
synced = 8.times.any? do
|
|
188
|
+
synced = 8.times.any? do |i|
|
|
112
189
|
@rxbuf.clear
|
|
113
190
|
begin
|
|
191
|
+
vlog format('sync: attempt %<n>d/8 (status_bytes=%<sb>d)', n: i + 1, sb: @status_bytes)
|
|
114
192
|
command(SYNC, payload, timeout: 0.5)
|
|
115
193
|
drain_responses
|
|
194
|
+
vlog 'sync: success'
|
|
116
195
|
true
|
|
117
|
-
rescue Error
|
|
196
|
+
rescue Error => e
|
|
197
|
+
vlog format('sync: attempt %<n>d failed (%<msg>s)', n: i + 1, msg: e.message)
|
|
118
198
|
false
|
|
119
199
|
end
|
|
120
200
|
end
|
|
121
|
-
raise Error, 'could not sync with the
|
|
201
|
+
raise Error, 'could not sync with the ESP boot ROM' unless synced
|
|
122
202
|
end
|
|
123
203
|
|
|
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.
|
|
204
|
+
# CHANGE_BAUDRATE, then reopen the port at the new speed.
|
|
127
205
|
def upgrade_baud(port, baud)
|
|
128
206
|
command(CHANGE_BAUD, [baud, 0].pack('V2'))
|
|
129
207
|
@serial.close
|
|
130
208
|
sleep 0.05
|
|
131
|
-
serial
|
|
209
|
+
serial = Serial.new(port, baud)
|
|
132
210
|
@serial = serial
|
|
133
211
|
@fd = serial.instance_variable_get(:@fd)
|
|
134
212
|
@rxbuf.clear
|
|
@@ -136,36 +214,33 @@ module Prremote
|
|
|
136
214
|
end
|
|
137
215
|
|
|
138
216
|
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'),
|
|
217
|
+
spi_attach_payload = SPI_ATTACH_LEGACY_BOARDS.include?(@board) ? [0, 0].pack('V2') : [0].pack('V')
|
|
218
|
+
command(SPI_ATTACH, spi_attach_payload)
|
|
219
|
+
# id, total_size, block_size, sector_size, page_size, status_mask
|
|
220
|
+
command(SPI_SET_PARAMS, [0, 4 * 1024 * 1024, 64 * 1024, 4096, 256, 0xFFFF].pack('V6'))
|
|
221
|
+
|
|
222
|
+
blocks = (image.bytesize + FLASH_WRITE_SIZE - 1) / FLASH_WRITE_SIZE
|
|
223
|
+
erase_size = blocks * FLASH_WRITE_SIZE
|
|
224
|
+
erase_timeout = 30 * (1 + (erase_size / (1024 * 1024)))
|
|
225
|
+
command(FLASH_BEGIN, [erase_size, blocks, FLASH_WRITE_SIZE, offset].pack('V4'),
|
|
149
226
|
timeout: erase_timeout)
|
|
150
227
|
stream_blocks(image, blocks)
|
|
151
228
|
end
|
|
152
229
|
|
|
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.
|
|
230
|
+
# FLASH_END is a courtesy; hard_reset resets the chip anyway.
|
|
231
|
+
# The ROM has been seen returning error 0x06 here — ignore it.
|
|
157
232
|
def finish_flash
|
|
158
|
-
command(FLASH_END, [1].pack('V'))
|
|
233
|
+
command(FLASH_END, [1].pack('V'))
|
|
159
234
|
rescue Error
|
|
160
235
|
nil
|
|
161
236
|
end
|
|
162
237
|
|
|
163
238
|
def verify_md5(image, offset: 0)
|
|
164
|
-
timeout
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
device_md5 = data[0,
|
|
239
|
+
timeout = 8 * (1 + (image.bytesize / (1024 * 1024)))
|
|
240
|
+
_v, data = command(SPI_FLASH_MD5, [offset, image.bytesize, 0, 0].pack('V4'),
|
|
241
|
+
timeout: timeout)
|
|
242
|
+
# Classic ESP32 ROM returns 32 hex ASCII chars; detect by length.
|
|
243
|
+
device_md5 = data.bytesize >= MD5_HEX_LENGTH ? data[0, MD5_HEX_LENGTH] : data[0, MD5_RAW_LENGTH].unpack1('H*')
|
|
169
244
|
local_md5 = Digest::MD5.hexdigest(image)
|
|
170
245
|
return if device_md5 == local_md5
|
|
171
246
|
|
|
@@ -174,8 +249,6 @@ module Prremote
|
|
|
174
249
|
|
|
175
250
|
# ── request/response plumbing ──────────────────────────────────────────
|
|
176
251
|
|
|
177
|
-
# Sends one command packet and waits for its response.
|
|
178
|
-
# Returns [value, data] from the response (status bytes stripped).
|
|
179
252
|
def command(op, payload, checksum: 0, timeout: 3)
|
|
180
253
|
packet = [0x00, op, payload.bytesize].pack('CCv') +
|
|
181
254
|
[checksum].pack('V') + payload
|
|
@@ -206,6 +279,10 @@ module Prremote
|
|
|
206
279
|
|
|
207
280
|
private
|
|
208
281
|
|
|
282
|
+
def vlog(msg)
|
|
283
|
+
warn "[flash] #{msg}" if @verbose
|
|
284
|
+
end
|
|
285
|
+
|
|
209
286
|
def stream_blocks(image, blocks)
|
|
210
287
|
blocks.times do |seq|
|
|
211
288
|
block = image.byteslice(seq * FLASH_WRITE_SIZE, FLASH_WRITE_SIZE)
|
|
@@ -218,25 +295,21 @@ module Prremote
|
|
|
218
295
|
$stderr.print "\n"
|
|
219
296
|
end
|
|
220
297
|
|
|
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
298
|
def parse_response(frame, op)
|
|
224
|
-
return nil if frame.bytesize < 8 +
|
|
299
|
+
return nil if frame.bytesize < 8 + @status_bytes || frame.getbyte(0) != 0x01 ||
|
|
225
300
|
frame.getbyte(1) != op
|
|
226
301
|
|
|
227
302
|
value = frame.byteslice(4, 4).unpack1('V')
|
|
228
303
|
body = frame.byteslice(8..)
|
|
229
|
-
status = body.byteslice(
|
|
304
|
+
status = body.byteslice(-@status_bytes, @status_bytes)
|
|
230
305
|
if status.getbyte(0) != 0
|
|
231
306
|
raise Error, format('command 0x%<op>02x failed (error 0x%<err>02x)',
|
|
232
307
|
op: op, err: status.getbyte(1))
|
|
233
308
|
end
|
|
234
309
|
|
|
235
|
-
[value, body.byteslice(0
|
|
310
|
+
[value, body.byteslice(0...-@status_bytes)]
|
|
236
311
|
end
|
|
237
312
|
|
|
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
313
|
def read_frame(deadline)
|
|
241
314
|
loop do
|
|
242
315
|
start = @rxbuf.index("\xC0".b)
|
|
@@ -245,7 +318,7 @@ module Prremote
|
|
|
245
318
|
if stop
|
|
246
319
|
frame = @rxbuf.byteslice((start + 1)...stop)
|
|
247
320
|
@rxbuf = @rxbuf.byteslice((stop + 1)..) || +''.b
|
|
248
|
-
next if frame.empty?
|
|
321
|
+
next if frame.empty?
|
|
249
322
|
|
|
250
323
|
return slip_decode(frame)
|
|
251
324
|
end
|
|
@@ -257,8 +330,6 @@ module Prremote
|
|
|
257
330
|
end
|
|
258
331
|
end
|
|
259
332
|
|
|
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
333
|
def drain_responses
|
|
263
334
|
deadline = Time.now + 0.3
|
|
264
335
|
loop { break if read_frame(deadline).nil? }
|
|
@@ -268,12 +339,10 @@ module Prremote
|
|
|
268
339
|
return if @fd.nil?
|
|
269
340
|
|
|
270
341
|
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'))
|
|
342
|
+
io.ioctl(dtr ? TIOCMBIS : TIOCMBIC, [TIOCM_DTR].pack('L'))
|
|
343
|
+
io.ioctl(rts ? TIOCMBIS : TIOCMBIC, [TIOCM_RTS].pack('L'))
|
|
344
|
+
rescue StandardError
|
|
345
|
+
nil
|
|
277
346
|
end
|
|
278
347
|
|
|
279
348
|
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.
|
|
4
|
+
version: 0.3.0
|
|
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
|