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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b55c090d4bddcdac18b7a1166429af025eeecc0416e31dedf51f248b4d2cd975
4
- data.tar.gz: c81096a50e22d31064a8f721daca2de89dcfc5ccbb6e6c9d2d3fb58973c85ac0
3
+ metadata.gz: c370f33a2cd268d2ab4eba26c7123d195e71fc5a927c386d561e55ad5a2adb0e
4
+ data.tar.gz: 181302d7b8d2cd6e78f3a76b98171bddd5fb4c9d66f8c0aacd925f4911cb4920
5
5
  SHA512:
6
- metadata.gz: 8d959c616f0205f2e63f9ba8eba7d96f66943822e1e59be4dc41cb58aed070fabe816ff1473a6cbee7057bdc19533274dbf8c0c2a5e04924d354bcaae54653db
7
- data.tar.gz: 9a1fd33542e02a7a97f0e0a9d9626e3f688d80cf3027ff975de58e2159defc90fa3880a9611cb35b125359c7fc8d36ed3708e517673e16795ac3e8cb6a1d566a
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
- - Raspberry Pi Pico W
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 Pico.
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
- Put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
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.1.7
167
- # runtime: 0.1.7 (/dev/tty.usbmodem101)
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 and WiFi (CYW43) C bindings are available in Ruby code running on the device.
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
- Requires the ARM cross-compiler (`arm-none-eabi-gcc`) and CMake.
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 cache # build UF2 for pico and picow → ~/.prremote/runtime/
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.7
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 Pico'
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 picow (default: picow)'
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 = 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
@@ -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,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
- def self.uf2_filename(version, board)
10
- "prremote-#{board}-runtime-#{version}.uf2"
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}/#{uf2_filename(version, board)}"
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, uf2_filename(version, board))
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 #{uf2_filename(version, board)}..."
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.1.7
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