prremote 0.1.7 → 0.2.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 +32 -9
- data/VERSION +1 -1
- data/lib/prremote/cli.rb +4 -3
- data/lib/prremote/commands/install.rb +16 -1
- data/lib/prremote/commands/serial_helpers.rb +9 -1
- data/lib/prremote/detector.rb +36 -14
- data/lib/prremote/esp_flasher.rb +288 -0
- data/lib/prremote/runtime_manager.rb +11 -7
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c370f33a2cd268d2ab4eba26c7123d195e71fc5a927c386d561e55ad5a2adb0e
|
|
4
|
+
data.tar.gz: 181302d7b8d2cd6e78f3a76b98171bddd5fb4c9d66f8c0aacd925f4911cb4920
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f411813a2aa5c91a59bb7ce05b74a1bbd6f85bfb4b8329f7a1df146d8a014eb65ee6750643c63d2bdb3d09ec8bd0b3943e28e14d48a459cb3d3f645bf6206a68
|
|
7
|
+
data.tar.gz: 47f84f1d2277a04b1739f7c72f3565d9c3e8f6ba2700abc38142370d90ef928e577eb931d3ec8a8dafc5019b0ac15bd5da857cc33a1c400f4ca3ae28f59109d4
|
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,9 @@ 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
|
+
- A supported board:
|
|
21
|
+
- Raspberry Pi Pico W / Pico
|
|
22
|
+
- ESP32 (classic) — e.g. M5GO / M5Stack Core gen1, generic dev boards
|
|
21
23
|
- `mrbc` (mruby 4.x) for `run`, `deploy`, and `eval`
|
|
22
24
|
- macOS: `brew install mruby`
|
|
23
25
|
- Linux: build from source — [github.com/mruby/mruby/releases](https://github.com/mruby/mruby/releases)
|
|
@@ -54,18 +56,21 @@ prremote run app.rb
|
|
|
54
56
|
|
|
55
57
|
### `install`
|
|
56
58
|
|
|
57
|
-
Flash the prremote runtime firmware to a Pico W or
|
|
59
|
+
Flash the prremote runtime firmware to a Pico W, Pico, or ESP32.
|
|
58
60
|
|
|
59
61
|
```bash
|
|
60
62
|
prremote install # Pico W (default)
|
|
61
63
|
prremote install --board pico # Pico (no wireless)
|
|
64
|
+
prremote install --board esp32 # ESP32 (M5GO / M5Stack Core, etc.)
|
|
62
65
|
prremote install --version 0.1.1 # specify a runtime version
|
|
63
66
|
prremote install --board pico --version 0.1.1
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
Pico boards: put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
|
|
72
|
+
|
|
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.
|
|
69
74
|
|
|
70
75
|
---
|
|
71
76
|
|
|
@@ -163,8 +168,8 @@ Show the gem version, mrbc version, and the connected device's runtime version.
|
|
|
163
168
|
|
|
164
169
|
```bash
|
|
165
170
|
prremote version
|
|
166
|
-
# prremote: 0.
|
|
167
|
-
# runtime: 0.
|
|
171
|
+
# prremote: 0.2.0
|
|
172
|
+
# runtime: 0.2.0 (/dev/tty.usbmodem101)
|
|
168
173
|
# mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
|
|
169
174
|
```
|
|
170
175
|
|
|
@@ -209,7 +214,7 @@ prremote flashes a minimal C firmware (built on mruby/c) onto the Pico W. The fi
|
|
|
209
214
|
- `DPLY` + `.mrb` bytecode → save to flash and confirm with `DEPLOYED` (`deploy`)
|
|
210
215
|
3. Waits for the next command
|
|
211
216
|
|
|
212
|
-
Scripts saved via `deploy` are stored in flash and run automatically on every boot. GPIO
|
|
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 (CYW43) on the Pico W; an `LCD` class (ILI9342C) on ESP32 / M5Stack.
|
|
213
218
|
|
|
214
219
|
---
|
|
215
220
|
|
|
@@ -232,11 +237,29 @@ git submodule update --init --recursive
|
|
|
232
237
|
|
|
233
238
|
### Build the runtime firmware
|
|
234
239
|
|
|
235
|
-
|
|
240
|
+
Pico boards require the ARM cross-compiler (`arm-none-eabi-gcc`) and CMake.
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
cd runtime/
|
|
244
|
+
rake build # UF2 for pico and picow
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The ESP32 runtime requires ESP-IDF v5.3, which is not vendored (it is several
|
|
248
|
+
GB and installs its own toolchains). One-time setup:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
mkdir -p ~/sources/esp
|
|
252
|
+
git clone -b v5.3.2 --recursive --shallow-submodules \
|
|
253
|
+
https://github.com/espressif/esp-idf.git ~/sources/esp/esp-idf
|
|
254
|
+
cd ~/sources/esp/esp-idf && ./install.sh esp32
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Then (set `IDF_PATH` if you installed somewhere else):
|
|
236
258
|
|
|
237
259
|
```bash
|
|
238
260
|
cd runtime/
|
|
239
|
-
rake
|
|
261
|
+
rake build:esp32 # merged .bin for esp32
|
|
262
|
+
rake cache # build all boards → ~/.prremote/runtime/
|
|
240
263
|
```
|
|
241
264
|
|
|
242
265
|
### Run the tests
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
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'
|
|
@@ -23,9 +24,9 @@ module Prremote
|
|
|
23
24
|
|
|
24
25
|
remove_command :tree
|
|
25
26
|
|
|
26
|
-
desc 'install', 'Flash prremote runtime firmware to Pico W or
|
|
27
|
+
desc 'install', 'Flash prremote runtime firmware to Pico W, Pico, or ESP32'
|
|
27
28
|
option :version, type: :string, desc: "Firmware version to install (default: #{VERSION})"
|
|
28
|
-
option :board, type: :string, desc: 'Board type: pico or
|
|
29
|
+
option :board, type: :string, desc: 'Board type: picow, pico, or esp32 (default: picow)'
|
|
29
30
|
def install
|
|
30
31
|
version = options[:version] || VERSION
|
|
31
32
|
board = options[:board] || 'picow'
|
|
@@ -33,7 +34,7 @@ module Prremote
|
|
|
33
34
|
raise Thor::Error, "Unknown board '#{board}'. Valid values: #{RuntimeManager::BOARDS.join(', ')}"
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
Commands::Install.new(version: version, board: board).call
|
|
37
|
+
Commands::Install.new(version: version, board: board, port: options[:port]).call
|
|
37
38
|
rescue StandardError => e
|
|
38
39
|
raise Thor::Error, e.message
|
|
39
40
|
end
|
|
@@ -3,12 +3,15 @@ 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)
|
|
7
7
|
@version = version
|
|
8
8
|
@board = board
|
|
9
|
+
@port = port
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def call
|
|
13
|
+
return install_esp32 if @board == 'esp32'
|
|
14
|
+
|
|
12
15
|
uf2_path = RuntimeManager.fetch(@version, @board)
|
|
13
16
|
|
|
14
17
|
device_label = @board == 'picow' ? 'Pico W' : 'Pico'
|
|
@@ -31,6 +34,18 @@ module Prremote
|
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
37
|
+
# No BOOTSEL dance on ESP32: the flasher toggles DTR/RTS to enter the
|
|
38
|
+
# boot ROM by itself, so flashing works over the normal serial port.
|
|
39
|
+
def install_esp32
|
|
40
|
+
image = RuntimeManager.fetch(@version, @board)
|
|
41
|
+
port = @port || Detector.find_device
|
|
42
|
+
raise 'No serial device found. Connect the board or pass --port.' unless port
|
|
43
|
+
|
|
44
|
+
puts "Flashing #{File.basename(image)} to #{port}..."
|
|
45
|
+
EspFlasher.flash(port: port, image_path: image)
|
|
46
|
+
puts "Done. Runtime #{@version} installed."
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def volume_paths
|
|
35
50
|
[
|
|
36
51
|
'/Volumes/RPI-RP2',
|
|
@@ -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
|
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,288 @@
|
|
|
1
|
+
require 'rubyserial'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module Prremote
|
|
6
|
+
# Pure-Ruby ESP32 flasher — speaks the Espressif serial bootloader protocol
|
|
7
|
+
# directly so `install --board esp32` needs no esptool / Python.
|
|
8
|
+
#
|
|
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
|
+
# Protocol reference:
|
|
13
|
+
# 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
|
+
class EspFlasher
|
|
19
|
+
# Command opcodes (ROM loader subset)
|
|
20
|
+
FLASH_BEGIN = 0x02
|
|
21
|
+
FLASH_DATA = 0x03
|
|
22
|
+
FLASH_END = 0x04
|
|
23
|
+
SYNC = 0x08
|
|
24
|
+
SPI_SET_PARAMS = 0x0B
|
|
25
|
+
SPI_ATTACH = 0x0D
|
|
26
|
+
CHANGE_BAUD = 0x0F
|
|
27
|
+
SPI_FLASH_MD5 = 0x13
|
|
28
|
+
|
|
29
|
+
FLASH_WRITE_SIZE = 0x400 # ROM loader max data per FLASH_DATA packet
|
|
30
|
+
STATUS_BYTES = 4 # ESP32 ROM appends 4 status bytes to responses
|
|
31
|
+
CHECKSUM_SEED = 0xEF
|
|
32
|
+
|
|
33
|
+
ROM_BAUD = 115_200
|
|
34
|
+
|
|
35
|
+
# ioctl modem-control constants
|
|
36
|
+
TIOCM_DTR = 0x0002
|
|
37
|
+
TIOCM_RTS = 0x0004
|
|
38
|
+
DARWIN = RbConfig::CONFIG['host_os'] =~ /darwin/ ? true : false
|
|
39
|
+
TIOCMGET = DARWIN ? 0x4004746A : 0x5415
|
|
40
|
+
TIOCMSET = DARWIN ? 0x8004746D : 0x5418
|
|
41
|
+
|
|
42
|
+
class Error < RuntimeError; end
|
|
43
|
+
|
|
44
|
+
# Flashes `image_path` at offset 0x0 and verifies it with an on-chip MD5.
|
|
45
|
+
# The transfer runs at `baud` when rubyserial supports it locally
|
|
46
|
+
# (macOS termios caps out at 230400), otherwise at the ROM's 115200.
|
|
47
|
+
def self.flash(port:, image_path:, baud: 230_400)
|
|
48
|
+
unless RbConfig::CONFIG['host_os'] =~ /darwin|linux/
|
|
49
|
+
raise Error, 'pure-Ruby flashing supports macOS/Linux only; ' \
|
|
50
|
+
'on other systems flash with esptool: ' \
|
|
51
|
+
"esptool write_flash 0x0 #{image_path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
image = File.binread(image_path)
|
|
55
|
+
serial = Serial.new(port, ROM_BAUD)
|
|
56
|
+
flasher = new(serial, fd: serial.instance_variable_get(:@fd))
|
|
57
|
+
begin
|
|
58
|
+
flasher.enter_bootloader
|
|
59
|
+
flasher.sync!
|
|
60
|
+
serial = flasher.upgrade_baud(port, baud) if baud != ROM_BAUD && baud_supported?(baud)
|
|
61
|
+
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
|
+
flasher.verify_md5(image, offset: 0)
|
|
65
|
+
flasher.finish_flash
|
|
66
|
+
flasher.hard_reset
|
|
67
|
+
ensure
|
|
68
|
+
serial.close
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
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
|
+
def self.baud_supported?(baud)
|
|
76
|
+
RubySerial::Posix::BAUDE_RATES.key?(baud)
|
|
77
|
+
rescue NameError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `serial` needs #read/#write; `fd` enables DTR/RTS control and may be
|
|
82
|
+
# nil in tests.
|
|
83
|
+
def initialize(serial, fd: nil)
|
|
84
|
+
@serial = serial
|
|
85
|
+
@fd = fd
|
|
86
|
+
@rxbuf = +''.b
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── chip reset control (DTR/RTS via the auto-download circuit) ────────
|
|
90
|
+
|
|
91
|
+
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
|
+
set_lines(dtr: false, rts: true)
|
|
95
|
+
sleep 0.1
|
|
96
|
+
set_lines(dtr: true, rts: false)
|
|
97
|
+
sleep 0.05
|
|
98
|
+
set_lines(dtr: false, rts: false)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def hard_reset
|
|
102
|
+
set_lines(dtr: false, rts: true)
|
|
103
|
+
sleep 0.1
|
|
104
|
+
set_lines(dtr: false, rts: false)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ── protocol steps ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def sync!
|
|
110
|
+
payload = [0x07, 0x07, 0x12, 0x20].pack('C4') + ([0x55] * 32).pack('C32')
|
|
111
|
+
synced = 8.times.any? do
|
|
112
|
+
@rxbuf.clear
|
|
113
|
+
begin
|
|
114
|
+
command(SYNC, payload, timeout: 0.5)
|
|
115
|
+
drain_responses
|
|
116
|
+
true
|
|
117
|
+
rescue Error
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
raise Error, 'could not sync with the ESP32 boot ROM' unless synced
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# CHANGE_BAUDRATE, then reopen the port at the new speed. Plain open does
|
|
125
|
+
# not touch DTR/RTS (verified with rubyserial), so the chip stays in the
|
|
126
|
+
# bootloader across the reopen.
|
|
127
|
+
def upgrade_baud(port, baud)
|
|
128
|
+
command(CHANGE_BAUD, [baud, 0].pack('V2'))
|
|
129
|
+
@serial.close
|
|
130
|
+
sleep 0.05
|
|
131
|
+
serial = Serial.new(port, baud)
|
|
132
|
+
@serial = serial
|
|
133
|
+
@fd = serial.instance_variable_get(:@fd)
|
|
134
|
+
@rxbuf.clear
|
|
135
|
+
serial
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def write_flash(image, offset: 0)
|
|
139
|
+
command(SPI_ATTACH, [0, 0].pack('V2'))
|
|
140
|
+
# id, total size, block size, sector size, page size, status mask
|
|
141
|
+
command(SPI_SET_PARAMS,
|
|
142
|
+
[0, 4 * 1024 * 1024, 64 * 1024, 4096, 256, 0xFFFF].pack('V6'))
|
|
143
|
+
|
|
144
|
+
blocks = (image.bytesize + FLASH_WRITE_SIZE - 1) / FLASH_WRITE_SIZE
|
|
145
|
+
# The ROM erases the region inside FLASH_BEGIN; allow ~30 s per MB.
|
|
146
|
+
erase_timeout = 30 * (1 + (image.bytesize / (1024 * 1024)))
|
|
147
|
+
command(FLASH_BEGIN,
|
|
148
|
+
[image.bytesize, blocks, FLASH_WRITE_SIZE, offset].pack('V4'),
|
|
149
|
+
timeout: erase_timeout)
|
|
150
|
+
stream_blocks(image, blocks)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Every block is already committed once its FLASH_DATA is acked, so
|
|
154
|
+
# FLASH_END is only a courtesy "done" (we leave the loader via hard_reset
|
|
155
|
+
# anyway). The ESP32 ROM has been seen answering it with error 0x06 —
|
|
156
|
+
# ignore it; the MD5 check is the source of truth.
|
|
157
|
+
def finish_flash
|
|
158
|
+
command(FLASH_END, [1].pack('V')) # 1 = stay in the loader
|
|
159
|
+
rescue Error
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def verify_md5(image, offset: 0)
|
|
164
|
+
timeout = 8 * (1 + (image.bytesize / (1024 * 1024)))
|
|
165
|
+
_value, data = command(SPI_FLASH_MD5,
|
|
166
|
+
[offset, image.bytesize, 0, 0].pack('V4'),
|
|
167
|
+
timeout: timeout)
|
|
168
|
+
device_md5 = data[0, 32] # the ROM loader answers as 32 hex chars
|
|
169
|
+
local_md5 = Digest::MD5.hexdigest(image)
|
|
170
|
+
return if device_md5 == local_md5
|
|
171
|
+
|
|
172
|
+
raise Error, "MD5 mismatch after flashing (device #{device_md5}, local #{local_md5})"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ── request/response plumbing ──────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
# Sends one command packet and waits for its response.
|
|
178
|
+
# Returns [value, data] from the response (status bytes stripped).
|
|
179
|
+
def command(op, payload, checksum: 0, timeout: 3)
|
|
180
|
+
packet = [0x00, op, payload.bytesize].pack('CCv') +
|
|
181
|
+
[checksum].pack('V') + payload
|
|
182
|
+
@serial.write(slip_encode(packet))
|
|
183
|
+
|
|
184
|
+
deadline = Time.now + timeout
|
|
185
|
+
loop do
|
|
186
|
+
frame = read_frame(deadline)
|
|
187
|
+
raise Error, format('timeout waiting for response to 0x%<op>02x', op: op) if frame.nil?
|
|
188
|
+
|
|
189
|
+
result = parse_response(frame, op)
|
|
190
|
+
return result if result
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def checksum(data)
|
|
195
|
+
data.bytes.reduce(CHECKSUM_SEED) { |acc, b| acc ^ b }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def slip_encode(packet)
|
|
199
|
+
escaped = packet.gsub("\xDB".b, "\xDB\xDD".b).gsub("\xC0".b, "\xDB\xDC".b)
|
|
200
|
+
"\xC0".b + escaped + "\xC0".b
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def slip_decode(frame)
|
|
204
|
+
frame.gsub("\xDB\xDC".b, "\xC0".b).gsub("\xDB\xDD".b, "\xDB".b)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def stream_blocks(image, blocks)
|
|
210
|
+
blocks.times do |seq|
|
|
211
|
+
block = image.byteslice(seq * FLASH_WRITE_SIZE, FLASH_WRITE_SIZE)
|
|
212
|
+
block += "\xFF".b * (FLASH_WRITE_SIZE - block.bytesize)
|
|
213
|
+
command(FLASH_DATA,
|
|
214
|
+
[block.bytesize, seq, 0, 0].pack('V4') + block,
|
|
215
|
+
checksum: checksum(block), timeout: 5)
|
|
216
|
+
progress(seq + 1, blocks)
|
|
217
|
+
end
|
|
218
|
+
$stderr.print "\n"
|
|
219
|
+
end
|
|
220
|
+
|
|
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
|
+
def parse_response(frame, op)
|
|
224
|
+
return nil if frame.bytesize < 8 + STATUS_BYTES || frame.getbyte(0) != 0x01 ||
|
|
225
|
+
frame.getbyte(1) != op
|
|
226
|
+
|
|
227
|
+
value = frame.byteslice(4, 4).unpack1('V')
|
|
228
|
+
body = frame.byteslice(8..)
|
|
229
|
+
status = body.byteslice(-STATUS_BYTES, STATUS_BYTES)
|
|
230
|
+
if status.getbyte(0) != 0
|
|
231
|
+
raise Error, format('command 0x%<op>02x failed (error 0x%<err>02x)',
|
|
232
|
+
op: op, err: status.getbyte(1))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
[value, body.byteslice(0...-STATUS_BYTES)]
|
|
236
|
+
end
|
|
237
|
+
|
|
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
|
+
def read_frame(deadline)
|
|
241
|
+
loop do
|
|
242
|
+
start = @rxbuf.index("\xC0".b)
|
|
243
|
+
if start
|
|
244
|
+
stop = @rxbuf.index("\xC0".b, start + 1)
|
|
245
|
+
if stop
|
|
246
|
+
frame = @rxbuf.byteslice((start + 1)...stop)
|
|
247
|
+
@rxbuf = @rxbuf.byteslice((stop + 1)..) || +''.b
|
|
248
|
+
next if frame.empty? # back-to-back C0 markers
|
|
249
|
+
|
|
250
|
+
return slip_decode(frame)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
return nil if Time.now > deadline
|
|
254
|
+
|
|
255
|
+
chunk = @serial.read(256) || ''
|
|
256
|
+
chunk.empty? ? sleep(0.01) : @rxbuf << chunk.b
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
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
|
+
def drain_responses
|
|
263
|
+
deadline = Time.now + 0.3
|
|
264
|
+
loop { break if read_frame(deadline).nil? }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def set_lines(dtr:, rts:)
|
|
268
|
+
return if @fd.nil?
|
|
269
|
+
|
|
270
|
+
io = IO.for_fd(@fd, autoclose: false)
|
|
271
|
+
buf = [0].pack('L')
|
|
272
|
+
io.ioctl(TIOCMGET, buf)
|
|
273
|
+
bits = buf.unpack1('L')
|
|
274
|
+
bits = dtr ? (bits | TIOCM_DTR) : (bits & ~TIOCM_DTR)
|
|
275
|
+
bits = rts ? (bits | TIOCM_RTS) : (bits & ~TIOCM_RTS)
|
|
276
|
+
io.ioctl(TIOCMSET, [bits].pack('L'))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def progress(done, total)
|
|
280
|
+
pct = done * 100 / total
|
|
281
|
+
return if pct == @last_pct
|
|
282
|
+
|
|
283
|
+
@last_pct = pct
|
|
284
|
+
$stderr.print format("\rWriting %<pct>3d%% (%<done>d/%<total>d)",
|
|
285
|
+
pct: pct, done: done, total: total)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -4,14 +4,18 @@ 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].freeze
|
|
8
|
+
|
|
9
|
+
# Pico boards ship as UF2 (copied to the BOOTSEL drive); ESP32 ships as a
|
|
10
|
+
# single merged .bin (bootloader + partition table + app) flashed at 0x0
|
|
11
|
+
# with esptool.
|
|
12
|
+
def self.artifact_filename(version, board)
|
|
13
|
+
ext = board == 'esp32' ? 'bin' : 'uf2'
|
|
14
|
+
"prremote-#{board}-runtime-#{version}.#{ext}"
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def self.release_url(version, board)
|
|
14
|
-
"https://github.com/lumbermill/prremote/releases/download/runtime-#{version}/#{
|
|
18
|
+
"https://github.com/lumbermill/prremote/releases/download/runtime-#{version}/#{artifact_filename(version, board)}"
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def self.cache_dir
|
|
@@ -19,7 +23,7 @@ module Prremote
|
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def self.cached_path(version, board)
|
|
22
|
-
File.join(cache_dir,
|
|
26
|
+
File.join(cache_dir, artifact_filename(version, board))
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def self.fetch(version, board)
|
|
@@ -27,7 +31,7 @@ module Prremote
|
|
|
27
31
|
return path if File.exist?(path)
|
|
28
32
|
|
|
29
33
|
FileUtils.mkdir_p(cache_dir)
|
|
30
|
-
$stderr.print "Downloading #{
|
|
34
|
+
$stderr.print "Downloading #{artifact_filename(version, board)}..."
|
|
31
35
|
$stderr.flush
|
|
32
36
|
download(release_url(version, board), path)
|
|
33
37
|
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.
|
|
4
|
+
version: 0.2.0
|
|
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
|