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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b55c090d4bddcdac18b7a1166429af025eeecc0416e31dedf51f248b4d2cd975
4
- data.tar.gz: c81096a50e22d31064a8f721daca2de89dcfc5ccbb6e6c9d2d3fb58973c85ac0
3
+ metadata.gz: 5c315e2104e35b23c87d7565249c2695f0bc8b9f7d0075564dda98b8fcc11491
4
+ data.tar.gz: 3e8ae2b89ca93b33f7830d45413dadef6f7c2d1d0a19d51499649214d626f1a6
5
5
  SHA512:
6
- metadata.gz: 8d959c616f0205f2e63f9ba8eba7d96f66943822e1e59be4dc41cb58aed070fabe816ff1473a6cbee7057bdc19533274dbf8c0c2a5e04924d354bcaae54653db
7
- data.tar.gz: 9a1fd33542e02a7a97f0e0a9d9626e3f688d80cf3027ff975de58e2159defc90fa3880a9611cb35b125359c7fc8d36ed3708e517673e16795ac3e8cb6a1d566a
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
- - Raspberry Pi Pico W
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 Pico W or Pico.
60
+ Flash the prremote runtime firmware to a supported board.
58
61
 
59
62
  ```bash
60
- prremote install # Pico W (default)
61
- prremote install --board pico # Pico (no wireless)
62
- prremote install --version 0.1.1 # specify a runtime version
63
- prremote install --board pico --version 0.1.1
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
- Put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
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 [test/samples](test/samples/).
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.7
167
- # runtime: 0.1.7 (/dev/tty.usbmodem101)
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`, `-b N` | Baud rate (default: `115200`) |
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 and WiFi (CYW43) C bindings are available in Ruby code running on the device.
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
- Requires the ARM cross-compiler (`arm-none-eabi-gcc`) and CMake.
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 cache # build UF2 for pico and picow → ~/.prremote/runtime/
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.7
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, aliases: '-b', type: :numeric, default: 115_200, desc: 'Baud rate'
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 Pico W or Pico'
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, type: :string, desc: 'Board type: pico or picow (default: picow)'
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] || 'picow'
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}'. Valid values: #{RuntimeManager::BOARDS.join(', ')}"
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).call
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.write(DEPLOY_MAGIC + build_meta_packet(rb_paths) + mrb_data)
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 = 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.write(mrb_data)
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 = Time.now + 10
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)
@@ -2,7 +2,16 @@ require 'rbconfig'
2
2
 
3
3
  module Prremote
4
4
  class Detector
5
- R2P2_VENDOR_IDS = %w[2e8a].freeze # Raspberry Pi USB VID
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
- r2p2 = candidates.select { |p| r2p2_port?(p) }
16
- r2p2.first || candidates.first
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 = r2p2_port?(port) ? 'R2P2/PicoRuby' : 'unknown'
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 r2p2_port?(port)
49
- # On macOS/Linux, check sysfs or ioreg for the Raspberry Pi VID
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
- ioreg_output = `ioreg -p IOUSB -l 2>/dev/null`
53
- R2P2_VENDOR_IDS.any? { |vid| ioreg_output.include?(vid) } &&
54
- port.match?(/usbmodem/)
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 false unless File.exist?(vid_path)
71
+ return nil unless File.exist?(vid_path)
59
72
 
60
73
  vid = File.read(vid_path).strip.downcase
61
- R2P2_VENDOR_IDS.include?(vid)
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
- def self.uf2_filename(version, board)
10
- "prremote-#{board}-runtime-#{version}.uf2"
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}/#{uf2_filename(version, board)}"
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, uf2_filename(version, board))
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 #{uf2_filename(version, board)}..."
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.7
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.3
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