prremote 0.4.1 → 0.4.3

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: fa8044b474d22090a340385b2995d1e242b3aa44f820e0f7dbd3486b4b141a77
4
- data.tar.gz: 9af86c303d859f714b0f566d1910f200d1e3c70a5e56b8b9e799d61299c10aa6
3
+ metadata.gz: 27b1b34fbd6bbb46d3adb571005dafe869b286b7bc2babc547af1c72cd7f9cd6
4
+ data.tar.gz: 6f35395ede42d344d2b5de27502fe20631551a94ba1c6ea115abefccd4cda9d0
5
5
  SHA512:
6
- metadata.gz: ab718d4431f723ed4af6d17ad744654bf547cf167afa7f1e5bd60aebb96c2d7bd6470a14898a769c7660a5c959331dff5a71527297b4245e9418abe02511c727
7
- data.tar.gz: 220d6bf77f25475dd25ecf84ffd09a06ae592469e6c2bc90e4af117086b44a5f408ae90a75666d3cd26a79b38b1fd9832f1470cccd838c9edd4bf54176786ec6
6
+ metadata.gz: 9097fa9e4df160a9bf2bf4b4667d56a0fc3c8600e359d568b9aa028a9f3affb899b3e7a7ad2f9ae0204b824df378cbaa4f6c2935214807b504d70d59014eeee7
7
+ data.tar.gz: 0be3d04ce9b4bd1b07cb1d690b4a844d33c9f6e76c6594b270714214eef1c9662a2eb2151d26ee1e1123a8c6ea0c6c8b8b11caf552d1f62901d5cdd394a2038f
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 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.
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, Linux or WSL2 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,8 +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
+ - Supported hosts: macOS, Linux, and WSL2 — see [WSL](#wsl) for the extra setup WSL needs
20
21
  - Supported boards:
21
22
  - [Raspberry Pi Pico W](https://www.raspberrypi.com/products/raspberry-pi-pico-w/) / [Pico](https://www.raspberrypi.com/products/raspberry-pi-pico/)
23
+ - [Raspberry Pi Pico 2](https://www.raspberrypi.com/products/raspberry-pi-pico-2/) (RP2350) — `install` not yet verified on real hardware; reports welcome
22
24
  - ESP32 (classic) — e.g. [M5GO / M5Stack Core gen1](https://docs.m5stack.com/en/core/m5go), [M5StickC PLUS](https://docs.m5stack.com/en/core/m5stickc_plus), generic [ESP32](https://www.espressif.com/en/products/socs/esp32) dev boards
23
25
  - ESP32-C6 (RISC-V) — e.g. [Seeed Studio XIAO ESP32C6](https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/)
24
26
  - Per-board feature status (GPIO / ADC / PWM / I2C / SPI / WiFi / sockets / LCD / IR): [docs/SUPPORT.md](docs/SUPPORT.md)
@@ -64,6 +66,7 @@ Flash the prremote runtime firmware to a supported board.
64
66
  prremote install # show supported boards
65
67
  prremote install -b picow # Pico W
66
68
  prremote install -b pico # Pico (no wireless)
69
+ prremote install -b pico2 # Pico 2 (RP2350)
67
70
  prremote install -b esp32 # ESP32 (M5GO / M5Stack Core, etc.)
68
71
  prremote install -b m5stickc_plus # M5StickC PLUS (esp32 runtime + board library)
69
72
  prremote install -b esp32c6 # ESP32-C6 (e.g. XIAO ESP32C6)
@@ -74,7 +77,7 @@ prremote install -b picow --version 0.1.1 # specify a runtime version
74
77
 
75
78
  The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
76
79
 
77
- Pico boards: put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
80
+ Pico boards: if the board is already running the prremote runtime, `install` reboots it into BOOTSEL mode by itself (a 1200 bps touch on its serial port). Otherwise — a fresh board, or one running something else — put it into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted. The drive appears as `RPI-RP2` on Pico / Pico W and as `RP2350` on Pico 2.
78
81
 
79
82
  ESP32 (classic): no button dance and no extra tools needed — the firmware is written over the serial port by prremote's pure-Ruby implementation of the Espressif bootloader protocol (the chip is reset into its boot ROM automatically, and the write is verified with an on-chip MD5).
80
83
 
@@ -180,13 +183,48 @@ Show the gem version, mrbc version, and the connected device's runtime version.
180
183
 
181
184
  ```bash
182
185
  prremote version
183
- # prremote: 0.4.1
184
- # runtime: 0.4.1 board=picow (/dev/tty.usbmodem101)
186
+ # prremote: 0.4.3
187
+ # runtime: 0.4.3 board=picow (/dev/tty.usbmodem101)
185
188
  # mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
186
189
  ```
187
190
 
188
191
  ---
189
192
 
193
+ ## WSL
194
+
195
+ prremote runs under WSL2, but two things are not automatic there.
196
+
197
+ **USB serial devices are not forwarded.** WSL2 does not pass USB through to the guest, so a board Windows sees perfectly well has no `/dev/ttyACM*` on the Linux side. Install [usbipd-win](https://github.com/dorssel/usbipd-win) on the Windows side and attach the board:
198
+
199
+ ```powershell
200
+ winget install --exact dorssel.usbipd-win
201
+ usbipd list # note the BUSID of your board
202
+ usbipd bind --busid <BUSID> # once, as Administrator
203
+ usbipd attach --wsl --busid <BUSID> # after every reconnect
204
+ ```
205
+
206
+ Then, in WSL:
207
+
208
+ ```bash
209
+ prremote list # /dev/ttyACM0 should now show up
210
+ ```
211
+
212
+ If the port is there but cannot be opened, add yourself to the `dialout` group (`sudo usermod -aG dialout $USER`) and restart WSL with `wsl --shutdown`.
213
+
214
+ **Removable drives are not mounted.** For Pico boards, the BOOTSEL volume arrives as a Windows drive letter, which WSL does not mount on its own. Mount it before running `install`:
215
+
216
+ ```bash
217
+ sudo mkdir -p /mnt/d
218
+ sudo mount -t drvfs D: /mnt/d # use the drive letter RPI-RP2 was assigned
219
+ prremote install -b picow
220
+ ```
221
+
222
+ `install` scans `/mnt/d` through `/mnt/z` for the BOOTSEL volume, so the mount point only has to match the drive letter.
223
+
224
+ ESP32 boards need neither step in WSL beyond the usbipd attach, since they are flashed over the serial port.
225
+
226
+ ---
227
+
190
228
  ## Global Options
191
229
 
192
230
  | Option | Description |
@@ -200,7 +238,7 @@ prremote version
200
238
 
201
239
  ```bash
202
240
  # First-time setup
203
- prremote install -b picow # or: pico / esp32 / esp32c6
241
+ prremote install -b picow # or: pico / pico2 / esp32 / esp32c6
204
242
 
205
243
  # Manual cycle
206
244
  prremote run app.rb # compile + run (one-shot)
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.4.3
data/lib/prremote/cli.rb CHANGED
@@ -112,6 +112,8 @@ module Prremote
112
112
  devices = Detector.new.list_devices
113
113
  if devices.empty?
114
114
  puts 'No serial devices found.'
115
+ hint = Detector.no_device_hint
116
+ puts hint if hint
115
117
  else
116
118
  devices.each { |d| puts "#{d[:port]} (#{d[:label]})" }
117
119
  end
@@ -9,6 +9,23 @@ module Prremote
9
9
 
10
10
  BOARD_MAGIC = 'BORD'.freeze
11
11
 
12
+ DEVICE_LABELS = { 'pico' => 'Pico', 'picow' => 'Pico W', 'pico2' => 'Pico 2' }.freeze
13
+
14
+ # The UF2 bootloader exposes this file on the BOOTSEL volume.
15
+ BOOTSEL_MARKER = 'INFO_UF2.TXT'.freeze
16
+
17
+ # pico-sdk's stdio_usb reboots into the BOOTSEL boot ROM when the host
18
+ # sets this baud rate (PICO_STDIO_USB_RESET_MAGIC_BAUD_RATE), so a board
19
+ # already running the runtime can be flashed without the button.
20
+ RESET_BAUD = 1200
21
+
22
+ # How long to wait for the BOOTSEL drive after the 1200 bps touch before
23
+ # falling back to the manual instructions.
24
+ AUTO_BOOTSEL_TIMEOUT = 10
25
+
26
+ # Boards whose install path has not been run on real hardware yet.
27
+ UNVERIFIED_BOARDS = %w[pico2].freeze
28
+
12
29
  # board may be a runtime image name (esp32) or a board sharing one
13
30
  # (m5stickc_plus → esp32 image); see Boards::FIRMWARE.
14
31
  def initialize(version: VERSION, board: 'picow', port: nil, verbose: false)
@@ -29,15 +46,8 @@ module Prremote
29
46
 
30
47
  uf2_path = RuntimeManager.fetch(@version, @board)
31
48
 
32
- device_label = @board == 'picow' ? 'Pico W' : 'Pico'
33
- puts "Put the #{device_label} into BOOTSEL mode:"
34
- puts ' 1. Hold the BOOTSEL button'
35
- puts ' 2. Connect USB (or press RUN while holding BOOTSEL)'
36
- puts ' 3. Release BOOTSEL — RPI-RP2 should appear as a USB drive'
37
- puts
38
- puts 'Waiting for RPI-RP2...'
39
-
40
- volume = wait_for_volume
49
+ warn_unverified
50
+ volume = mounted_volume || auto_bootsel || manual_bootsel
41
51
  puts "Copying firmware to #{volume}..."
42
52
  FileUtils.cp(uf2_path, File.join(volume, File.basename(uf2_path)))
43
53
 
@@ -49,12 +59,65 @@ module Prremote
49
59
 
50
60
  private
51
61
 
62
+ # A board already sitting in BOOTSEL needs no reboot.
63
+ def mounted_volume
64
+ volume = volume_paths.find { |p| mounted?(p) }
65
+ puts "Found #{volume_name} at #{volume}." if volume
66
+ volume
67
+ end
68
+
69
+ # Reboots a board running the runtime into BOOTSEL with a 1200 bps
70
+ # touch. nil when there is no Pico port or the drive never shows up
71
+ # (no runtime on the board, a wedged runtime, or a WSL drive that still
72
+ # needs mounting), so the caller falls back to the button.
73
+ def auto_bootsel
74
+ port = @port || Detector.find_pico_port
75
+ return nil unless port
76
+
77
+ puts "Rebooting #{device_label} on #{port} into BOOTSEL mode..."
78
+ touch_reset_baud(port)
79
+ puts "Waiting for #{volume_name}..."
80
+ wait_for_volume(timeout: AUTO_BOOTSEL_TIMEOUT)
81
+ rescue RuntimeError
82
+ puts "#{volume_name} did not appear; falling back to manual BOOTSEL."
83
+ puts
84
+ nil
85
+ end
86
+
87
+ # Opening the port at the magic baud rate is the whole trigger. The board
88
+ # drops off USB right away, so errors while closing are expected.
89
+ def touch_reset_baud(port)
90
+ serial = Serial.new(port, RESET_BAUD)
91
+ rescue RubySerial::Error, SystemCallError => e
92
+ puts "Could not open #{port} at #{RESET_BAUD} bps: #{e.message}" if @verbose
93
+ ensure
94
+ begin
95
+ serial&.close
96
+ rescue RubySerial::Error, SystemCallError
97
+ nil
98
+ end
99
+ end
100
+
101
+ def manual_bootsel
102
+ puts "Put the #{device_label} into BOOTSEL mode:"
103
+ puts ' 1. Hold the BOOTSEL button'
104
+ puts ' 2. Connect USB (or press RUN while holding BOOTSEL)'
105
+ puts " 3. Release BOOTSEL — #{volume_name} should appear as a USB drive"
106
+ puts
107
+ puts "Waiting for #{volume_name}..."
108
+ wait_for_volume
109
+ end
110
+
52
111
  # No BOOTSEL dance on ESP32: the flasher toggles DTR/RTS to enter the
53
112
  # boot ROM by itself, so flashing works over the normal serial port.
54
113
  def install_esp32
55
114
  image = RuntimeManager.fetch(@version, @firmware)
56
115
  port = @port || Detector.find_device
57
- raise 'No serial device found. Connect the board or pass --port.' unless port
116
+ unless port
117
+ message = 'No serial device found. Connect the board or pass --port.'
118
+ hint = Detector.no_device_hint
119
+ raise [message, hint].compact.join("\n")
120
+ end
58
121
 
59
122
  puts "Flashing #{File.basename(image)} to #{port}..."
60
123
  EspFlasher.flash(port: port, image_path: image, board: @firmware, verbose: @verbose)
@@ -94,34 +157,87 @@ module Prremote
94
157
  end
95
158
  end
96
159
 
160
+ def warn_unverified
161
+ return unless UNVERIFIED_BOARDS.include?(@board)
162
+
163
+ warn "NOTE: installing on #{device_label} has not been verified on real hardware yet."
164
+ warn ' Please report the result at https://github.com/lumbermill/prremote/issues'
165
+ end
166
+
167
+ def device_label
168
+ DEVICE_LABELS.fetch(@board, 'Pico')
169
+ end
170
+
171
+ # The RP2040 boot ROM mounts its BOOTSEL drive as RPI-RP2; the RP2350
172
+ # (Pico 2) boot ROM uses RP2350 instead. INFO_UF2.TXT carries the same
173
+ # name as its Board-ID.
174
+ def volume_name
175
+ @board == 'pico2' ? 'RP2350' : 'RPI-RP2'
176
+ end
177
+
97
178
  def volume_paths
179
+ user = ENV.fetch('USER', nil)
98
180
  [
99
- '/Volumes/RPI-RP2',
100
- "/run/media/#{ENV.fetch('USER', nil)}/RPI-RP2",
101
- "/media/#{ENV.fetch('USER', nil)}/RPI-RP2"
102
- ]
181
+ "/Volumes/#{volume_name}",
182
+ "/run/media/#{user}/#{volume_name}",
183
+ "/media/#{user}/#{volume_name}"
184
+ ] + drive_letter_volume_paths
185
+ end
186
+
187
+ # WSL reaches the Windows drive letter the BOOTSEL volume lands on through
188
+ # drvfs at /mnt/<letter>. That path carries no volume name, so the
189
+ # marker file is what identifies the board.
190
+ def drive_letter_volume_paths
191
+ drive_letter_roots.select { |root| bootsel_volume?(root) }
192
+ end
193
+
194
+ # /mnt/c is the Windows system drive; the board never lands there, so the
195
+ # scan starts at D.
196
+ def drive_letter_roots
197
+ ('d'..'z').map { |letter| "/mnt/#{letter}" }
198
+ end
199
+
200
+ # Other UF2 bootloaders (and the other RP2 chip) expose INFO_UF2.TXT
201
+ # too, so the Board-ID line is what tells the target chip apart.
202
+ def bootsel_volume?(root)
203
+ marker = File.join(root, BOOTSEL_MARKER)
204
+ File.file?(marker) && /^Board-ID:\s*#{Regexp.escape(volume_name)}\s*$/.match?(File.read(marker))
205
+ rescue SystemCallError
206
+ false
103
207
  end
104
208
 
209
+ # The marker, not the directory, is the test: macOS can leave an empty
210
+ # /Volumes/RPI-RP2 behind after the board reboots off a copy.
105
211
  def wait_for_volume(timeout: 60)
106
212
  deadline = Time.now + timeout
107
213
  loop do
108
- path = volume_paths.find { |p| File.directory?(p) }
214
+ path = volume_paths.find { |p| mounted?(p) }
109
215
  return path if path
110
- raise "Timed out waiting for RPI-RP2 volume (#{timeout}s)" if Time.now > deadline
216
+ raise "Timed out waiting for #{volume_name} volume (#{timeout}s)" if Time.now > deadline
111
217
 
112
218
  sleep 1
113
219
  end
114
220
  end
115
221
 
222
+ # The board reboots as soon as the UF2 lands, which tears the volume down.
223
+ # Testing the marker rather than the directory covers the WSL case, where
224
+ # the /mnt/<letter> mountpoint outlives the drive it pointed at.
116
225
  def wait_for_unmount(volume, timeout: 30)
117
226
  deadline = Time.now + timeout
118
227
  loop do
119
- return unless File.directory?(volume)
228
+ return unless mounted?(volume)
120
229
  raise "Timed out waiting for device to reboot (#{timeout}s)" if Time.now > deadline
121
230
 
122
231
  sleep 1
123
232
  end
124
233
  end
234
+
235
+ def mounted?(volume)
236
+ File.directory?(volume) && File.file?(File.join(volume, BOOTSEL_MARKER))
237
+ rescue SystemCallError
238
+ # A stale drvfs mountpoint raises instead of reporting the drive as gone.
239
+ false
240
+ end
125
241
  end
126
242
  end
127
243
  end
@@ -13,10 +13,48 @@ module Prremote
13
13
  }.freeze
14
14
  R2P2_VENDOR_IDS = %w[2e8a].freeze # Raspberry Pi USB VID (kept for compat)
15
15
 
16
+ # How far above the tty node the USB device sits is not fixed: ttyUSB
17
+ # bridges add a level that the native-CDC ttyACM nodes do not, and under
18
+ # WSL the chain hangs off vhci_hcd rather than a PCI controller. The lookup
19
+ # walks up until idVendor turns up instead of guessing a depth.
20
+ SYSFS_WALK_LIMIT = 6
21
+
16
22
  def self.find_device
17
23
  new.find_device
18
24
  end
19
25
 
26
+ # WSL2 does not forward USB devices to the guest: a board Windows enumerates
27
+ # correctly still has no /dev/ttyACM* here until usbipd-win attaches it.
28
+ def self.wsl?
29
+ return @wsl unless @wsl.nil?
30
+
31
+ @wsl = RbConfig::CONFIG['host_os'].match?(/linux/) &&
32
+ File.file?('/proc/version') &&
33
+ File.read('/proc/version').downcase.include?('microsoft')
34
+ rescue SystemCallError
35
+ @wsl = false
36
+ end
37
+
38
+ # Appended wherever a missing device would otherwise look like a prremote
39
+ # bug, since under WSL the cause is almost always the missing attach.
40
+ def self.no_device_hint
41
+ return nil unless wsl?
42
+
43
+ 'Running under WSL: attach the board with usbipd-win first — ' \
44
+ '`usbipd list`, then `usbipd attach --wsl --busid <BUSID>`.'
45
+ end
46
+
47
+ def self.find_pico_port
48
+ new.find_pico_port
49
+ end
50
+
51
+ # A port whose USB vendor is Raspberry Pi — the only kind the 1200 bps
52
+ # BOOTSEL touch applies to. nil when none (or the vendor can't be read).
53
+ def find_pico_port
54
+ label = KNOWN_VENDORS.dig(R2P2_VENDOR_IDS.first, :label)
55
+ serial_ports.find { |p| port_label(p) == label }
56
+ end
57
+
20
58
  def find_device
21
59
  candidates = serial_ports
22
60
  return candidates.first if candidates.size == 1
@@ -66,13 +104,25 @@ module Prremote
66
104
  end
67
105
  nil
68
106
  when /linux/
69
- port_name = File.basename(port)
70
- vid_path = "/sys/class/tty/#{port_name}/device/../../../idVendor"
71
- return nil unless File.exist?(vid_path)
107
+ vid = linux_vendor_id(port)
108
+ vid && KNOWN_VENDORS.dig(vid, :label)
109
+ end
110
+ end
111
+
112
+ def linux_vendor_id(port)
113
+ dir = File.realpath("/sys/class/tty/#{File.basename(port)}/device")
114
+ SYSFS_WALK_LIMIT.times do
115
+ vid_path = File.join(dir, 'idVendor')
116
+ return File.read(vid_path).strip.downcase if File.file?(vid_path)
117
+
118
+ parent = File.dirname(dir)
119
+ break if parent == dir
72
120
 
73
- vid = File.read(vid_path).strip.downcase
74
- KNOWN_VENDORS.dig(vid, :label)
121
+ dir = parent
75
122
  end
123
+ nil
124
+ rescue SystemCallError
125
+ nil
76
126
  end
77
127
 
78
128
  # Vendor IDs of all connected USB devices as 4-digit hex strings.
@@ -4,7 +4,7 @@ require 'fileutils'
4
4
 
5
5
  module Prremote
6
6
  module RuntimeManager
7
- BOARDS = %w[pico picow esp32 esp32c6].freeze
7
+ BOARDS = %w[pico picow pico2 esp32 esp32c6].freeze
8
8
  ESP32_BOARDS = %w[esp32 esp32c6].freeze
9
9
 
10
10
  # Pico boards ship as UF2 (copied to the BOOTSEL drive); ESP32 family ships
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.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - ITO Yosei