prremote 0.4.0 → 0.4.2

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: a30a56541a311e708b79d18eb2f3a07b1007df6fe5c971d307ecbf069b64a78a
4
- data.tar.gz: 75bd5a9439f0eacb9237e375010e7d65423c6f8c5556d82b352d946ba3702f88
3
+ metadata.gz: e8eeb5a287e2d90f2c3fdab1324a778be063eee3bb8eeb5577803113478f7afe
4
+ data.tar.gz: f58b759474589038e3c4f5b4fc5c0504543fa2c03f4fde9957a30fe2d6f198f1
5
5
  SHA512:
6
- metadata.gz: bb40ffda0d41b8fa8863eb330597e9f46d4dd0c963763f66a8e543e11fce491b5f969ce84a8986cc6564908f07a9d37951c5e9ed8d9a06591032e4eb7248b0bd
7
- data.tar.gz: b740c328761c6c31f0a2bd400b0baf8a537bd4d5e04637223588b9e56e695f870d2d8557790d1a92794a71f3c6be1903f31cb84a0b13ba7e5829edbcc6893dc9
6
+ metadata.gz: bec989326521193f9abcdd90794d79d2acefe8f9d41721507188cd267dc9fbb085c75bc953e7c14960f6ccdb6106da8f5cc276f4a8a7b861038092beeb74af57
7
+ data.tar.gz: f5087a5ab44764509e10b7c70b4b1214223cebdf3ff8f72adee7740297a8deeb7b37c8729a27511484630521ba8da20c55102174e8333728ffd36c39fbd37b12
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,7 +66,9 @@ 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)
67
- prremote install -b esp32 # ESP32 (M5GO / M5Stack Core, M5StickC PLUS, etc.)
69
+ prremote install -b pico2 # Pico 2 (RP2350)
70
+ prremote install -b esp32 # ESP32 (M5GO / M5Stack Core, etc.)
71
+ prremote install -b m5stickc_plus # M5StickC PLUS (esp32 runtime + board library)
68
72
  prremote install -b esp32c6 # ESP32-C6 (e.g. XIAO ESP32C6)
69
73
  prremote install -b picow --version 0.1.1 # specify a runtime version
70
74
  ```
@@ -73,7 +77,7 @@ prremote install -b picow --version 0.1.1 # specify a runtime version
73
77
 
74
78
  The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
75
79
 
76
- Pico boards: put the device into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
80
+ Pico boards: put the device 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.
77
81
 
78
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).
79
83
 
@@ -81,6 +85,8 @@ ESP32-C6: flashing is delegated to [esptool](https://docs.espressif.com/projects
81
85
 
82
86
  Reflashing the runtime does not erase a deployed script on either chip.
83
87
 
88
+ Board names such as `m5stickc_plus` install the runtime image of their chip (esp32) and record the board name on the device, which the runtime then reports (`prremote version` shows `board=m5stickc_plus`). That name decides which [board libraries](#board-libraries) scripts can use; installing with the plain chip name (`-b esp32`) clears it.
89
+
84
90
  ---
85
91
 
86
92
  ### `run FILE [FILE ...]`
@@ -177,13 +183,48 @@ Show the gem version, mrbc version, and the connected device's runtime version.
177
183
 
178
184
  ```bash
179
185
  prremote version
180
- # prremote: 0.4.0
181
- # runtime: 0.4.0 (/dev/tty.usbmodem101)
186
+ # prremote: 0.4.2
187
+ # runtime: 0.4.2 board=picow (/dev/tty.usbmodem101)
182
188
  # mrbc: mruby 4.0.0 (2026-04-20) (/opt/homebrew/bin/mrbc)
183
189
  ```
184
190
 
185
191
  ---
186
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
+
187
228
  ## Global Options
188
229
 
189
230
  | Option | Description |
@@ -197,7 +238,7 @@ prremote version
197
238
 
198
239
  ```bash
199
240
  # First-time setup
200
- prremote install -b picow # or: pico / esp32 / esp32c6
241
+ prremote install -b picow # or: pico / pico2 / esp32 / esp32c6
201
242
 
202
243
  # Manual cycle
203
244
  prremote run app.rb # compile + run (one-shot)
@@ -213,6 +254,25 @@ prremote undeploy # remove from flash
213
254
 
214
255
  ---
215
256
 
257
+ ## Board libraries
258
+
259
+ Some boards come with a Ruby library for their built-in hardware. You don't load it yourself: when a script mentions one of its classes, `run` / `deploy` / `watch` / `eval` compile the library in ahead of your files (it prints `Adding board library: ...`). Scripts that don't mention it pay nothing on the device.
260
+
261
+ | Board (`install -b`) | Classes | Covers |
262
+ |---|---|---|
263
+ | `m5stickc_plus` | `M5StickCPlus`, `BM8563` | AXP192 power-on, LCD preset per rotation, buttons A/B, red LED, buzzer, IR LED, BM8563 RTC (as `Time`) |
264
+
265
+ ```ruby
266
+ stick = M5StickCPlus.new # powers the LCD
267
+ lcd = stick.lcd(rotation: 1) # pins / size / RAM offset / color order preset
268
+ lcd.text(4, 4, stick.rtc.time.to_s, scale: 1)
269
+ stick.led.on if stick.button_a.pressed?
270
+ ```
271
+
272
+ A board library is refused if the device reports a different board (e.g. `M5StickCPlus` on an esp32c6). Chip drivers such as `BM8563` work on any board. The library sources live in [lib/prremote/boards/](lib/prremote/boards/) and double as the reference for driving the hardware directly.
273
+
274
+ ---
275
+
216
276
  ## How It Works
217
277
 
218
278
  prremote flashes a minimal C firmware (built on mruby/c) onto the board. The firmware:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.4.2
@@ -0,0 +1,55 @@
1
+ # BM8563 / PCF8563 real-time clock (I2C address 0x51).
2
+ # Board library for prremote — compiled into the script by the host when it
3
+ # references BM8563 (see lib/prremote/boards.rb); this file runs on the device.
4
+ #
5
+ # rtc = BM8563.new(i2c)
6
+ # rtc.valid? # false until the time has been set (VL flag)
7
+ # puts rtc.time.to_s # => "2026-09-28T08:30:42+9" (a Time)
8
+ # rtc.time = Time.new(offset: 9).sync("ntp.nict.jp")
9
+ #
10
+ # The chip holds local time; `time(offset:)` says which zone that is (JST by
11
+ # default) and `time=` writes the given Time in its own offset.
12
+ class BM8563
13
+ ADDR = 0x51
14
+
15
+ def initialize(i2c)
16
+ @i2c = i2c
17
+ end
18
+
19
+ # false while the VL (voltage-low) flag says the clock lost integrity —
20
+ # never set, or the backup battery ran out.
21
+ def valid?
22
+ r = @i2c.read(ADDR, 1, 0x02)
23
+ !r.nil? && r.getbyte(0) < 0x80
24
+ end
25
+
26
+ def time(offset: 9)
27
+ r = @i2c.read(ADDR, 7, 0x02).bytes
28
+ t = Time.new(offset: offset)
29
+ year = _dec(r[6]) + (r[5] < 0x80 ? 2000 : 1900) # month bit7 = century
30
+ days = t._days_since_epoch(year, _dec(r[5] & 0x1F), _dec(r[3] & 0x3F))
31
+ t._set_from_epoch((days * 86_400) + _seconds_of_day(r) - (offset * 3600))
32
+ t
33
+ end
34
+
35
+ # Also clears the VL flag and restarts the clock if it was stopped.
36
+ def time=(t)
37
+ wday = (((t.epoch + (t.utc_offset * 3600)) / 86_400) + 4) % 7 # 1970-01-01 = Thu
38
+ @i2c.write(ADDR, 0x00, 0x00, 0x00) # control 1/2: running, no alarm/timer IRQ
39
+ @i2c.write(ADDR, 0x02, _bcd(t.sec), _bcd(t.min), _bcd(t.hour), _bcd(t.day),
40
+ wday, _bcd(t.month), _bcd(t.year % 100))
41
+ end
42
+
43
+ # r: registers 0x02.. (sec, min, hour, ...)
44
+ def _seconds_of_day(r)
45
+ (_dec(r[2] & 0x3F) * 3600) + (_dec(r[1] & 0x7F) * 60) + _dec(r[0] & 0x7F)
46
+ end
47
+
48
+ def _dec(b)
49
+ ((b >> 4) * 10) + (b & 0x0F)
50
+ end
51
+
52
+ def _bcd(n)
53
+ ((n / 10) << 4) | (n % 10)
54
+ end
55
+ end
@@ -0,0 +1,102 @@
1
+ # M5StickC PLUS board support: power-on, LCD preset and built-in devices.
2
+ # Board library for prremote — compiled into the script by the host when it
3
+ # references M5StickCPlus (see lib/prremote/boards.rb); this file runs on the
4
+ # device (esp32 runtime) together with bm8563.rb.
5
+ #
6
+ # stick = M5StickCPlus.new # powers the LCD via the AXP192 PMIC
7
+ # lcd = stick.lcd(rotation: 1) # landscape; 0-3 all preset
8
+ # stick.button_a.pressed? # front button; button_b = side button
9
+ # stick.led.on # red LED
10
+ # stick.tone(440, 0.2) # passive buzzer
11
+ # stick.rtc.time.to_s # BM8563 RTC (see bm8563.rb)
12
+ # stick.ir.send_nec(0x00, 0x45) # IR LED
13
+ #
14
+ # Built-in devices share one I2C bus (SDA=21/SCL=22): AXP192 0x34, BM8563
15
+ # 0x51, MPU6886 0x68 — use stick.i2c for anything else on it.
16
+ class M5StickCPlus
17
+ AXP192 = 0x34
18
+
19
+ # ST7789v2 135x240 inside a 240x320 RAM window. Per rotation: MADCTL (the
20
+ # generic table minus its BGR bit — this panel is RGB) and the RAM offset
21
+ # of the 135-px side, which is 52 px from one edge and 53 from the other,
22
+ # so it changes when a rotation mirrors that axis.
23
+ LCD_MADCTL = [0x00, 0x60, 0xC0, 0xA0]
24
+ LCD_OFFSET_X = [52, 53, 53, 52]
25
+
26
+ attr_reader :i2c
27
+
28
+ def initialize
29
+ @i2c = I2C.new(sda_pin: 21, scl_pin: 22)
30
+ @i2c.write(AXP192, 0x28, 0xCC) # LDO2 (backlight) / LDO3 (panel) = 3.0V
31
+ cur = @i2c.read(AXP192, 1, 0x12).getbyte(0)
32
+ @i2c.write(AXP192, 0x12, cur | 0x4D) # enable EXTEN (Grove 5V), LDO2, LDO3, DCDC1
33
+ end
34
+
35
+ # rotation: 0 = portrait (USB at the bottom), 1/3 = landscape, 2 = portrait
36
+ # upside down. Returns a plain LCD (see lcd_wrap.rb for drawing methods).
37
+ def lcd(rotation: 0)
38
+ r = rotation % 4
39
+ LCD.new(rotation: r, sck_pin: 13, mosi_pin: 15, miso_pin: -1, cs_pin: 5,
40
+ dc_pin: 23, rst_pin: 18, bl_pin: -1, invert: true,
41
+ madctl: LCD_MADCTL[r], width: 135, height: 240,
42
+ offset_x: LCD_OFFSET_X[r], offset_y: 40)
43
+ end
44
+
45
+ def rtc
46
+ @rtc ||= BM8563.new(@i2c)
47
+ end
48
+
49
+ # Front (big) button.
50
+ def button_a
51
+ @button_a ||= Button.new(37)
52
+ end
53
+
54
+ # Side button, opposite the power button.
55
+ def button_b
56
+ @button_b ||= Button.new(39)
57
+ end
58
+
59
+ def led
60
+ @led ||= LED.new(10)
61
+ end
62
+
63
+ # Plays `hz` on the passive buzzer (GPIO2) for `duration` seconds.
64
+ def tone(hz, duration)
65
+ @buzzer ||= PWM.new(2, frequency: hz, duty_u16: 0)
66
+ @buzzer.frequency = hz
67
+ @buzzer.duty_u16 = 32_768
68
+ sleep duration
69
+ @buzzer.duty_u16 = 0
70
+ end
71
+
72
+ def ir
73
+ @ir ||= IR.new(9)
74
+ end
75
+
76
+ # External pull-up on the board; pressed reads 0.
77
+ class Button
78
+ def initialize(pin)
79
+ @gpio = GPIO.new(pin, GPIO::IN)
80
+ end
81
+
82
+ def pressed?
83
+ @gpio.read == 0
84
+ end
85
+ end
86
+
87
+ # Active-low red LED.
88
+ class LED
89
+ def initialize(pin)
90
+ @gpio = GPIO.new(pin, GPIO::OUT)
91
+ off
92
+ end
93
+
94
+ def on
95
+ @gpio.write(0)
96
+ end
97
+
98
+ def off
99
+ @gpio.write(1)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,85 @@
1
+ module Prremote
2
+ # Board support libraries: Ruby files under lib/prremote/boards/ that run on
3
+ # the device (not on the host). They are compiled into the user's script
4
+ # only when the script references one of their constants, so scripts that
5
+ # don't use them pay nothing in device heap / symbol table.
6
+ #
7
+ # A board name is what `install -b NAME` records on the device and what the
8
+ # runtime reports in its READY banner (`READY prremote-runtime/X board=NAME`).
9
+ # Several board names can share one runtime image (FIRMWARE).
10
+ module Boards
11
+ LIB_DIR = File.expand_path('boards', __dir__)
12
+
13
+ # Board name => runtime image it runs. Boards not listed here are their
14
+ # own image (pico, picow, esp32, esp32c6, ...).
15
+ FIRMWARE = {
16
+ 'm5stickc_plus' => 'esp32'
17
+ }.freeze
18
+
19
+ # file: path under LIB_DIR
20
+ # constants: referencing any of these pulls the library in
21
+ # boards: board names it is written for (nil = any board)
22
+ # requires: other library files it needs, compiled before it
23
+ Library = Struct.new(:file, :constants, :boards, :requires, keyword_init: true)
24
+
25
+ LIBRARIES = [
26
+ Library.new(file: 'bm8563.rb', constants: %w[BM8563], boards: nil, requires: []),
27
+ Library.new(file: 'm5stickc_plus.rb', constants: %w[M5StickCPlus],
28
+ boards: %w[m5stickc_plus], requires: %w[bm8563.rb])
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ def firmware_for(board)
34
+ FIRMWARE.fetch(board, board)
35
+ end
36
+
37
+ # Board names `install` accepts on top of the plain runtime images.
38
+ def names
39
+ FIRMWARE.keys
40
+ end
41
+
42
+ # Libraries the sources reference directly.
43
+ def referenced(sources)
44
+ LIBRARIES.select { |lib| referenced?(lib, sources) }
45
+ end
46
+
47
+ # Library file paths to compile ahead of the user's sources, in dependency
48
+ # order. Depends only on the sources, so it can run before connecting.
49
+ def library_paths(sources)
50
+ files = []
51
+ referenced(sources).each { |lib| add_with_requires(lib.file, files) }
52
+ files.map { |f| File.join(LIB_DIR, f) }
53
+ end
54
+
55
+ # Raises if a referenced library is for another board than the device's
56
+ # (`board` is nil when the runtime is too old to report one).
57
+ def check!(sources, board)
58
+ referenced(sources).each { |lib| check_board!(lib, board) }
59
+ end
60
+
61
+ def referenced?(lib, sources)
62
+ lib.constants.any? { |c| sources.any? { |src| src.match?(/\b#{c}\b/) } }
63
+ end
64
+
65
+ # A board library also runs on a device that only reports its runtime
66
+ # image name (installed with the plain `-b esp32`, or a runtime older than
67
+ # board names), since the host cannot tell those boards apart.
68
+ def check_board!(lib, board)
69
+ return if lib.boards.nil? || board.nil?
70
+ return if lib.boards.include?(board)
71
+ return if lib.boards.any? { |b| firmware_for(b) == board }
72
+
73
+ raise "#{lib.constants.join('/')} is for #{lib.boards.join('/')}, " \
74
+ "but the device reports board=#{board}"
75
+ end
76
+
77
+ def add_with_requires(file, files)
78
+ return if files.include?(file)
79
+
80
+ lib = LIBRARIES.find { |l| l.file == file }
81
+ lib&.requires&.each { |dep| add_with_requires(dep, files) }
82
+ files << file
83
+ end
84
+ end
85
+ end
data/lib/prremote/cli.rb CHANGED
@@ -27,7 +27,7 @@ module Prremote
27
27
  desc 'install', 'Flash prremote runtime firmware to a supported board'
28
28
  option :version, type: :string, desc: "Firmware version to install (default: #{VERSION})"
29
29
  option :board, aliases: '-b', type: :string,
30
- desc: "Board type: #{RuntimeManager::BOARDS.join(', ')}"
30
+ desc: "Board type: #{Commands::Install.board_names.join(', ')}"
31
31
  option :verbose, aliases: '-V', type: :boolean, default: false,
32
32
  desc: 'Print step-by-step flash diagnostics'
33
33
  def install
@@ -36,14 +36,14 @@ module Prremote
36
36
 
37
37
  unless board
38
38
  puts "Specify a board with --board / -b. Supported boards:"
39
- RuntimeManager::BOARDS.each { |b| puts " #{b}" }
39
+ Commands::Install.board_names.each { |b| puts " #{b}" }
40
40
  puts
41
41
  puts "Example: prremote install -b esp32c6"
42
42
  return
43
43
  end
44
44
 
45
- unless RuntimeManager::BOARDS.include?(board)
46
- raise Thor::Error, "Unknown board '#{board}'. Supported boards: #{RuntimeManager::BOARDS.join(', ')}"
45
+ unless Commands::Install.board_names.include?(board)
46
+ raise Thor::Error, "Unknown board '#{board}'. Supported boards: #{Commands::Install.board_names.join(', ')}"
47
47
  end
48
48
 
49
49
  Commands::Install.new(version: version, board: board, port: options[:port],
@@ -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
@@ -188,7 +190,8 @@ module Prremote
188
190
  next
189
191
  end
190
192
 
191
- return "#{::Regexp.last_match(1)} (#{port})" if buf =~ %r{READY prremote-runtime/([\d.]+)}
193
+ ready = format_ready(buf, port)
194
+ return ready if ready
192
195
  return '(not responding)' if Time.now > deadline
193
196
 
194
197
  sleep 0.05
@@ -199,6 +202,16 @@ module Prremote
199
202
  serial&.close
200
203
  end
201
204
 
205
+ # "0.4.1 board=m5stickc_plus (/dev/...)" from a complete READY line, else
206
+ # nil. Runtimes older than board names omit the board= field.
207
+ def format_ready(buf, port)
208
+ m = buf.match(%r{READY prremote-runtime/([\d.]+)(?: board=(\S+))?\s*\n})
209
+ return nil unless m
210
+
211
+ board = m[2] ? " board=#{m[2]}" : ''
212
+ "#{m[1]}#{board} (#{port})"
213
+ end
214
+
202
215
  def resolve_port
203
216
  return options[:port] if options[:port]
204
217
 
@@ -1,6 +1,3 @@
1
- require 'open3'
2
- require 'tempfile'
3
- require_relative '../mrbc'
4
1
  require_relative 'serial_helpers'
5
2
 
6
3
  module Prremote
@@ -20,29 +17,19 @@ module Prremote
20
17
  rb_paths.each { |f| raise "File not found: #{f}" unless File.exist?(f) }
21
18
 
22
19
  warn "Compiling #{rb_paths.map { |f| File.basename(f) }.join(', ')}..."
23
- mrb_data = compile(*rb_paths)
20
+ mrb_data = compile_with_boards(rb_paths)
24
21
 
25
- warn 'Deploying to flash...'
26
22
  deploy_to_device(mrb_data, rb_paths)
27
23
  warn 'Deployed. Script will run automatically on next boot.'
28
24
  end
29
25
 
30
26
  private
31
27
 
32
- def compile(*rb_paths)
33
- Mrbc.check_version!
34
- tmp = Tempfile.new(['prremote', '.mrb'])
35
- out, status = Open3.capture2e(Mrbc.bin, '-o', tmp.path, *rb_paths)
36
- raise "mrbc failed:\n#{out.chomp}" unless status.success?
37
-
38
- File.binread(tmp.path)
39
- ensure
40
- tmp&.close!
41
- end
42
-
43
28
  def deploy_to_device(mrb_data, rb_paths)
44
29
  serial = Serial.new(@port, @baud)
45
- wait_for_ready(serial)
30
+ check_board!(wait_for_ready(serial), rb_paths)
31
+
32
+ warn 'Deploying to flash...'
46
33
  write_chunked(serial, DEPLOY_MAGIC + build_meta_packet(rb_paths) + mrb_data)
47
34
  wait_for_deployed(serial)
48
35
  ensure
@@ -1,27 +1,49 @@
1
1
  require 'fileutils'
2
+ require_relative '../boards'
3
+ require_relative 'serial_helpers'
2
4
 
3
5
  module Prremote
4
6
  module Commands
5
7
  class Install
8
+ include SerialHelpers
9
+
10
+ BOARD_MAGIC = 'BORD'.freeze
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
+ # Boards whose install path has not been run on real hardware yet.
18
+ UNVERIFIED_BOARDS = %w[pico2].freeze
19
+
20
+ # board may be a runtime image name (esp32) or a board sharing one
21
+ # (m5stickc_plus → esp32 image); see Boards::FIRMWARE.
6
22
  def initialize(version: VERSION, board: 'picow', port: nil, verbose: false)
7
- @version = version
8
- @board = board
9
- @port = port
10
- @verbose = verbose
23
+ @version = version
24
+ @board = board
25
+ @firmware = Boards.firmware_for(board)
26
+ @port = port
27
+ @verbose = verbose
28
+ end
29
+
30
+ # Runtime images plus boards that share one (m5stickc_plus).
31
+ def self.board_names
32
+ RuntimeManager::BOARDS + Boards.names
11
33
  end
12
34
 
13
35
  def call
14
- return install_esp32 if RuntimeManager::ESP32_BOARDS.include?(@board)
36
+ return install_esp32 if RuntimeManager::ESP32_BOARDS.include?(@firmware)
15
37
 
16
38
  uf2_path = RuntimeManager.fetch(@version, @board)
17
39
 
18
- device_label = @board == 'picow' ? 'Pico W' : 'Pico'
40
+ warn_unverified
19
41
  puts "Put the #{device_label} into BOOTSEL mode:"
20
42
  puts ' 1. Hold the BOOTSEL button'
21
43
  puts ' 2. Connect USB (or press RUN while holding BOOTSEL)'
22
- puts ' 3. Release BOOTSEL — RPI-RP2 should appear as a USB drive'
44
+ puts " 3. Release BOOTSEL — #{volume_name} should appear as a USB drive"
23
45
  puts
24
- puts 'Waiting for RPI-RP2...'
46
+ puts "Waiting for #{volume_name}..."
25
47
 
26
48
  volume = wait_for_volume
27
49
  puts "Copying firmware to #{volume}..."
@@ -38,21 +60,99 @@ module Prremote
38
60
  # No BOOTSEL dance on ESP32: the flasher toggles DTR/RTS to enter the
39
61
  # boot ROM by itself, so flashing works over the normal serial port.
40
62
  def install_esp32
41
- image = RuntimeManager.fetch(@version, @board)
63
+ image = RuntimeManager.fetch(@version, @firmware)
42
64
  port = @port || Detector.find_device
43
- raise 'No serial device found. Connect the board or pass --port.' unless port
65
+ unless port
66
+ message = 'No serial device found. Connect the board or pass --port.'
67
+ hint = Detector.no_device_hint
68
+ raise [message, hint].compact.join("\n")
69
+ end
44
70
 
45
71
  puts "Flashing #{File.basename(image)} to #{port}..."
46
- EspFlasher.flash(port: port, image_path: image, board: @board, verbose: @verbose)
72
+ EspFlasher.flash(port: port, image_path: image, board: @firmware, verbose: @verbose)
73
+ record_board(port)
47
74
  puts "Done. Runtime #{@version} installed."
48
75
  end
49
76
 
77
+ # One ESP32 image serves several boards, so tell the runtime which one
78
+ # this is (kept in NVS; reported as board= in READY, which picks the
79
+ # board libraries). Installing the plain image clears it.
80
+ def record_board(port)
81
+ name = @board == @firmware ? '' : @board
82
+ serial = Serial.new(port, 115_200)
83
+ wait_for_ready(serial)
84
+ serial.write(BOARD_MAGIC + [name.bytesize].pack('C') + name)
85
+ reply = wait_for_board_reply(serial)
86
+ if reply.nil?
87
+ warn "WARNING: runtime #{@version} does not record board names; board=#{@board} not set."
88
+ elsif !name.empty?
89
+ puts "Board: #{reply}"
90
+ end
91
+ ensure
92
+ serial&.close
93
+ end
94
+
95
+ # "BOARD <name>" on success; nil if the runtime predates the command.
96
+ def wait_for_board_reply(serial)
97
+ buf = +''
98
+ deadline = Time.now + 3
99
+ loop do
100
+ buf << normalize(safe_read(serial, 256))
101
+ return Regexp.last_match(1) if buf =~ /^BOARD (\S+)\n/
102
+ raise "Device rejected board name '#{@board}'" if buf.include?("ERROR board\n")
103
+ return nil if Time.now > deadline
104
+
105
+ sleep 0.05
106
+ end
107
+ end
108
+
109
+ def warn_unverified
110
+ return unless UNVERIFIED_BOARDS.include?(@board)
111
+
112
+ warn "NOTE: installing on #{device_label} has not been verified on real hardware yet."
113
+ warn ' Please report the result at https://github.com/lumbermill/prremote/issues'
114
+ end
115
+
116
+ def device_label
117
+ DEVICE_LABELS.fetch(@board, 'Pico')
118
+ end
119
+
120
+ # The RP2040 boot ROM mounts its BOOTSEL drive as RPI-RP2; the RP2350
121
+ # (Pico 2) boot ROM uses RP2350 instead. INFO_UF2.TXT carries the same
122
+ # name as its Board-ID.
123
+ def volume_name
124
+ @board == 'pico2' ? 'RP2350' : 'RPI-RP2'
125
+ end
126
+
50
127
  def volume_paths
128
+ user = ENV.fetch('USER', nil)
51
129
  [
52
- '/Volumes/RPI-RP2',
53
- "/run/media/#{ENV.fetch('USER', nil)}/RPI-RP2",
54
- "/media/#{ENV.fetch('USER', nil)}/RPI-RP2"
55
- ]
130
+ "/Volumes/#{volume_name}",
131
+ "/run/media/#{user}/#{volume_name}",
132
+ "/media/#{user}/#{volume_name}"
133
+ ] + drive_letter_volume_paths
134
+ end
135
+
136
+ # WSL reaches the Windows drive letter the BOOTSEL volume lands on through
137
+ # drvfs at /mnt/<letter>. That path carries no volume name, so the
138
+ # marker file is what identifies the board.
139
+ def drive_letter_volume_paths
140
+ drive_letter_roots.select { |root| bootsel_volume?(root) }
141
+ end
142
+
143
+ # /mnt/c is the Windows system drive; the board never lands there, so the
144
+ # scan starts at D.
145
+ def drive_letter_roots
146
+ ('d'..'z').map { |letter| "/mnt/#{letter}" }
147
+ end
148
+
149
+ # Other UF2 bootloaders (and the other RP2 chip) expose INFO_UF2.TXT
150
+ # too, so the Board-ID line is what tells the target chip apart.
151
+ def bootsel_volume?(root)
152
+ marker = File.join(root, BOOTSEL_MARKER)
153
+ File.file?(marker) && /^Board-ID:\s*#{Regexp.escape(volume_name)}\s*$/.match?(File.read(marker))
154
+ rescue SystemCallError
155
+ false
56
156
  end
57
157
 
58
158
  def wait_for_volume(timeout: 60)
@@ -60,21 +160,31 @@ module Prremote
60
160
  loop do
61
161
  path = volume_paths.find { |p| File.directory?(p) }
62
162
  return path if path
63
- raise "Timed out waiting for RPI-RP2 volume (#{timeout}s)" if Time.now > deadline
163
+ raise "Timed out waiting for #{volume_name} volume (#{timeout}s)" if Time.now > deadline
64
164
 
65
165
  sleep 1
66
166
  end
67
167
  end
68
168
 
169
+ # The board reboots as soon as the UF2 lands, which tears the volume down.
170
+ # Testing the marker rather than the directory covers the WSL case, where
171
+ # the /mnt/<letter> mountpoint outlives the drive it pointed at.
69
172
  def wait_for_unmount(volume, timeout: 30)
70
173
  deadline = Time.now + timeout
71
174
  loop do
72
- return unless File.directory?(volume)
175
+ return unless mounted?(volume)
73
176
  raise "Timed out waiting for device to reboot (#{timeout}s)" if Time.now > deadline
74
177
 
75
178
  sleep 1
76
179
  end
77
180
  end
181
+
182
+ def mounted?(volume)
183
+ File.directory?(volume) && File.file?(File.join(volume, BOOTSEL_MARKER))
184
+ rescue SystemCallError
185
+ # A stale drvfs mountpoint raises instead of reporting the drive as gone.
186
+ false
187
+ end
78
188
  end
79
189
  end
80
190
  end
@@ -1,6 +1,3 @@
1
- require 'open3'
2
- require 'tempfile'
3
- require_relative '../mrbc'
4
1
  require_relative 'serial_helpers'
5
2
 
6
3
  module Prremote
@@ -17,31 +14,20 @@ module Prremote
17
14
  rb_paths.each { |f| raise "File not found: #{f}" unless File.exist?(f) }
18
15
 
19
16
  warn "Compiling #{rb_paths.map { |f| File.basename(f) }.join(', ')}..."
20
- mrb_data = compile(*rb_paths)
17
+ mrb_data = compile_with_boards(rb_paths)
21
18
 
22
- warn 'Running...'
23
- run_on_device(mrb_data)
19
+ run_on_device(mrb_data, rb_paths)
24
20
  rescue Interrupt
25
21
  warn ''
26
22
  end
27
23
 
28
24
  private
29
25
 
30
- def compile(*rb_paths)
31
- Mrbc.check_version!
32
- tmp = Tempfile.new(['prremote', '.mrb'])
33
- out, status = Open3.capture2e(Mrbc.bin, '-o', tmp.path, *rb_paths)
34
- raise "mrbc failed:\n#{out.chomp}" unless status.success?
35
-
36
- File.binread(tmp.path)
37
- ensure
38
- tmp&.close!
39
- end
40
-
41
- def run_on_device(mrb_data)
26
+ def run_on_device(mrb_data, rb_paths)
42
27
  serial = Serial.new(@port, @baud)
43
- wait_for_ready(serial)
28
+ check_board!(wait_for_ready(serial), rb_paths)
44
29
 
30
+ warn 'Running...'
45
31
  write_chunked(serial, mrb_data)
46
32
  debug "sent #{mrb_data.bytesize} bytes (first 4: #{mrb_data[0, 4].inspect})"
47
33
 
@@ -1,3 +1,6 @@
1
+ require_relative '../boards'
2
+ require_relative '../mrbc'
3
+
1
4
  module Prremote
2
5
  module Commands
3
6
  module SerialHelpers
@@ -16,9 +19,10 @@ module Prremote
16
19
  next_ctrl = Time.now + 1
17
20
  loop do
18
21
  buf << normalize(safe_read(serial, 256))
19
- if buf.include?('READY ')
22
+ # Wait for the whole line: the board= field follows the version.
23
+ if buf.match?(/READY [^\n]*\n/)
20
24
  warn_if_runtime_outdated(buf)
21
- return
25
+ return parse_ready(buf)
22
26
  end
23
27
  raise 'Timeout waiting for device. Run `prremote reset` if a script is running.' if Time.now > deadline
24
28
 
@@ -30,6 +34,32 @@ module Prremote
30
34
  end
31
35
  end
32
36
 
37
+ # READY banner fields. board is nil on runtimes older than board names.
38
+ Ready = Struct.new(:version, :board)
39
+
40
+ def parse_ready(buf)
41
+ m = buf.match(%r{READY prremote-runtime/(\S+)(?: board=(\S+))?})
42
+ m ? Ready.new(m[1], m[2]) : Ready.new(nil, nil)
43
+ end
44
+
45
+ # Compiles the user's files, prepending the board libraries they
46
+ # reference (see Boards). Done before connecting: an open ESP32 port can
47
+ # reset the chip, so the gap between READY and sending stays minimal.
48
+ def compile_with_boards(rb_paths)
49
+ libs = Boards.library_paths(sources_of(rb_paths))
50
+ warn "Adding board library: #{libs.map { |f| File.basename(f) }.join(', ')}" unless libs.empty?
51
+ Mrbc.compile(*libs, *rb_paths)
52
+ end
53
+
54
+ # After READY: refuse a board library meant for another board.
55
+ def check_board!(ready, rb_paths)
56
+ Boards.check!(sources_of(rb_paths), ready.board)
57
+ end
58
+
59
+ def sources_of(rb_paths)
60
+ rb_paths.map { |f| File.read(f, encoding: 'UTF-8') }
61
+ end
62
+
33
63
  def normalize(str)
34
64
  str.gsub("\r\n", "\n").gsub("\r", '')
35
65
  end
@@ -13,10 +13,37 @@ 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
+
20
47
  def find_device
21
48
  candidates = serial_ports
22
49
  return candidates.first if candidates.size == 1
@@ -66,13 +93,25 @@ module Prremote
66
93
  end
67
94
  nil
68
95
  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)
96
+ vid = linux_vendor_id(port)
97
+ vid && KNOWN_VENDORS.dig(vid, :label)
98
+ end
99
+ end
100
+
101
+ def linux_vendor_id(port)
102
+ dir = File.realpath("/sys/class/tty/#{File.basename(port)}/device")
103
+ SYSFS_WALK_LIMIT.times do
104
+ vid_path = File.join(dir, 'idVendor')
105
+ return File.read(vid_path).strip.downcase if File.file?(vid_path)
106
+
107
+ parent = File.dirname(dir)
108
+ break if parent == dir
72
109
 
73
- vid = File.read(vid_path).strip.downcase
74
- KNOWN_VENDORS.dig(vid, :label)
110
+ dir = parent
75
111
  end
112
+ nil
113
+ rescue SystemCallError
114
+ nil
76
115
  end
77
116
 
78
117
  # Vendor IDs of all connected USB devices as 4-digit hex strings.
data/lib/prremote/mrbc.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'open3'
2
+ require 'tempfile'
2
3
 
3
4
  module Prremote
4
5
  # mrbc lookup order:
@@ -43,6 +44,18 @@ module Prremote
43
44
  @version_ok = true
44
45
  end
45
46
 
47
+ # Compiles the given .rb files (in order) into one .mrb; returns its bytes.
48
+ def self.compile(*rb_paths)
49
+ check_version!
50
+ tmp = Tempfile.new(['prremote', '.mrb'])
51
+ out, status = Open3.capture2e(bin, '-o', tmp.path, *rb_paths)
52
+ raise "mrbc failed:\n#{out.chomp}" unless status.success?
53
+
54
+ File.binread(tmp.path)
55
+ ensure
56
+ tmp&.close!
57
+ end
58
+
46
59
  private_class_method def self.path_candidates
47
60
  ENV['PATH'].split(File::PATH_SEPARATOR)
48
61
  .grep_v(SHIMS_RE)
@@ -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.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ITO Yosei
@@ -106,6 +106,9 @@ files:
106
106
  - VERSION
107
107
  - bin/prremote
108
108
  - lib/prremote.rb
109
+ - lib/prremote/boards.rb
110
+ - lib/prremote/boards/bm8563.rb
111
+ - lib/prremote/boards/m5stickc_plus.rb
109
112
  - lib/prremote/cli.rb
110
113
  - lib/prremote/commands/deploy.rb
111
114
  - lib/prremote/commands/eval_cmd.rb