prremote 0.1.7 → 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 +47 -16
- data/VERSION +1 -1
- data/lib/prremote/cli.rb +20 -6
- data/lib/prremote/commands/deploy.rb +1 -1
- data/lib/prremote/commands/install.rb +18 -2
- data/lib/prremote/commands/run.rb +1 -1
- data/lib/prremote/commands/serial_helpers.rb +28 -1
- data/lib/prremote/detector.rb +36 -14
- data/lib/prremote/esp_flasher.rb +336 -0
- data/lib/prremote/runtime_manager.rb +12 -7
- metadata +3 -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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> ⚠️ This project is in early development. APIs and commands are subject to change.
|
|
4
4
|
|
|
5
|
-
**prremote** is a command-line tool for deploying and running Ruby scripts on a Raspberry Pi Pico W over USB serial. It ships a minimal [mruby/c](https://github.com/mrubyc/mrubyc) runtime firmware and lets you compile and send `.rb` files from your Mac or Linux machine directly to the device.
|
|
5
|
+
**prremote** is a command-line tool for deploying and running Ruby scripts on a Raspberry Pi Pico W or ESP32 (e.g. M5Stack) over USB serial. It ships a minimal [mruby/c](https://github.com/mrubyc/mrubyc) runtime firmware and lets you compile and send `.rb` files from your Mac or Linux machine directly to the device.
|
|
6
6
|
|
|
7
7
|
Inspired by [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) for MicroPython.
|
|
8
8
|
|
|
@@ -17,7 +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
|
+
- Raspberry Pi Pico W / Pico
|
|
22
|
+
- ESP32 (classic) — e.g. M5GO / M5Stack Core gen1, generic dev boards
|
|
23
|
+
- ESP32-C6 (RISC-V) — e.g. Seeed Studio XIAO ESP32C6
|
|
21
24
|
- `mrbc` (mruby 4.x) for `run`, `deploy`, and `eval`
|
|
22
25
|
- macOS: `brew install mruby`
|
|
23
26
|
- Linux: build from source — [github.com/mruby/mruby/releases](https://github.com/mruby/mruby/releases)
|
|
@@ -54,18 +57,24 @@ prremote run app.rb
|
|
|
54
57
|
|
|
55
58
|
### `install`
|
|
56
59
|
|
|
57
|
-
Flash the prremote runtime firmware to a
|
|
60
|
+
Flash the prremote runtime firmware to a supported board.
|
|
58
61
|
|
|
59
62
|
```bash
|
|
60
|
-
prremote install #
|
|
61
|
-
prremote install
|
|
62
|
-
prremote install
|
|
63
|
-
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
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
`-b` / `--board` selects the target board. Running `install` without `--board` prints the list of supported boards.
|
|
72
|
+
|
|
66
73
|
The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
Pico boards: put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
|
|
76
|
+
|
|
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.
|
|
69
78
|
|
|
70
79
|
---
|
|
71
80
|
|
|
@@ -86,7 +95,7 @@ prremote run lib.rb main.rb
|
|
|
86
95
|
|
|
87
96
|
The device responds with `RUNNING`, streams any output, then `DONE`.
|
|
88
97
|
|
|
89
|
-
You can find some examples in [
|
|
98
|
+
You can find some examples in [examples](examples/).
|
|
90
99
|
|
|
91
100
|
---
|
|
92
101
|
|
|
@@ -163,8 +172,8 @@ Show the gem version, mrbc version, and the connected device's runtime version.
|
|
|
163
172
|
|
|
164
173
|
```bash
|
|
165
174
|
prremote version
|
|
166
|
-
# prremote: 0.1
|
|
167
|
-
# runtime: 0.1
|
|
175
|
+
# prremote: 0.2.1
|
|
176
|
+
# runtime: 0.2.1 (/dev/tty.usbmodem101)
|
|
168
177
|
# mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
|
|
169
178
|
```
|
|
170
179
|
|
|
@@ -175,7 +184,7 @@ prremote version
|
|
|
175
184
|
| Option | Description |
|
|
176
185
|
|---|---|
|
|
177
186
|
| `--port`, `-p PORT` | Serial port (default: auto-detect) |
|
|
178
|
-
| `--baud
|
|
187
|
+
| `--baud N` | Baud rate (default: `115200`) |
|
|
179
188
|
|
|
180
189
|
---
|
|
181
190
|
|
|
@@ -183,7 +192,7 @@ prremote version
|
|
|
183
192
|
|
|
184
193
|
```bash
|
|
185
194
|
# First-time setup
|
|
186
|
-
prremote install
|
|
195
|
+
prremote install -b picow # or: pico / esp32 / esp32c6
|
|
187
196
|
|
|
188
197
|
# Manual cycle
|
|
189
198
|
prremote run app.rb # compile + run (one-shot)
|
|
@@ -209,7 +218,7 @@ prremote flashes a minimal C firmware (built on mruby/c) onto the Pico W. The fi
|
|
|
209
218
|
- `DPLY` + `.mrb` bytecode → save to flash and confirm with `DEPLOYED` (`deploy`)
|
|
210
219
|
3. Waits for the next command
|
|
211
220
|
|
|
212
|
-
Scripts saved via `deploy` are stored in flash and run automatically on every boot. GPIO
|
|
221
|
+
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 (CYW43) on the Pico W; an `LCD` class (ILI9342C) on ESP32 / M5Stack.
|
|
213
222
|
|
|
214
223
|
---
|
|
215
224
|
|
|
@@ -232,11 +241,29 @@ git submodule update --init --recursive
|
|
|
232
241
|
|
|
233
242
|
### Build the runtime firmware
|
|
234
243
|
|
|
235
|
-
|
|
244
|
+
Pico boards require the ARM cross-compiler (`arm-none-eabi-gcc`) and CMake.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
cd runtime/
|
|
248
|
+
rake build # UF2 for pico and picow
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The ESP32 runtime requires ESP-IDF v5.3, which is not vendored (it is several
|
|
252
|
+
GB and installs its own toolchains). One-time setup:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
mkdir -p ~/sources/esp
|
|
256
|
+
git clone -b v5.3.2 --recursive --shallow-submodules \
|
|
257
|
+
https://github.com/espressif/esp-idf.git ~/sources/esp/esp-idf
|
|
258
|
+
cd ~/sources/esp/esp-idf && ./install.sh esp32
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Then (set `IDF_PATH` if you installed somewhere else):
|
|
236
262
|
|
|
237
263
|
```bash
|
|
238
264
|
cd runtime/
|
|
239
|
-
rake
|
|
265
|
+
rake build:esp32 # merged .bin for esp32
|
|
266
|
+
rake cache # build all boards → ~/.prremote/runtime/
|
|
240
267
|
```
|
|
241
268
|
|
|
242
269
|
### Run the tests
|
|
@@ -258,3 +285,7 @@ bundle exec rake test
|
|
|
258
285
|
- [mruby/c](https://github.com/mrubyc/mrubyc) — Lightweight mruby implementation used in the runtime
|
|
259
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
|
|
260
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.1
|
|
1
|
+
0.2.1
|
data/lib/prremote/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@ require 'rubyserial'
|
|
|
3
3
|
require_relative 'version'
|
|
4
4
|
require_relative 'detector'
|
|
5
5
|
require_relative 'mrbc'
|
|
6
|
+
require_relative 'esp_flasher'
|
|
6
7
|
require_relative 'runtime_manager'
|
|
7
8
|
require_relative 'commands/install'
|
|
8
9
|
require_relative 'commands/deploy'
|
|
@@ -15,7 +16,7 @@ require_relative 'commands/watch'
|
|
|
15
16
|
module Prremote
|
|
16
17
|
class CLI < Thor
|
|
17
18
|
class_option :port, aliases: '-p', desc: 'Serial port (default: auto-detect)'
|
|
18
|
-
class_option :baud,
|
|
19
|
+
class_option :baud, type: :numeric, default: 115_200, desc: 'Baud rate'
|
|
19
20
|
|
|
20
21
|
def self.exit_on_failure?
|
|
21
22
|
true
|
|
@@ -23,17 +24,30 @@ module Prremote
|
|
|
23
24
|
|
|
24
25
|
remove_command :tree
|
|
25
26
|
|
|
26
|
-
desc 'install', 'Flash prremote runtime firmware to
|
|
27
|
+
desc 'install', 'Flash prremote runtime firmware to a supported board'
|
|
27
28
|
option :version, type: :string, desc: "Firmware version to install (default: #{VERSION})"
|
|
28
|
-
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'
|
|
29
33
|
def install
|
|
30
34
|
version = options[:version] || VERSION
|
|
31
|
-
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
|
+
|
|
32
45
|
unless RuntimeManager::BOARDS.include?(board)
|
|
33
|
-
raise Thor::Error, "Unknown board '#{board}'.
|
|
46
|
+
raise Thor::Error, "Unknown board '#{board}'. Supported boards: #{RuntimeManager::BOARDS.join(', ')}"
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
Commands::Install.new(version: version, board: board
|
|
49
|
+
Commands::Install.new(version: version, board: board, port: options[:port],
|
|
50
|
+
verbose: options[:verbose]).call
|
|
37
51
|
rescue StandardError => e
|
|
38
52
|
raise Thor::Error, e.message
|
|
39
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,12 +3,16 @@ require 'fileutils'
|
|
|
3
3
|
module Prremote
|
|
4
4
|
module Commands
|
|
5
5
|
class Install
|
|
6
|
-
def initialize(version: VERSION, board: 'picow')
|
|
6
|
+
def initialize(version: VERSION, board: 'picow', port: nil, verbose: false)
|
|
7
7
|
@version = version
|
|
8
|
-
@board
|
|
8
|
+
@board = board
|
|
9
|
+
@port = port
|
|
10
|
+
@verbose = verbose
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def call
|
|
14
|
+
return install_esp32 if RuntimeManager::ESP32_BOARDS.include?(@board)
|
|
15
|
+
|
|
12
16
|
uf2_path = RuntimeManager.fetch(@version, @board)
|
|
13
17
|
|
|
14
18
|
device_label = @board == 'picow' ? 'Pico W' : 'Pico'
|
|
@@ -31,6 +35,18 @@ module Prremote
|
|
|
31
35
|
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
# No BOOTSEL dance on ESP32: the flasher toggles DTR/RTS to enter the
|
|
39
|
+
# boot ROM by itself, so flashing works over the normal serial port.
|
|
40
|
+
def install_esp32
|
|
41
|
+
image = RuntimeManager.fetch(@version, @board)
|
|
42
|
+
port = @port || Detector.find_device
|
|
43
|
+
raise 'No serial device found. Connect the board or pass --port.' unless port
|
|
44
|
+
|
|
45
|
+
puts "Flashing #{File.basename(image)} to #{port}..."
|
|
46
|
+
EspFlasher.flash(port: port, image_path: image, board: @board, verbose: @verbose)
|
|
47
|
+
puts "Done. Runtime #{@version} installed."
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def volume_paths
|
|
35
51
|
[
|
|
36
52
|
'/Volumes/RPI-RP2',
|
|
@@ -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)
|
|
@@ -5,11 +5,15 @@ module Prremote
|
|
|
5
5
|
# On macOS, USB CDC TX buffers can be dropped when the host reopens the port,
|
|
6
6
|
# leaving the device stuck in getchar() without re-sending READY.
|
|
7
7
|
# Sending Ctrl+C forces the device to restart its READY loop.
|
|
8
|
+
# Resent every second: ESP32 boards reset when the port opens (DTR/RTS
|
|
9
|
+
# auto-reset circuit) and a Ctrl+C sent while they are still booting
|
|
10
|
+
# is lost. Harmless on Pico — 0x03 while idle just re-prints READY.
|
|
8
11
|
sleep 0.1
|
|
9
12
|
serial.write("\x03") rescue nil
|
|
10
13
|
|
|
11
14
|
buf = +''
|
|
12
|
-
deadline
|
|
15
|
+
deadline = Time.now + 10
|
|
16
|
+
next_ctrl = Time.now + 1
|
|
13
17
|
loop do
|
|
14
18
|
buf << normalize(safe_read(serial, 256))
|
|
15
19
|
if buf.include?('READY ')
|
|
@@ -18,6 +22,10 @@ module Prremote
|
|
|
18
22
|
end
|
|
19
23
|
raise 'Timeout waiting for device. Run `prremote reset` if a script is running.' if Time.now > deadline
|
|
20
24
|
|
|
25
|
+
if Time.now > next_ctrl
|
|
26
|
+
serial.write("\x03") rescue nil
|
|
27
|
+
next_ctrl = Time.now + 1
|
|
28
|
+
end
|
|
21
29
|
sleep 0.05
|
|
22
30
|
end
|
|
23
31
|
end
|
|
@@ -26,6 +34,25 @@ module Prremote
|
|
|
26
34
|
str.gsub("\r\n", "\n").gsub("\r", '')
|
|
27
35
|
end
|
|
28
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
|
+
|
|
29
56
|
# Wraps serial.read so that a device disconnect (e.g. ENXIO on macOS when
|
|
30
57
|
# the Pico resets) surfaces as a human-readable error instead of a bare errno name.
|
|
31
58
|
def safe_read(serial, size)
|
data/lib/prremote/detector.rb
CHANGED
|
@@ -2,7 +2,16 @@ require 'rbconfig'
|
|
|
2
2
|
|
|
3
3
|
module Prremote
|
|
4
4
|
class Detector
|
|
5
|
-
|
|
5
|
+
# Known USB vendor IDs → device label and the macOS port-name pattern.
|
|
6
|
+
# Pico exposes native USB CDC (usbmodem); ESP32 boards sit behind a
|
|
7
|
+
# USB-UART bridge (usbserial): CP210x on M5GO/M5Stack, CH910x on newer
|
|
8
|
+
# revisions.
|
|
9
|
+
KNOWN_VENDORS = {
|
|
10
|
+
'2e8a' => { label: 'Pico (prremote/R2P2)', macos: /usbmodem/ },
|
|
11
|
+
'10c4' => { label: 'ESP32 (CP210x)', macos: /usbserial/ },
|
|
12
|
+
'1a86' => { label: 'ESP32 (CH910x)', macos: /usbserial/ }
|
|
13
|
+
}.freeze
|
|
14
|
+
R2P2_VENDOR_IDS = %w[2e8a].freeze # Raspberry Pi USB VID (kept for compat)
|
|
6
15
|
|
|
7
16
|
def self.find_device
|
|
8
17
|
new.find_device
|
|
@@ -12,14 +21,13 @@ module Prremote
|
|
|
12
21
|
candidates = serial_ports
|
|
13
22
|
return candidates.first if candidates.size == 1
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
known = candidates.select { |p| known_port?(p) }
|
|
25
|
+
known.first || candidates.first
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
def list_devices
|
|
20
29
|
serial_ports.map do |port|
|
|
21
|
-
label
|
|
22
|
-
{ port: port, label: label }
|
|
30
|
+
{ port: port, label: port_label(port) || 'unknown' }
|
|
23
31
|
end
|
|
24
32
|
end
|
|
25
33
|
|
|
@@ -45,23 +53,37 @@ module Prremote
|
|
|
45
53
|
end
|
|
46
54
|
end
|
|
47
55
|
|
|
48
|
-
def
|
|
49
|
-
|
|
56
|
+
def known_port?(port)
|
|
57
|
+
!port_label(port).nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def port_label(port)
|
|
61
|
+
# On macOS/Linux, check ioreg or sysfs for a known vendor ID
|
|
50
62
|
case RbConfig::CONFIG['host_os']
|
|
51
63
|
when /darwin/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
KNOWN_VENDORS.each do |vid, info|
|
|
65
|
+
return info[:label] if usb_vendor_ids.include?(vid) && port.match?(info[:macos])
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
55
68
|
when /linux/
|
|
56
69
|
port_name = File.basename(port)
|
|
57
70
|
vid_path = "/sys/class/tty/#{port_name}/device/../../../idVendor"
|
|
58
|
-
return
|
|
71
|
+
return nil unless File.exist?(vid_path)
|
|
59
72
|
|
|
60
73
|
vid = File.read(vid_path).strip.downcase
|
|
61
|
-
|
|
62
|
-
else
|
|
63
|
-
false
|
|
74
|
+
KNOWN_VENDORS.dig(vid, :label)
|
|
64
75
|
end
|
|
65
76
|
end
|
|
77
|
+
|
|
78
|
+
# Vendor IDs of all connected USB devices as 4-digit hex strings.
|
|
79
|
+
# ioreg prints idVendor in decimal (e.g. 4292 for 0x10c4).
|
|
80
|
+
def usb_vendor_ids
|
|
81
|
+
@usb_vendor_ids ||= ioreg_usb.scan(/"idVendor" = (\d+)/)
|
|
82
|
+
.map { |(dec)| format('%04x', dec.to_i) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ioreg_usb
|
|
86
|
+
@ioreg_usb ||= `ioreg -p IOUSB -l 2>/dev/null`
|
|
87
|
+
end
|
|
66
88
|
end
|
|
67
89
|
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
require 'rubyserial'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module Prremote
|
|
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.
|
|
14
|
+
#
|
|
15
|
+
# Protocol reference:
|
|
16
|
+
# https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html
|
|
17
|
+
class EspFlasher
|
|
18
|
+
# Command opcodes
|
|
19
|
+
FLASH_BEGIN = 0x02
|
|
20
|
+
FLASH_DATA = 0x03
|
|
21
|
+
FLASH_END = 0x04
|
|
22
|
+
SYNC = 0x08
|
|
23
|
+
SPI_SET_PARAMS = 0x0B
|
|
24
|
+
SPI_ATTACH = 0x0D
|
|
25
|
+
CHANGE_BAUD = 0x0F
|
|
26
|
+
SPI_FLASH_MD5 = 0x13
|
|
27
|
+
|
|
28
|
+
FLASH_WRITE_SIZE = 0x400 # max data per FLASH_DATA packet
|
|
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
|
|
34
|
+
|
|
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
|
|
44
|
+
|
|
45
|
+
# ioctl modem-control constants
|
|
46
|
+
TIOCM_DTR = 0x0002
|
|
47
|
+
TIOCM_RTS = 0x0004
|
|
48
|
+
DARWIN = RbConfig::CONFIG['host_os'] =~ /darwin/ ? true : false
|
|
49
|
+
TIOCMGET = DARWIN ? 0x4004746A : 0x5415
|
|
50
|
+
TIOCMBIS = DARWIN ? 0x8004746E : 0x5416
|
|
51
|
+
TIOCMBIC = DARWIN ? 0x8004746F : 0x5417
|
|
52
|
+
|
|
53
|
+
class Error < RuntimeError; end
|
|
54
|
+
|
|
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
|
+
|
|
63
|
+
unless RbConfig::CONFIG['host_os'] =~ /darwin|linux/
|
|
64
|
+
raise Error, 'pure-Ruby flashing supports macOS/Linux only; ' \
|
|
65
|
+
'on other systems use esptool: ' \
|
|
66
|
+
"esptool write-flash 0x0 #{image_path}"
|
|
67
|
+
end
|
|
68
|
+
|
|
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)
|
|
74
|
+
begin
|
|
75
|
+
flasher.enter_bootloader
|
|
76
|
+
flasher.sync!
|
|
77
|
+
upgrade = baud != ROM_BAUD && baud_supported?(baud)
|
|
78
|
+
serial = flasher.upgrade_baud(port, baud) if upgrade
|
|
79
|
+
flasher.write_flash(image, offset: 0)
|
|
80
|
+
flasher.verify_md5(image, offset: 0)
|
|
81
|
+
flasher.finish_flash
|
|
82
|
+
flasher.hard_reset
|
|
83
|
+
ensure
|
|
84
|
+
serial.close
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.baud_supported?(baud)
|
|
89
|
+
RubySerial::Posix::BAUDE_RATES.key?(baud)
|
|
90
|
+
rescue NameError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
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.'
|
|
120
|
+
end
|
|
121
|
+
|
|
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 ──────────────────────────────────────────────────────────
|
|
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).
|
|
149
|
+
def enter_bootloader
|
|
150
|
+
set_lines(dtr: false, rts: true)
|
|
151
|
+
sleep 0.1
|
|
152
|
+
set_lines(dtr: true, rts: false)
|
|
153
|
+
sleep 0.05
|
|
154
|
+
set_lines(dtr: false, rts: false)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def hard_reset
|
|
158
|
+
set_lines(dtr: false, rts: true)
|
|
159
|
+
sleep 0.1
|
|
160
|
+
set_lines(dtr: false, rts: false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ── protocol steps ─────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def sync!
|
|
166
|
+
payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32')
|
|
167
|
+
synced = 8.times.any? do |i|
|
|
168
|
+
@rxbuf.clear
|
|
169
|
+
begin
|
|
170
|
+
vlog format('sync: attempt %<n>d/8 (status_bytes=%<sb>d)', n: i + 1, sb: @status_bytes)
|
|
171
|
+
command(SYNC, payload, timeout: 0.5)
|
|
172
|
+
drain_responses
|
|
173
|
+
vlog 'sync: success'
|
|
174
|
+
true
|
|
175
|
+
rescue Error => e
|
|
176
|
+
vlog format('sync: attempt %<n>d failed (%<msg>s)', n: i + 1, msg: e.message)
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
raise Error, 'could not sync with the ESP boot ROM' unless synced
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# CHANGE_BAUDRATE, then reopen the port at the new speed.
|
|
184
|
+
def upgrade_baud(port, baud)
|
|
185
|
+
command(CHANGE_BAUD, [baud, 0].pack('V2'))
|
|
186
|
+
@serial.close
|
|
187
|
+
sleep 0.05
|
|
188
|
+
serial = Serial.new(port, baud)
|
|
189
|
+
@serial = serial
|
|
190
|
+
@fd = serial.instance_variable_get(:@fd)
|
|
191
|
+
@rxbuf.clear
|
|
192
|
+
serial
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def write_flash(image, offset: 0)
|
|
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'),
|
|
205
|
+
timeout: erase_timeout)
|
|
206
|
+
stream_blocks(image, blocks)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# FLASH_END is a courtesy; hard_reset resets the chip anyway.
|
|
210
|
+
# The ROM has been seen returning error 0x06 here — ignore it.
|
|
211
|
+
def finish_flash
|
|
212
|
+
command(FLASH_END, [1].pack('V'))
|
|
213
|
+
rescue Error
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def verify_md5(image, offset: 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*')
|
|
223
|
+
local_md5 = Digest::MD5.hexdigest(image)
|
|
224
|
+
return if device_md5 == local_md5
|
|
225
|
+
|
|
226
|
+
raise Error, "MD5 mismatch after flashing (device #{device_md5}, local #{local_md5})"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── request/response plumbing ──────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def command(op, payload, checksum: 0, timeout: 3)
|
|
232
|
+
packet = [0x00, op, payload.bytesize].pack('CCv') +
|
|
233
|
+
[checksum].pack('V') + payload
|
|
234
|
+
@serial.write(slip_encode(packet))
|
|
235
|
+
|
|
236
|
+
deadline = Time.now + timeout
|
|
237
|
+
loop do
|
|
238
|
+
frame = read_frame(deadline)
|
|
239
|
+
raise Error, format('timeout waiting for response to 0x%<op>02x', op: op) if frame.nil?
|
|
240
|
+
|
|
241
|
+
result = parse_response(frame, op)
|
|
242
|
+
return result if result
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def checksum(data)
|
|
247
|
+
data.bytes.reduce(CHECKSUM_SEED) { |acc, b| acc ^ b }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def slip_encode(packet)
|
|
251
|
+
escaped = packet.gsub("\xDB".b, "\xDB\xDD".b).gsub("\xC0".b, "\xDB\xDC".b)
|
|
252
|
+
"\xC0".b + escaped + "\xC0".b
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def slip_decode(frame)
|
|
256
|
+
frame.gsub("\xDB\xDC".b, "\xC0".b).gsub("\xDB\xDD".b, "\xDB".b)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
def vlog(msg)
|
|
262
|
+
warn "[flash] #{msg}" if @verbose
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def stream_blocks(image, blocks)
|
|
266
|
+
blocks.times do |seq|
|
|
267
|
+
block = image.byteslice(seq * FLASH_WRITE_SIZE, FLASH_WRITE_SIZE)
|
|
268
|
+
block += "\xFF".b * (FLASH_WRITE_SIZE - block.bytesize)
|
|
269
|
+
command(FLASH_DATA,
|
|
270
|
+
[block.bytesize, seq, 0, 0].pack('V4') + block,
|
|
271
|
+
checksum: checksum(block), timeout: 5)
|
|
272
|
+
progress(seq + 1, blocks)
|
|
273
|
+
end
|
|
274
|
+
$stderr.print "\n"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_response(frame, op)
|
|
278
|
+
return nil if frame.bytesize < 8 + @status_bytes || frame.getbyte(0) != 0x01 ||
|
|
279
|
+
frame.getbyte(1) != op
|
|
280
|
+
|
|
281
|
+
value = frame.byteslice(4, 4).unpack1('V')
|
|
282
|
+
body = frame.byteslice(8..)
|
|
283
|
+
status = body.byteslice(-@status_bytes, @status_bytes)
|
|
284
|
+
if status.getbyte(0) != 0
|
|
285
|
+
raise Error, format('command 0x%<op>02x failed (error 0x%<err>02x)',
|
|
286
|
+
op: op, err: status.getbyte(1))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
[value, body.byteslice(0...-@status_bytes)]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_frame(deadline)
|
|
293
|
+
loop do
|
|
294
|
+
start = @rxbuf.index("\xC0".b)
|
|
295
|
+
if start
|
|
296
|
+
stop = @rxbuf.index("\xC0".b, start + 1)
|
|
297
|
+
if stop
|
|
298
|
+
frame = @rxbuf.byteslice((start + 1)...stop)
|
|
299
|
+
@rxbuf = @rxbuf.byteslice((stop + 1)..) || +''.b
|
|
300
|
+
next if frame.empty?
|
|
301
|
+
|
|
302
|
+
return slip_decode(frame)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
return nil if Time.now > deadline
|
|
306
|
+
|
|
307
|
+
chunk = @serial.read(256) || ''
|
|
308
|
+
chunk.empty? ? sleep(0.01) : @rxbuf << chunk.b
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def drain_responses
|
|
313
|
+
deadline = Time.now + 0.3
|
|
314
|
+
loop { break if read_frame(deadline).nil? }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def set_lines(dtr:, rts:)
|
|
318
|
+
return if @fd.nil?
|
|
319
|
+
|
|
320
|
+
io = IO.for_fd(@fd, autoclose: false)
|
|
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
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def progress(done, total)
|
|
328
|
+
pct = done * 100 / total
|
|
329
|
+
return if pct == @last_pct
|
|
330
|
+
|
|
331
|
+
@last_pct = pct
|
|
332
|
+
$stderr.print format("\rWriting %<pct>3d%% (%<done>d/%<total>d)",
|
|
333
|
+
pct: pct, done: done, total: total)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -4,14 +4,19 @@ require 'fileutils'
|
|
|
4
4
|
|
|
5
5
|
module Prremote
|
|
6
6
|
module RuntimeManager
|
|
7
|
-
BOARDS = %w[pico picow].freeze
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
BOARDS = %w[pico picow esp32 esp32c6].freeze
|
|
8
|
+
ESP32_BOARDS = %w[esp32 esp32c6].freeze
|
|
9
|
+
|
|
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.
|
|
13
|
+
def self.artifact_filename(version, board)
|
|
14
|
+
ext = ESP32_BOARDS.include?(board) ? 'bin' : 'uf2'
|
|
15
|
+
"prremote-#{board}-runtime-#{version}.#{ext}"
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
def self.release_url(version, board)
|
|
14
|
-
"https://github.com/lumbermill/prremote/releases/download/runtime-#{version}/#{
|
|
19
|
+
"https://github.com/lumbermill/prremote/releases/download/runtime-#{version}/#{artifact_filename(version, board)}"
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def self.cache_dir
|
|
@@ -19,7 +24,7 @@ module Prremote
|
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
def self.cached_path(version, board)
|
|
22
|
-
File.join(cache_dir,
|
|
27
|
+
File.join(cache_dir, artifact_filename(version, board))
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def self.fetch(version, board)
|
|
@@ -27,7 +32,7 @@ module Prremote
|
|
|
27
32
|
return path if File.exist?(path)
|
|
28
33
|
|
|
29
34
|
FileUtils.mkdir_p(cache_dir)
|
|
30
|
-
$stderr.print "Downloading #{
|
|
35
|
+
$stderr.print "Downloading #{artifact_filename(version, board)}..."
|
|
31
36
|
$stderr.flush
|
|
32
37
|
download(release_url(version, board), path)
|
|
33
38
|
warn ' done.'
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ITO Yosei
|
|
@@ -116,6 +116,7 @@ files:
|
|
|
116
116
|
- lib/prremote/commands/undeploy.rb
|
|
117
117
|
- lib/prremote/commands/watch.rb
|
|
118
118
|
- lib/prremote/detector.rb
|
|
119
|
+
- lib/prremote/esp_flasher.rb
|
|
119
120
|
- lib/prremote/mrbc.rb
|
|
120
121
|
- lib/prremote/runtime_manager.rb
|
|
121
122
|
- lib/prremote/version.rb
|
|
@@ -138,7 +139,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
139
|
- !ruby/object:Gem::Version
|
|
139
140
|
version: '0'
|
|
140
141
|
requirements: []
|
|
141
|
-
rubygems_version: 4.0.
|
|
142
|
+
rubygems_version: 4.0.10
|
|
142
143
|
specification_version: 4
|
|
143
144
|
summary: CLI tool for deploying and running mruby/c scripts on a Raspberry Pi Pico
|
|
144
145
|
W over USB serial
|