prremote 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 38c2f68eda963d1330c36a056cd57d7f40aa20916aab7b254b190aea3f9cde11
4
+ data.tar.gz: 6ef03b383f689c40a43a5449300e648cc693a1c7ebc974661398e2f290272396
5
+ SHA512:
6
+ metadata.gz: 67e8c6d8ce84d1721f23671269460fa366ab3ceaf4316f535e185a8d4395b919d6cb746197934074a8942752ed389476a0e979d41705f22cb5053a0fbade6531
7
+ data.tar.gz: 88100fc592979327716cf747bebbd06c90087c8193c2bb7fc692fab36fb7a3f9d38d68b97c5b5533c3a682ca0ac4e55b39c311ff28c2ac0643aaea3fe1fd3a2f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ITO Yosei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # prremote
2
+
3
+ > ⚠️ This project is in early development. APIs and commands are subject to change.
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.
6
+
7
+ Inspired by [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) for MicroPython.
8
+
9
+ ---
10
+
11
+ ## Requirements
12
+
13
+ - Ruby 3.x or later
14
+ - Raspberry Pi Pico W
15
+ - `mrbc` in your PATH (for `run`, `deploy`, and `eval`) — install via `brew install mruby` on macOS
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ gem install prremote
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # 1. Flash the prremote runtime to your Pico W (one-time setup)
31
+ prremote install
32
+
33
+ # 2. Write your app
34
+ echo 'puts "Hello from Pico W!"' > app.rb
35
+
36
+ # 3. Run it
37
+ prremote run app.rb
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Commands
43
+
44
+ ### `install`
45
+
46
+ Flash the prremote runtime firmware to a Pico W.
47
+
48
+ ```bash
49
+ prremote install
50
+ prremote install --version 0.1.1 # specify a runtime version
51
+ ```
52
+
53
+ The firmware is downloaded from GitHub Releases on first use and cached in `~/.prremote/runtime/`. Subsequent installs use the cache.
54
+
55
+ Put the Pico W into BOOTSEL mode (hold BOOTSEL, connect USB, release) when prompted.
56
+
57
+ ---
58
+
59
+ ### `run FILE`
60
+
61
+ Compile a local `.rb` file to mruby bytecode and run it on the device immediately (one-shot).
62
+
63
+ ```bash
64
+ prremote run app.rb
65
+ prremote run blink.rb --port /dev/tty.usbmodem101
66
+ ```
67
+
68
+ The device responds with `RUNNING`, streams any output, then `DONE`.
69
+
70
+ ---
71
+
72
+ ### `deploy FILE`
73
+
74
+ Compile a local `.rb` file and save it to the device's flash. The script runs automatically on every boot.
75
+
76
+ ```bash
77
+ prremote deploy app.rb
78
+ ```
79
+
80
+ The device responds with `DEPLOYED` when the write is complete.
81
+
82
+ ---
83
+
84
+ ### `undeploy`
85
+
86
+ Erase the deployed script from flash. After this, the device boots into idle mode.
87
+
88
+ ```bash
89
+ prremote undeploy
90
+ ```
91
+
92
+ ---
93
+
94
+ ### `eval EXPR`
95
+
96
+ Evaluate a Ruby one-liner on the device.
97
+
98
+ ```bash
99
+ prremote eval "puts 1 + 1"
100
+ prremote eval "CYW43.init; CYW43::GPIO.new(CYW43::GPIO::LED_PIN).write 1"
101
+ ```
102
+
103
+ ---
104
+
105
+ ### `reset`
106
+
107
+ Send `Ctrl+C` to interrupt a running program.
108
+
109
+ ```bash
110
+ prremote reset
111
+ ```
112
+
113
+ ---
114
+
115
+ ### `watch FILE`
116
+
117
+ Watch a local file for changes and automatically re-run it on the device on every save.
118
+
119
+ ```bash
120
+ prremote watch app.rb
121
+ ```
122
+
123
+ Useful during development — save your file and the device immediately runs the updated code.
124
+
125
+ ---
126
+
127
+ ### `list`
128
+
129
+ List USB serial devices that may be prremote-compatible.
130
+
131
+ ```bash
132
+ prremote list
133
+ ```
134
+
135
+ ---
136
+
137
+ ### `version`
138
+
139
+ Show the gem version, mrbc version, and the connected device's runtime version.
140
+
141
+ ```bash
142
+ prremote version
143
+ # prremote: 0.1.0
144
+ # mrbc: 3.3.0 (/usr/local/bin/mrbc)
145
+ # runtime: 0.1.2
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Global Options
151
+
152
+ | Option | Description |
153
+ |---|---|
154
+ | `--port`, `-p PORT` | Serial port (default: auto-detect) |
155
+ | `--baud`, `-b N` | Baud rate (default: `115200`) |
156
+
157
+ ---
158
+
159
+ ## Typical Development Workflow
160
+
161
+ ```bash
162
+ # First-time setup
163
+ prremote install
164
+
165
+ # Manual cycle
166
+ prremote run app.rb # compile + run (one-shot)
167
+ prremote reset # interrupt a running program
168
+
169
+ # Automated cycle (recommended)
170
+ prremote watch app.rb # auto-run on every file save
171
+
172
+ # Persistent deployment (auto-runs on boot)
173
+ prremote deploy app.rb
174
+ prremote undeploy # remove from flash
175
+ ```
176
+
177
+ ---
178
+
179
+ ## How It Works
180
+
181
+ prremote flashes a minimal C firmware (built on mruby/c) onto the Pico W. The firmware:
182
+
183
+ 1. Waits for a USB serial connection and sends `READY prremote-runtime/VERSION`
184
+ 2. Receives a command from the host:
185
+ - Raw `.mrb` bytecode → execute immediately and stream output (`run` / `eval` / `watch`)
186
+ - `DPLY` + `.mrb` bytecode → save to flash and confirm with `DEPLOYED` (`deploy`)
187
+ 3. Waits for the next command
188
+
189
+ 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.
190
+
191
+ ---
192
+
193
+ ## License
194
+
195
+ [MIT License](LICENSE)
196
+
197
+ ---
198
+
199
+ ## Related Projects
200
+
201
+ - [mruby/c](https://github.com/mrubyc/mrubyc) — Lightweight mruby implementation used in the runtime
202
+ - [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) — MicroPython equivalent (inspiration)
data/bin/prremote ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
+ require 'prremote'
5
+
6
+ Prremote::CLI.start(ARGV)
@@ -0,0 +1,140 @@
1
+ require 'thor'
2
+ require 'rubyserial'
3
+ require_relative 'version'
4
+ require_relative 'detector'
5
+ require_relative 'mrbc'
6
+ require_relative 'runtime_manager'
7
+ require_relative 'commands/install'
8
+ require_relative 'commands/deploy'
9
+ require_relative 'commands/undeploy'
10
+ require_relative 'commands/run'
11
+ require_relative 'commands/eval_cmd'
12
+ require_relative 'commands/watch'
13
+
14
+ module Prremote
15
+ class CLI < Thor
16
+ class_option :port, aliases: '-p', desc: 'Serial port (default: auto-detect)'
17
+ class_option :baud, aliases: '-b', type: :numeric, default: 115_200, desc: 'Baud rate'
18
+
19
+ def self.exit_on_failure?
20
+ true
21
+ end
22
+
23
+ desc 'install', 'Flash prremote runtime firmware to Pico W'
24
+ option :version, type: :string, desc: "Firmware version to install (default: #{RUNTIME_VERSION})"
25
+ def install
26
+ version = options[:version] || RUNTIME_VERSION
27
+ Commands::Install.new(version: version).call
28
+ rescue RuntimeError => e
29
+ raise Thor::Error, e.message
30
+ end
31
+
32
+ desc 'run FILE', 'Compile and run a Ruby script on the device (one-shot)'
33
+ def run_script(file)
34
+ port = resolve_port
35
+ Commands::Run.new(port: port, baud: options[:baud]).call(file)
36
+ rescue RuntimeError => e
37
+ raise Thor::Error, e.message
38
+ end
39
+ map 'run' => :run_script
40
+
41
+ desc 'deploy FILE', 'Compile and save a Ruby script to flash (auto-runs on boot)'
42
+ def deploy(file)
43
+ port = resolve_port
44
+ Commands::Deploy.new(port: port, baud: options[:baud]).call(file)
45
+ rescue RuntimeError => e
46
+ raise Thor::Error, e.message
47
+ end
48
+
49
+ desc 'undeploy', 'Erase the deployed script from flash (disables auto-run on boot)'
50
+ def undeploy
51
+ port = resolve_port
52
+ Commands::Undeploy.new(port: port, baud: options[:baud]).call
53
+ rescue RuntimeError => e
54
+ raise Thor::Error, e.message
55
+ end
56
+
57
+ desc 'eval EXPR', 'Compile and run a one-liner Ruby expression on the device'
58
+ def eval(expr)
59
+ port = resolve_port
60
+ Commands::EvalCmd.new(port: port, baud: options[:baud]).call(expr)
61
+ rescue RuntimeError => e
62
+ raise Thor::Error, e.message
63
+ end
64
+
65
+ desc 'watch FILE', 'Watch a Ruby file for changes and re-run on the device automatically'
66
+ def watch(file)
67
+ port = resolve_port
68
+ Commands::Watch.new(port: port, baud: options[:baud]).call(file)
69
+ rescue RuntimeError => e
70
+ raise Thor::Error, e.message
71
+ end
72
+
73
+ desc 'list', 'Show available serial devices'
74
+ def list
75
+ devices = Detector.new.list_devices
76
+ if devices.empty?
77
+ puts 'No serial devices found.'
78
+ else
79
+ devices.each { |d| puts "#{d[:port]} (#{d[:label]})" }
80
+ end
81
+ end
82
+
83
+ desc 'reset', 'Send Ctrl+C to interrupt the running program'
84
+ def reset
85
+ port = resolve_port
86
+ serial = Serial.new(port, options[:baud])
87
+ serial.write("\x03")
88
+ sleep 0.1
89
+ puts 'Reset signal sent.'
90
+ rescue RuntimeError => e
91
+ raise Thor::Error, e.message
92
+ ensure
93
+ serial&.close
94
+ end
95
+
96
+ desc 'version', 'Show prremote, mrbc, and device firmware version'
97
+ def version
98
+ puts "prremote: #{VERSION}"
99
+
100
+ begin
101
+ puts "mrbc: #{Mrbc.version} (#{Mrbc.bin})"
102
+ rescue RuntimeError => e
103
+ puts "mrbc: (#{e.message})"
104
+ end
105
+
106
+ puts "runtime: #{fetch_runtime_version}"
107
+ end
108
+
109
+ private
110
+
111
+ def fetch_runtime_version
112
+ port = options[:port] || Detector.find_device
113
+ return '(no device connected)' unless port
114
+
115
+ serial = Serial.new(port, options[:baud])
116
+ serial.write("\x03")
117
+ buf = +''
118
+ deadline = Time.now + 5
119
+ loop do
120
+ buf << (serial.read(256) || '').gsub("\r\n", "\n").gsub("\r", '')
121
+ return ::Regexp.last_match(1) if buf =~ %r{READY prremote-runtime/([\d.]+)}
122
+ break if Time.now > deadline
123
+
124
+ sleep 0.05
125
+ end
126
+ '(not responding)'
127
+ ensure
128
+ serial&.close
129
+ end
130
+
131
+ def resolve_port
132
+ return options[:port] if options[:port]
133
+
134
+ port = Detector.find_device
135
+ raise Thor::Error, 'No device found. Use --port to specify one.' unless port
136
+
137
+ port
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,75 @@
1
+ require 'open3'
2
+ require 'tempfile'
3
+ require_relative '../mrbc'
4
+
5
+ module Prremote
6
+ module Commands
7
+ class Deploy
8
+ DEPLOY_MAGIC = 'DPLY'.freeze
9
+
10
+ def initialize(port:, baud:)
11
+ @port = port
12
+ @baud = baud
13
+ end
14
+
15
+ def call(rb_path)
16
+ raise "File not found: #{rb_path}" unless File.exist?(rb_path)
17
+
18
+ warn "Compiling #{rb_path}..."
19
+ mrb_data = compile(rb_path)
20
+
21
+ warn 'Deploying to flash...'
22
+ deploy_to_device(mrb_data)
23
+ warn 'Deployed. Script will run automatically on next boot.'
24
+ end
25
+
26
+ private
27
+
28
+ def compile(rb_path)
29
+ tmp = Tempfile.new(['prremote', '.mrb'])
30
+ out, status = Open3.capture2e(Mrbc.bin, '-o', tmp.path, rb_path)
31
+ raise "mrbc failed:\n#{out.chomp}" unless status.success?
32
+
33
+ File.binread(tmp.path)
34
+ ensure
35
+ tmp&.close!
36
+ end
37
+
38
+ def deploy_to_device(mrb_data)
39
+ serial = Serial.new(@port, @baud)
40
+ sleep 0.5
41
+ serial.read(4096)
42
+
43
+ serial.write(DEPLOY_MAGIC + mrb_data)
44
+ debug "sent DPLY + #{mrb_data.bytesize} bytes"
45
+
46
+ wait_for_deployed(serial)
47
+ ensure
48
+ serial&.close
49
+ end
50
+
51
+ def wait_for_deployed(serial)
52
+ buf = +''
53
+ deadline = Time.now + 30
54
+ loop do
55
+ chunk = normalize(serial.read(256) || '')
56
+ buf << chunk unless chunk.empty?
57
+
58
+ return if buf.include?("DEPLOYED\n")
59
+ raise "Device error: #{buf.strip}" if buf.match?(/^ERROR /)
60
+ raise 'Timeout waiting for deploy confirmation' if Time.now > deadline
61
+
62
+ sleep 0.05
63
+ end
64
+ end
65
+
66
+ def normalize(str)
67
+ str.gsub("\r\n", "\n").gsub("\r", '')
68
+ end
69
+
70
+ def debug(msg)
71
+ warn "[debug] #{msg}" if ENV['PRREMOTE_DEBUG']
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,22 @@
1
+ require 'tempfile'
2
+ require_relative 'run'
3
+
4
+ module Prremote
5
+ module Commands
6
+ class EvalCmd
7
+ def initialize(port:, baud:)
8
+ @port = port
9
+ @baud = baud
10
+ end
11
+
12
+ def call(expr)
13
+ tmp = Tempfile.new(['prremote_eval', '.rb'])
14
+ tmp.write(expr)
15
+ tmp.flush
16
+ Run.new(port: @port, baud: @baud).call(tmp.path)
17
+ ensure
18
+ tmp&.close!
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ require 'fileutils'
2
+
3
+ module Prremote
4
+ module Commands
5
+ class Install
6
+ def initialize(version: RUNTIME_VERSION)
7
+ @version = version
8
+ end
9
+
10
+ def call
11
+ uf2_path = RuntimeManager.fetch(@version)
12
+
13
+ puts 'Put the Pico W into BOOTSEL mode:'
14
+ puts ' 1. Hold the BOOTSEL button'
15
+ puts ' 2. Connect USB (or press RUN while holding BOOTSEL)'
16
+ puts ' 3. Release BOOTSEL — RPI-RP2 should appear as a USB drive'
17
+ puts
18
+ puts 'Waiting for RPI-RP2...'
19
+
20
+ volume = wait_for_volume
21
+ puts "Copying firmware to #{volume}..."
22
+ FileUtils.cp(uf2_path, File.join(volume, File.basename(uf2_path)))
23
+
24
+ puts 'Waiting for device to reboot...'
25
+ wait_for_unmount(volume)
26
+
27
+ puts "Done. Runtime #{@version} installed."
28
+ end
29
+
30
+ private
31
+
32
+ def volume_paths
33
+ [
34
+ '/Volumes/RPI-RP2',
35
+ "/run/media/#{ENV.fetch('USER', nil)}/RPI-RP2",
36
+ "/media/#{ENV.fetch('USER', nil)}/RPI-RP2"
37
+ ]
38
+ end
39
+
40
+ def wait_for_volume(timeout: 60)
41
+ deadline = Time.now + timeout
42
+ loop do
43
+ path = volume_paths.find { |p| File.directory?(p) }
44
+ return path if path
45
+ raise "Timed out waiting for RPI-RP2 volume (#{timeout}s)" if Time.now > deadline
46
+
47
+ sleep 1
48
+ end
49
+ end
50
+
51
+ def wait_for_unmount(volume, timeout: 30)
52
+ deadline = Time.now + timeout
53
+ loop do
54
+ return unless File.directory?(volume)
55
+ raise "Timed out waiting for device to reboot (#{timeout}s)" if Time.now > deadline
56
+
57
+ sleep 1
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,102 @@
1
+ require 'open3'
2
+ require 'tempfile'
3
+ require_relative '../mrbc'
4
+
5
+ module Prremote
6
+ module Commands
7
+ class Run
8
+ def initialize(port:, baud:)
9
+ @port = port
10
+ @baud = baud
11
+ end
12
+
13
+ def call(rb_path)
14
+ raise "File not found: #{rb_path}" unless File.exist?(rb_path)
15
+
16
+ warn "Compiling #{rb_path}..."
17
+ mrb_data = compile(rb_path)
18
+
19
+ warn 'Running...'
20
+ run_on_device(mrb_data)
21
+ end
22
+
23
+ private
24
+
25
+ def compile(rb_path)
26
+ tmp = Tempfile.new(['prremote', '.mrb'])
27
+ out, status = Open3.capture2e(Mrbc.bin, '-o', tmp.path, rb_path)
28
+ raise "mrbc failed:\n#{out.chomp}" unless status.success?
29
+
30
+ File.binread(tmp.path)
31
+ ensure
32
+ tmp&.close!
33
+ end
34
+
35
+ def run_on_device(mrb_data)
36
+ serial = Serial.new(@port, @baud)
37
+ sleep 0.5
38
+ drained = serial.read(4096) || ''
39
+ debug "drained #{drained.bytesize} bytes: #{drained.inspect}"
40
+
41
+ serial.write(mrb_data)
42
+ debug "sent #{mrb_data.bytesize} bytes (first 4: #{mrb_data[0, 4].inspect})"
43
+
44
+ post_running = wait_for_running(serial)
45
+ stream_until_done(serial, post_running)
46
+ ensure
47
+ serial&.close
48
+ end
49
+
50
+ def wait_for_running(serial)
51
+ buf = +''
52
+ deadline = Time.now + 10
53
+ loop do
54
+ chunk = normalize(serial.read(256) || '')
55
+ unless chunk.empty?
56
+ debug "recv: #{chunk.inspect}"
57
+ buf << chunk
58
+ end
59
+ raise "Device error: #{buf.strip}" if buf.match?(/^ERROR /)
60
+
61
+ if (idx = buf.index("RUNNING\n"))
62
+ return buf[(idx + "RUNNING\n".length)..]
63
+ end
64
+
65
+ raise 'Timeout waiting for device to start execution' if Time.now > deadline
66
+
67
+ sleep 0.05
68
+ end
69
+ end
70
+
71
+ def stream_until_done(serial, initial = +'')
72
+ buf = initial
73
+ loop do
74
+ buf << normalize(serial.read(256) || '')
75
+
76
+ if (done_pos = buf.index("DONE\n"))
77
+ $stdout.print buf[0, done_pos] unless done_pos.zero?
78
+ $stdout.flush
79
+ return
80
+ end
81
+
82
+ if buf.length > 512
83
+ safe = buf.length - 5
84
+ $stdout.print buf[0, safe]
85
+ $stdout.flush
86
+ buf = buf[safe..]
87
+ end
88
+
89
+ sleep 0.01
90
+ end
91
+ end
92
+
93
+ def normalize(str)
94
+ str.gsub("\r\n", "\n").gsub("\r", '')
95
+ end
96
+
97
+ def debug(msg)
98
+ warn "[debug] #{msg}" if ENV['PRREMOTE_DEBUG']
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,41 @@
1
+ module Prremote
2
+ module Commands
3
+ class Undeploy
4
+ ERASE_MAGIC = 'ERSE'.freeze
5
+
6
+ def initialize(port:, baud:)
7
+ @port = port
8
+ @baud = baud
9
+ end
10
+
11
+ def call
12
+ serial = Serial.new(@port, @baud)
13
+ sleep 0.5
14
+ serial.read(4096)
15
+
16
+ serial.write(ERASE_MAGIC)
17
+ wait_for_erased(serial)
18
+ warn 'Flash erased. Device will no longer auto-run a script on boot.'
19
+ ensure
20
+ serial&.close
21
+ end
22
+
23
+ private
24
+
25
+ def wait_for_erased(serial)
26
+ buf = +''
27
+ deadline = Time.now + 30
28
+ loop do
29
+ chunk = (serial.read(256) || '').gsub("\r\n", "\n").gsub("\r", '')
30
+ buf << chunk unless chunk.empty?
31
+
32
+ return if buf.include?("ERASED\n")
33
+ raise "Device error: #{buf.strip}" if buf.match?(/^ERROR /)
34
+ raise 'Timeout waiting for erase confirmation' if Time.now > deadline
35
+
36
+ sleep 0.05
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ require_relative 'run'
2
+
3
+ module Prremote
4
+ module Commands
5
+ class Watch
6
+ POLL_INTERVAL = 0.5
7
+
8
+ def initialize(port:, baud:)
9
+ @port = port
10
+ @baud = baud
11
+ end
12
+
13
+ def call(rb_path)
14
+ raise "File not found: #{rb_path}" unless File.exist?(rb_path)
15
+
16
+ warn "Watching #{rb_path} (Ctrl+C to stop)..."
17
+ last_mtime = File.mtime(rb_path)
18
+ run(rb_path)
19
+
20
+ loop do
21
+ sleep POLL_INTERVAL
22
+ mtime = File.mtime(rb_path)
23
+ next if mtime == last_mtime
24
+
25
+ last_mtime = mtime
26
+ warn "\n--- #{rb_path} changed, re-running ---"
27
+ run(rb_path)
28
+ end
29
+ rescue Interrupt
30
+ warn "\nStopped watching."
31
+ end
32
+
33
+ private
34
+
35
+ def run(rb_path)
36
+ Run.new(port: @port, baud: @baud).call(rb_path)
37
+ rescue RuntimeError => e
38
+ warn "Error: #{e.message}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ require 'rbconfig'
2
+
3
+ module Prremote
4
+ class Detector
5
+ R2P2_VENDOR_IDS = %w[2e8a].freeze # Raspberry Pi USB VID
6
+
7
+ def self.find_device
8
+ new.find_device
9
+ end
10
+
11
+ def find_device
12
+ candidates = serial_ports
13
+ return candidates.first if candidates.size == 1
14
+
15
+ r2p2 = candidates.select { |p| r2p2_port?(p) }
16
+ r2p2.first || candidates.first
17
+ end
18
+
19
+ def list_devices
20
+ serial_ports.map do |port|
21
+ label = r2p2_port?(port) ? 'R2P2/PicoRuby' : 'unknown'
22
+ { port: port, label: label }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def serial_ports
29
+ case RbConfig::CONFIG['host_os']
30
+ when /darwin/
31
+ # Use cu.* (call-out) devices — tty.* blocks on carrier and causes EBUSY
32
+ Dir.glob('/dev/cu.usbmodem*') + Dir.glob('/dev/cu.usbserial*')
33
+ when /linux/
34
+ Dir.glob('/dev/ttyACM*') + Dir.glob('/dev/ttyUSB*')
35
+ when /mswin|mingw|cygwin/
36
+ # Enumerate COM ports via registry on Windows
37
+ require 'win32/registry'
38
+ ports = []
39
+ Win32::Registry::HKEY_LOCAL_MACHINE.open('HARDWARE\DEVICEMAP\SERIALCOMM') do |reg|
40
+ reg.each_value { |_name, _type, data| ports << data }
41
+ end
42
+ ports
43
+ else
44
+ []
45
+ end
46
+ end
47
+
48
+ def r2p2_port?(port)
49
+ # On macOS/Linux, check sysfs or ioreg for the Raspberry Pi VID
50
+ case RbConfig::CONFIG['host_os']
51
+ 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/)
55
+ when /linux/
56
+ port_name = File.basename(port)
57
+ vid_path = "/sys/class/tty/#{port_name}/device/../../../idVendor"
58
+ return false unless File.exist?(vid_path)
59
+
60
+ vid = File.read(vid_path).strip.downcase
61
+ R2P2_VENDOR_IDS.include?(vid)
62
+ else
63
+ false
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,25 @@
1
+ require 'open3'
2
+
3
+ module Prremote
4
+ module Mrbc
5
+ SHIMS_RE = %r{\.rbenv/shims}
6
+
7
+ def self.bin
8
+ return ENV['MRBC'] if ENV['MRBC'] && File.executable?(ENV['MRBC'])
9
+
10
+ found = ENV['PATH'].split(File::PATH_SEPARATOR)
11
+ .grep_v(SHIMS_RE)
12
+ .map { |d| File.join(d, 'mrbc') }
13
+ .find { |f| File.executable?(f) }
14
+
15
+ found || raise('mrbc not found. Install mruby: brew install mruby')
16
+ end
17
+
18
+ def self.version
19
+ out, = Open3.capture2e(bin, '--version')
20
+ out.strip
21
+ rescue RuntimeError
22
+ '(mrbc not found)'
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'fileutils'
4
+
5
+ module Prremote
6
+ module RuntimeManager
7
+ REPO = 'lumbermill/prremote'.freeze
8
+ BOARD = 'picow'.freeze
9
+
10
+ def self.uf2_filename(version)
11
+ "prremote-#{BOARD}-runtime-#{version}.uf2"
12
+ end
13
+
14
+ def self.release_url(version)
15
+ "https://github.com/#{REPO}/releases/download/runtime-#{version}/#{uf2_filename(version)}"
16
+ end
17
+
18
+ def self.cache_dir
19
+ File.join(Dir.home, '.prremote', 'runtime')
20
+ end
21
+
22
+ def self.cached_path(version)
23
+ File.join(cache_dir, uf2_filename(version))
24
+ end
25
+
26
+ def self.fetch(version)
27
+ path = cached_path(version)
28
+ return path if File.exist?(path)
29
+
30
+ FileUtils.mkdir_p(cache_dir)
31
+ $stderr.print "Downloading #{uf2_filename(version)}..."
32
+ $stderr.flush
33
+ download(release_url(version), path)
34
+ warn ' done.'
35
+ path
36
+ end
37
+
38
+ def self.download(url, dest, redirects = 5)
39
+ raise 'Too many redirects' if redirects.zero?
40
+
41
+ uri = URI.parse(url)
42
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
43
+ http.request(Net::HTTP::Get.new(uri))
44
+ end
45
+
46
+ case response
47
+ when Net::HTTPRedirection
48
+ download(response['Location'], dest, redirects - 1)
49
+ when Net::HTTPSuccess
50
+ File.binwrite(dest, response.body)
51
+ else
52
+ FileUtils.rm_f(dest)
53
+ raise "Download failed: #{response.code} #{response.message}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,4 @@
1
+ module Prremote
2
+ VERSION = '0.1.0'.freeze
3
+ RUNTIME_VERSION = '0.1.2'.freeze
4
+ end
data/lib/prremote.rb ADDED
@@ -0,0 +1,2 @@
1
+ require_relative 'prremote/version'
2
+ require_relative 'prremote/cli'
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: prremote
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ITO Yosei
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubyserial
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.25'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.25'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.70'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.70'
96
+ description: Compile and run mruby/c scripts on a Raspberry Pi Pico W over USB serial.
97
+ email:
98
+ - y-itou@lumber-mill.co.jp
99
+ executables:
100
+ - prremote
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - LICENSE
105
+ - README.md
106
+ - bin/prremote
107
+ - lib/prremote.rb
108
+ - lib/prremote/cli.rb
109
+ - lib/prremote/commands/deploy.rb
110
+ - lib/prremote/commands/eval_cmd.rb
111
+ - lib/prremote/commands/install.rb
112
+ - lib/prremote/commands/run.rb
113
+ - lib/prremote/commands/undeploy.rb
114
+ - lib/prremote/commands/watch.rb
115
+ - lib/prremote/detector.rb
116
+ - lib/prremote/mrbc.rb
117
+ - lib/prremote/runtime_manager.rb
118
+ - lib/prremote/version.rb
119
+ homepage: https://github.com/lumbermill/prremote
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ rubygems_mfa_required: 'true'
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '3.4'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 4.0.3
139
+ specification_version: 4
140
+ summary: CLI tool for deploying and running mruby/c scripts on a Raspberry Pi Pico
141
+ W over USB serial
142
+ test_files: []