rpremote 0.1.0 → 0.3.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.
@@ -9,12 +9,15 @@ module Rpremote
9
9
  raise ArgumentError, "flash does not accept arguments; use --firmware FILE" unless args.empty?
10
10
 
11
11
  target = Target.new(**options.slice(:language, :language_version, :board, :cache_dir, :firmware))
12
+ firmware_path = target.firmware_path(root: Dir.pwd)
13
+ mount = options[:mount] || "an automatically detected RP2350 BOOTSEL volume"
14
+ output.puts("flashing #{firmware_path} to #{mount}; this replaces persistent board firmware")
12
15
  result = flasher.new(timeout: options[:timeout]).flash(
13
- target.firmware_path(root: Dir.pwd),
16
+ firmware_path,
14
17
  mount: options[:mount],
15
18
  port: options[:port]
16
19
  )
17
- output.puts("flashed firmware #{File.basename(target.firmware_path)}: #{result.port}")
20
+ output.puts("flashed firmware #{File.basename(firmware_path)}: #{result.port}")
18
21
  end
19
22
 
20
23
  def self.parse_options(args, defaults)
@@ -30,7 +33,10 @@ module Rpremote
30
33
  }
31
34
  OptionParser.new do |parser|
32
35
  parser.on("--cache DIR") { |value| options[:cache_dir] = value }
33
- parser.on("--firmware FILE") { |value| options[:firmware] = value }
36
+ parser.on("--firmware FILE") do |value|
37
+ options[:firmware] = value
38
+ options[:firmware_explicit] = true
39
+ end
34
40
  parser.on("--language LANGUAGE") { |value| options[:language] = value }
35
41
  parser.on("--language-version VERSION") { |value| options[:language_version] = value }
36
42
  parser.on("--board BOARD") { |value| options[:board] = value }
@@ -5,7 +5,7 @@ require "fileutils"
5
5
  module Rpremote
6
6
  class Flasher
7
7
  DEFAULT_VOLUMES_ROOT = "/Volumes"
8
- DEFAULT_TIMEOUT = 10.0
8
+ DEFAULT_TIMEOUT = 20.0
9
9
  INFO_FILE = "INFO_UF2.TXT"
10
10
  UF2_BLOCK_SIZE = 512
11
11
  UF2_MAGIC_START0 = 0x0A324655
@@ -36,30 +36,57 @@ module Rpremote
36
36
  raise ArgumentError, "timeout must be positive" unless @timeout.positive?
37
37
  end
38
38
 
39
- def flash(uf2_path, mount: nil, port: nil)
39
+ def flash(uf2_path, mount: nil, port: nil, wait_for_port: true)
40
40
  validate_firmware!(uf2_path)
41
41
  target = find_mount(mount)
42
42
  destination = File.join(target, File.basename(uf2_path))
43
43
  copy_firmware(uf2_path, destination)
44
44
  wait_until("BOOTSEL drive to disappear") { !mount_probe.call(target) }
45
+ return Result.new(mount: target, destination: destination, port: nil) unless wait_for_port
46
+
45
47
  detected_port = wait_until("R2P2 serial port to appear") { detect_port(port) }
46
48
  Result.new(mount: target, destination: destination, port: detected_port)
47
49
  end
48
50
 
49
51
  def find_mount(explicit_mount = nil)
50
- return validate_mount!(File.expand_path(explicit_mount)) if explicit_mount
52
+ mounted = find_mounted(explicit_mount)
53
+ return mounted if mounted
51
54
 
52
- matches = Dir.glob(File.join(volumes_root, "*")).select do |path|
53
- Dir.exist?(path) && valid_target?(path)
55
+ raise MountNotFoundError,
56
+ "Pico 2 BOOTSEL drive not found; reconnect it while holding BOOTSEL or use --mount DIR"
57
+ end
58
+
59
+ def find_mounted(explicit_mount = nil)
60
+ if explicit_mount
61
+ path = File.expand_path(explicit_mount)
62
+ return unless Dir.exist?(path)
63
+
64
+ return validate_mount!(path)
65
+ end
66
+
67
+ matches = bootsel_mounts
68
+ return if matches.empty?
69
+ return matches.first if matches.one?
70
+
71
+ raise MountNotFoundError, "multiple Pico 2 BOOTSEL drives found; use --mount DIR"
72
+ end
73
+
74
+ def wait_for_mount(mount: nil)
75
+ if mount
76
+ path = File.expand_path(mount)
77
+ return wait_until("Pico 2 BOOTSEL drive to appear") do
78
+ next unless Dir.exist?(path)
79
+ next unless valid_target?(path)
80
+
81
+ path
82
+ end
54
83
  end
55
- case matches.length
56
- when 0
57
- raise MountNotFoundError,
58
- "Pico 2 BOOTSEL drive not found; reconnect it while holding BOOTSEL or use --mount DIR"
59
- when 1
84
+
85
+ wait_until("Pico 2 BOOTSEL drive to appear") do
86
+ matches = bootsel_mounts
87
+ raise MountNotFoundError, "multiple Pico 2 BOOTSEL drives found; use --mount DIR" if matches.length > 1
88
+
60
89
  matches.first
61
- else
62
- raise MountNotFoundError, "multiple Pico 2 BOOTSEL drives found; use --mount DIR"
63
90
  end
64
91
  end
65
92
 
@@ -90,8 +117,19 @@ module Rpremote
90
117
  false
91
118
  end
92
119
 
120
+ def bootsel_mounts
121
+ Dir.glob(File.join(volumes_root, "*")).select do |path|
122
+ Dir.exist?(path) && valid_target?(path)
123
+ end
124
+ end
125
+
93
126
  def copy_firmware(source, destination)
94
127
  FileUtils.copy_file(source, destination)
128
+ rescue Errno::ENXIO
129
+ # RP2350 may reboot and detach its BOOTSEL volume before macOS finishes
130
+ # closing the destination. The disappearance and serial reconnect checks
131
+ # in #flash determine whether the transfer actually completed.
132
+ nil
95
133
  rescue SystemCallError => e
96
134
  raise Error, "failed to copy UF2 firmware: #{e.message}"
97
135
  end
data/lib/rpremote/help.rb CHANGED
@@ -1,35 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/ModuleLength, Metrics/CyclomaticComplexity
4
+
3
5
  module Rpremote
4
6
  module Help
5
- TEXT = <<~HELP
7
+ COMMAND_USAGE = {
8
+ setup: "rpremote setup [--language LANGUAGE] [--language-version VERSION] [--force] [--cache DIR]",
9
+ build: "rpremote build [--language LANGUAGE] [--language-version VERSION] [--board BOARD] " \
10
+ "[--firmware FILE] [--cache DIR] [--mrbgems FILE|--no-mrbgems]",
11
+ build_clean: "rpremote build clean",
12
+ bootsel: "rpremote bootsel [--reset-flash-memory] [--mount DIR] [--port PORT] [--baud RATE] [--timeout SEC]",
13
+ deploy: "rpremote deploy PATH [--language LANGUAGE] [--language-version VERSION] [--board BOARD] " \
14
+ "[--firmware FILE] [--cache DIR] [--mrbgems FILE|--no-mrbgems] [--mount DIR] " \
15
+ "[--port PORT] [--baud RATE] [--timeout SEC]",
16
+ dfu_app: "rpremote dfu app FILE [--type ruby|rite] [--port PORT] [--baud RATE] [--timeout SEC]",
17
+ dfu_compile: "rpremote dfu compile FILE [--output FILE] [--language LANGUAGE] [--language-version VERSION] [--cache DIR]",
18
+ dfu_status: "rpremote dfu status [--port PORT] [--baud RATE] [--timeout SEC]",
19
+ dfu_remove: "rpremote dfu remove [--port PORT] [--baud RATE] [--timeout SEC]",
20
+ mrbgems: "rpremote mrbgems SUBCOMMAND [--file FILE] [--lockfile FILE]",
21
+ flash: "rpremote flash [--firmware FILE] [--language LANGUAGE] [--language-version VERSION] " \
22
+ "[--board BOARD] [--cache DIR] [--mount DIR] [--port PORT] [--timeout SEC]",
23
+ config_show: "rpremote config show [--language LANGUAGE] [--language-version VERSION] [--board BOARD] " \
24
+ "[--cache DIR] [--firmware FILE] [--mrbgems FILE|--no-mrbgems] [--mount DIR] " \
25
+ "[--port PORT] [--baud RATE] [--timeout SEC]",
26
+ ports: "rpremote ports",
27
+ run: "rpremote run FILE [--port PORT] [--baud RATE] [--timeout SEC] [--reset-on-timeout] [--language LANGUAGE]",
28
+ monitor: "rpremote monitor [--port PORT] [--baud RATE] [--timeout SEC]",
29
+ repl: "rpremote repl [--port PORT] [--baud RATE] [--timeout SEC]",
30
+ exec: "rpremote exec CODE [--port PORT] [--baud RATE] [--timeout SEC] [--language LANGUAGE]",
31
+ reset: "rpremote reset [--port PORT] [--baud RATE] [--timeout SEC]",
32
+ fs_cp: "rpremote fs cp SOURCE DESTINATION [--recursive] [--port PORT] [--baud RATE] [--timeout SEC]",
33
+ fs_push: "rpremote fs push LOCAL_DIR :/REMOTE_DIR [--port PORT] [--baud RATE] [--timeout SEC]",
34
+ fs_cat: "rpremote fs cat :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
35
+ fs_ls: "rpremote fs ls :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
36
+ fs_rm: "rpremote fs rm :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
37
+ fs_mkdir: "rpremote fs mkdir :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]"
38
+ }.freeze
39
+
40
+ TEXT = <<~HELP.freeze
6
41
  rpremote - R2P2 remote control for Raspberry Pi Pico 2
7
42
 
8
43
  Usage:
9
- rpremote setup [--language LANGUAGE] [--language-version VERSION] [--force] [--cache DIR]
10
- rpremote build [--language LANGUAGE] [--language-version VERSION] [--board BOARD]
11
- [--firmware FILE] [--cache DIR]
12
- [--mrbgems FILE|--no-mrbgems]
13
- rpremote build clean
14
- rpremote dfu app FILE [--type ruby|rite] [--port PORT] [--baud RATE] [--timeout SEC]
15
- rpremote dfu compile FILE [--output FILE] [--language picoruby]
16
- [--language-version VERSION] [--cache DIR]
17
- rpremote dfu status [--port PORT] [--baud RATE] [--timeout SEC]
18
- rpremote mrbgems check|list|lock|update [--file FILE] [--lockfile FILE]
19
- rpremote flash [--firmware FILE] [--language LANGUAGE] [--language-version VERSION]
20
- [--board BOARD] [--cache DIR] [--mount DIR] [--port PORT] [--timeout SEC]
21
- rpremote ports
22
- rpremote run FILE [--port PORT] [--timeout SEC] [--language LANGUAGE]
23
- rpremote monitor [--port PORT]
24
- rpremote repl [--port PORT]
25
- rpremote exec CODE [--port PORT] [--timeout SEC] [--language LANGUAGE]
26
- rpremote reset [--port PORT]
27
- rpremote fs cp FILE :/REMOTE/PATH [--port PORT]
28
- rpremote fs cp :/REMOTE/PATH FILE [--port PORT]
29
- rpremote fs cat :/REMOTE/PATH [--port PORT]
30
- rpremote fs ls :/REMOTE/PATH [--port PORT]
31
- rpremote fs rm :/REMOTE/PATH [--port PORT]
32
- rpremote fs mkdir :/REMOTE/PATH [--port PORT]
44
+ #{COMMAND_USAGE.fetch(:setup)}
45
+ #{COMMAND_USAGE.fetch(:build)}
46
+ #{COMMAND_USAGE.fetch(:build_clean)}
47
+ #{COMMAND_USAGE.fetch(:bootsel)}
48
+ #{COMMAND_USAGE.fetch(:deploy)}
49
+ #{COMMAND_USAGE.fetch(:dfu_app)}
50
+ #{COMMAND_USAGE.fetch(:dfu_compile)}
51
+ #{COMMAND_USAGE.fetch(:dfu_status)}
52
+ #{COMMAND_USAGE.fetch(:dfu_remove)}
53
+ #{COMMAND_USAGE.fetch(:mrbgems).sub("SUBCOMMAND", "check|list|lock|update")}
54
+ #{COMMAND_USAGE.fetch(:flash)}
55
+ #{COMMAND_USAGE.fetch(:config_show)}
56
+ #{COMMAND_USAGE.fetch(:ports)}
57
+ #{COMMAND_USAGE.fetch(:run)}
58
+ #{COMMAND_USAGE.fetch(:monitor)}
59
+ #{COMMAND_USAGE.fetch(:repl)}
60
+ #{COMMAND_USAGE.fetch(:exec)}
61
+ #{COMMAND_USAGE.fetch(:reset)}
62
+ #{COMMAND_USAGE.fetch(:fs_cp)}
63
+ #{COMMAND_USAGE.fetch(:fs_push)}
64
+ #{COMMAND_USAGE.fetch(:fs_cat)}
65
+ #{COMMAND_USAGE.fetch(:fs_ls)}
66
+ #{COMMAND_USAGE.fetch(:fs_rm)}
67
+ #{COMMAND_USAGE.fetch(:fs_mkdir)}
33
68
 
34
69
  `rpremote build clean` removes only the project's generated `build/` directory.
35
70
 
@@ -41,20 +76,265 @@ module Rpremote
41
76
  --language-version VERSION
42
77
  use R2P2/PicoRuby 4.0.3 or 3.4.2 (default: 4.0.3)
43
78
  --cache DIR use another project cache directory
44
- --mrbgems FILE use an explicit Mrbgems definition during build
45
- --no-mrbgems build without the automatically detected Mrbgems
79
+ --mrbgems FILE use an explicit Mrbgems definition during build or deploy
80
+ --no-mrbgems build or deploy without the automatically detected Mrbgems
46
81
  --board BOARD
47
82
  select pico2 or pico2_w (default: pico2)
48
- --firmware FILE build to, or flash from, this UF2 path
83
+ --firmware FILE build to, or deploy or flash from, this UF2 path
49
84
  --language LANGUAGE
50
85
  select the remote language (default: picoruby)
51
86
  --config FILE use another configuration file
52
87
  --mount DIR use an explicit RP2350 BOOTSEL drive
53
88
  --port PORT use an explicit R2P2 CDC 0 device
54
89
  --baud RATE serial baud rate (default: 115200)
55
- --timeout SEC timeout in seconds (default: 10)
90
+ --timeout SEC timeout in seconds (default: 20)
56
91
  -h, --help show this help
57
92
  -V, --version show the version
58
93
  HELP
94
+
95
+ def self.requested?(command, args)
96
+ %w[help --help -h].include?(command) || args.include?("--help") || args.include?("-h")
97
+ end
98
+
99
+ def self.command_text(command, args)
100
+ command = args.shift if command == "help"
101
+ return TEXT if command.nil? || %w[help --help -h].include?(command)
102
+
103
+ case command
104
+ when "setup" then setup_text
105
+ when "build" then args.first == "clean" ? build_clean_text : build_text
106
+ when "bootsel" then bootsel_text
107
+ when "deploy" then deploy_text
108
+ when "dfu" then dfu_text(args.first)
109
+ when "mrbgems" then mrbgems_text(args.first)
110
+ when "flash" then flash_text
111
+ when "config" then config_text(args.first)
112
+ when "ports" then ports_text
113
+ when "run" then run_text
114
+ when "exec" then exec_text
115
+ when "monitor" then monitor_text
116
+ when "repl" then repl_text
117
+ when "reset" then reset_text
118
+ when "fs" then fs_text(args.first)
119
+ else TEXT
120
+ end
121
+ end
122
+
123
+ def self.setup_text
124
+ <<~HELP
125
+ Usage: #{COMMAND_USAGE.fetch(:setup)}
126
+
127
+ Creates config/setting.json when it does not exist, then downloads and prepares PicoRuby source and the official Raspberry Pi nuke_universal.uf2 firmware.
128
+ Options: --language LANGUAGE (picoruby), --language-version VERSION (4.0.3), --cache DIR (firmware), --force.
129
+ This changes the project configuration and source cache but does not connect to a board.
130
+ HELP
131
+ end
132
+
133
+ def self.build_text
134
+ <<~HELP
135
+ Usage: #{COMMAND_USAGE.fetch(:build)}
136
+
137
+ Builds a custom UF2 for the selected target. It writes generated files under build/ and the selected firmware path.
138
+ Options: --language LANGUAGE, --language-version VERSION, --board BOARD (pico2), --cache DIR (firmware),
139
+ --firmware FILE, --mrbgems FILE, --no-mrbgems. A project Mrbgems file is used automatically by default.
140
+ HELP
141
+ end
142
+
143
+ def self.build_clean_text
144
+ <<~HELP
145
+ Usage: #{COMMAND_USAGE.fetch(:build_clean)}
146
+
147
+ Removes only the project's generated build/ directory. It does not remove firmware/, Mrbgems, or Mrbgems.lock.
148
+ HELP
149
+ end
150
+
151
+ def self.deploy_text
152
+ <<~HELP
153
+ Usage: #{COMMAND_USAGE.fetch(:deploy)}
154
+
155
+ Builds the selected custom UF2, enters BOOTSEL when needed, flashes the firmware, and waits for the R2P2 Shell to become ready.
156
+ If PATH/lib/NAME exists, it copies it to :/lib/NAME, where NAME is the final component of PATH. It then runs PATH/main.rb and preserves its Shell job, so hardware output remains active until the next command.
157
+ The stages run in order and stop at the first failure. Flashing replaces persistent board firmware.
158
+ deploy requires PicoRuby 4.x firmware (currently 4.0.3).
159
+ Options combine build, BOOTSEL, flash, library-copy, and run settings. The first install of firmware with automatic BOOTSEL still requires holding BOOTSEL.
160
+ HELP
161
+ end
162
+
163
+ def self.bootsel_text
164
+ <<~HELP
165
+ Usage: #{COMMAND_USAGE.fetch(:bootsel)}
166
+
167
+ Asks the running R2P2 firmware to restart as an RP2350 BOOTSEL USB volume, then waits for that volume to appear.
168
+ By default it does not write firmware. This requires firmware built by a current rpremote; use physical BOOTSEL once to install it first.
169
+ --reset-flash-memory copies the official Raspberry Pi nuke_universal.uf2 in BOOTSEL mode and erases all external flash memory.
170
+ After resetting flash memory, wait for BOOTSEL to reappear and run `rpremote flash` to install R2P2 again.
171
+ HELP
172
+ end
173
+
174
+ def self.dfu_text(subcommand)
175
+ return dfu_app_text if subcommand == "app"
176
+ return dfu_compile_text if subcommand == "compile"
177
+ return dfu_status_text if subcommand == "status"
178
+ return dfu_remove_text if subcommand == "remove"
179
+
180
+ <<~HELP
181
+ Usage: #{COMMAND_USAGE.fetch(:dfu_app)}
182
+ #{COMMAND_USAGE.fetch(:dfu_compile)}
183
+ #{COMMAND_USAGE.fetch(:dfu_status)}
184
+ #{COMMAND_USAGE.fetch(:dfu_remove)}
185
+
186
+ Stages and inspects PicoModem DFU applications. Run `rpremote dfu SUBCOMMAND --help` for details.
187
+ HELP
188
+ end
189
+
190
+ def self.dfu_app_text
191
+ <<~HELP
192
+ Usage: #{COMMAND_USAGE.fetch(:dfu_app)}
193
+
194
+ Transfers a Ruby source or matching RITE bytecode application to the inactive DFU slot. The next R2P2 restart tries it.
195
+ Options default to the configured or automatically selected CDC 0 port, 115200 baud, and 20 seconds.
196
+ The transfer changes the staged boot application; use `rpremote reset` to restart and require DFU.confirm after a successful boot.
197
+ HELP
198
+ end
199
+
200
+ def self.dfu_compile_text
201
+ <<~HELP
202
+ Usage: #{COMMAND_USAGE.fetch(:dfu_compile)}
203
+
204
+ Compiles Ruby source to bytecode that matches the selected PicoRuby version. The output defaults beside FILE.
205
+ It requires prepared PicoRuby source and does not connect to a board.
206
+ HELP
207
+ end
208
+
209
+ def self.dfu_status_text
210
+ <<~HELP
211
+ Usage: #{COMMAND_USAGE.fetch(:dfu_status)}
212
+
213
+ Prints the active and candidate DFU A/B slots. Defaults are the configured or automatically selected CDC 0 port,
214
+ 115200 baud, and 20 seconds. This command reads board state without changing it.
215
+ HELP
216
+ end
217
+
218
+ def self.dfu_remove_text
219
+ <<~HELP
220
+ Usage: #{COMMAND_USAGE.fetch(:dfu_remove)}
221
+
222
+ Permanently removes the Ruby source and bytecode applications from both DFU A/B slots and resets their metadata.
223
+ Run `rpremote reset` afterward to stop the application already running in RAM. It does not remove /home/app.rb,
224
+ /home/app.mrb, other files, embedded mrbgems, or R2P2 firmware.
225
+ HELP
226
+ end
227
+
228
+ def self.mrbgems_text(subcommand)
229
+ action = subcommand || "check|list|lock|update"
230
+ <<~HELP
231
+ Usage: #{COMMAND_USAGE.fetch(:mrbgems).sub("SUBCOMMAND", action)}
232
+
233
+ Checks or lists build dependencies, writes a reproducible lock file, or updates locked GitHub commits.
234
+ `check` and `list` are read-only. `lock` writes Mrbgems.lock; `update` resolves new commits and rewrites it.
235
+ HELP
236
+ end
237
+
238
+ def self.flash_text
239
+ <<~HELP
240
+ Usage: #{COMMAND_USAGE.fetch(:flash)}
241
+
242
+ Copies the selected UF2 to an RP2350 BOOTSEL volume and waits for R2P2 to reconnect. It replaces persistent board firmware.
243
+ Defaults select pico2, firmware/picoruby-4.0.3-pico2.uf2, an automatic BOOTSEL mount and CDC 0 port, and 20 seconds.
244
+ HELP
245
+ end
246
+
247
+ def self.config_text(subcommand)
248
+ unless subcommand == "show"
249
+ return "Usage: #{COMMAND_USAGE.fetch(:config_show)}\n\nUse `rpremote config show --help` for the supported overrides.\n"
250
+ end
251
+
252
+ <<~HELP
253
+ Usage: #{COMMAND_USAGE.fetch(:config_show)}
254
+
255
+ Prints the configuration file, defaults, and command-line overrides after resolution. It does not connect to a board or change state.
256
+ HELP
257
+ end
258
+
259
+ def self.ports_text
260
+ "Usage: #{COMMAND_USAGE.fetch(:ports)}\n\nPrints each detected R2P2 CDC 0 serial path, one per line. It does not connect to a board.\n"
261
+ end
262
+
263
+ def self.run_text
264
+ <<~HELP
265
+ Usage: #{COMMAND_USAGE.fetch(:run)}
266
+
267
+ Uploads FILE to R2P2, or main.rb when FILE is a directory, relays output, then removes the temporary remote file. Defaults are the automatic CDC 0 port,
268
+ 115200 baud, a 20-second idle timeout, and picoruby. Output from the running program resets the idle timeout.
269
+ It exits nonzero when compatible R2P2 firmware reports a Ruby exception.
270
+ --reset-on-timeout resets R2P2 after the run times out and the serial connection has closed.
271
+ HELP
272
+ end
273
+
274
+ def self.exec_text
275
+ <<~HELP
276
+ Usage: #{COMMAND_USAGE.fetch(:exec)}
277
+
278
+ Runs short Ruby CODE through a temporary remote file, then removes it. Defaults are the automatic CDC 0 port,
279
+ 115200 baud, a 20-second idle timeout, and picoruby. Output from the running program resets the idle timeout.
280
+ It exits nonzero when compatible R2P2 firmware reports a Ruby exception.
281
+ HELP
282
+ end
283
+
284
+ def self.monitor_text
285
+ <<~HELP
286
+ Usage: #{COMMAND_USAGE.fetch(:monitor)}
287
+
288
+ Opens a serial monitor for the selected CDC 0 port. Defaults are 115200 baud and 20 seconds; exit with Ctrl-].
289
+ HELP
290
+ end
291
+
292
+ def self.repl_text
293
+ <<~HELP
294
+ Usage: #{COMMAND_USAGE.fetch(:repl)}
295
+
296
+ Opens PicoIRB on the selected CDC 0 port. Defaults are 115200 baud and 20 seconds; exit with Ctrl-].
297
+ HELP
298
+ end
299
+
300
+ def self.reset_text
301
+ <<~HELP
302
+ Usage: #{COMMAND_USAGE.fetch(:reset)}
303
+
304
+ Reboots R2P2 and waits for reconnection. Defaults are the automatic CDC 0 port, 115200 baud, and 20 seconds.
305
+ HELP
306
+ end
307
+
308
+ def self.fs_text(subcommand)
309
+ return fs_subcommand_text(subcommand) if %w[cp push cat ls rm mkdir].include?(subcommand)
310
+
311
+ <<~HELP
312
+ Usage: #{COMMAND_USAGE.fetch(:fs_cp)}
313
+ #{COMMAND_USAGE.fetch(:fs_push)}
314
+ #{COMMAND_USAGE.fetch(:fs_cat)}
315
+ #{COMMAND_USAGE.fetch(:fs_ls)}
316
+ #{COMMAND_USAGE.fetch(:fs_rm)}
317
+ #{COMMAND_USAGE.fetch(:fs_mkdir)}
318
+
319
+ Uses :/REMOTE/PATH for R2P2 paths. Run `rpremote fs SUBCOMMAND --help` for details.
320
+ HELP
321
+ end
322
+
323
+ def self.fs_subcommand_text(subcommand)
324
+ usage = COMMAND_USAGE.fetch(:"fs_#{subcommand}")
325
+ effect = case subcommand
326
+ when "rm" then "Deletes the remote path permanently."
327
+ when "mkdir" then "Creates a remote directory."
328
+ when "cp" then "Transfers one file, or recursively uploads a local directory with --recursive."
329
+ when "push" then "Creates missing remote directories and recursively uploads a local directory."
330
+ else "Reads remote files."
331
+ end
332
+ <<~HELP
333
+ Usage: #{usage}
334
+
335
+ #{effect} Defaults are the automatic CDC 0 port, 115200 baud, and 20 seconds.
336
+ HELP
337
+ end
59
338
  end
60
339
  end
340
+ # rubocop:enable Metrics/ModuleLength, Metrics/CyclomaticComplexity
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "net/http"
5
5
  require "timeout"
6
6
  require "uri"
7
+ require_relative "picoruby_source_patch"
7
8
 
8
9
  module Rpremote
9
10
  class LanguageSource
@@ -26,6 +27,7 @@ module Rpremote
26
27
  @fetcher = fetcher || method(:download)
27
28
  @extractor = extractor || method(:extract)
28
29
  @preparer = preparer || method(:prepare_repository)
30
+ @patcher = PicoRubySourcePatch.new(version: version).method(:apply)
29
31
  end
30
32
 
31
33
  def setup(force: false)
@@ -38,6 +40,7 @@ module Rpremote
38
40
  raise ExtractError, "PicoRuby source was not extracted: #{source_dir}" unless File.directory?(source_dir)
39
41
 
40
42
  preparer.call(source_dir)
43
+ patcher.call(source_dir)
41
44
  source_dir
42
45
  end
43
46
 
@@ -55,7 +58,7 @@ module Rpremote
55
58
 
56
59
  private
57
60
 
58
- attr_reader :fetcher, :extractor, :preparer
61
+ attr_reader :fetcher, :extractor, :preparer, :patcher
59
62
 
60
63
  def fetch_archive
61
64
  temporary = "#{archive_path}.part"
@@ -14,7 +14,7 @@ module Rpremote
14
14
  COMMIT_PATTERN = /\A[0-9a-f]{40,64}\z/i
15
15
  VMS = %i[mruby mrubyc].freeze
16
16
 
17
- Dependency = Data.define(:type, :source, :branch, :commit, :path)
17
+ Dependency = Data.define(:type, :source, :branch, :commit, :path, :require_name)
18
18
  Overlay = Data.define(:path, :fingerprint)
19
19
 
20
20
  class Error < Rpremote::Error; end
@@ -68,6 +68,20 @@ module Rpremote
68
68
  raise LockError, "invalid mrbgems lock file #{lock_path}: #{e.message}"
69
69
  end
70
70
 
71
+ def require_names
72
+ lock_data = read_lock(required: false)
73
+ return [] unless lock_data
74
+
75
+ lock_data.fetch("gems").filter_map { |gem| gem["require_name"] }.uniq
76
+ end
77
+
78
+ def prepend_requires(source)
79
+ names = require_names
80
+ return source if names.empty?
81
+
82
+ names.map { |name| "require #{name.inspect}\n" }.join.b + source.b
83
+ end
84
+
71
85
  def generate_overlay(base_config:, target:, directory:, update: false)
72
86
  lock_data = lock(update: update)
73
87
  fingerprint = build_fingerprint(base_config, lock_data)
@@ -109,10 +123,12 @@ module Rpremote
109
123
  dependency.source, dependency.branch
110
124
  )
111
125
  { "type" => "github", "source" => dependency.source,
112
- "branch" => dependency.branch, "commit" => validate_commit!(commit) }
126
+ "branch" => dependency.branch, "commit" => validate_commit!(commit),
127
+ "require_name" => dependency.require_name }.compact
113
128
  else
114
129
  { "type" => "path", "source" => dependency.source,
115
- "sha256" => digest_directory(dependency.path) }
130
+ "sha256" => digest_directory(dependency.path),
131
+ "require_name" => dependency.require_name || local_require_name(dependency.path) }.compact
116
132
  end
117
133
  end
118
134
 
@@ -184,12 +200,22 @@ module Rpremote
184
200
 
185
201
  def valid_github_lock?(entry)
186
202
  GITHUB_PATTERN.match?(entry["source"].to_s) && !entry["branch"].to_s.empty? &&
187
- COMMIT_PATTERN.match?(entry["commit"].to_s)
203
+ COMMIT_PATTERN.match?(entry["commit"].to_s) && valid_require_name?(entry["require_name"])
188
204
  end
189
205
 
190
206
  def valid_path_lock?(entry)
191
207
  entry["type"] == "path" && !entry["source"].to_s.empty? &&
192
- /\A[0-9a-f]{64}\z/.match?(entry["sha256"].to_s)
208
+ /\A[0-9a-f]{64}\z/.match?(entry["sha256"].to_s) && valid_require_name?(entry["require_name"])
209
+ end
210
+
211
+ def valid_require_name?(name)
212
+ name.nil? || (name.is_a?(String) && !name.empty? && !name.match?(/[\x00-\x1f\x7f]/))
213
+ end
214
+
215
+ def local_require_name(directory)
216
+ source = File.read(File.join(directory, "mrbgem.rake"))
217
+ match = source.match(/^\s*spec\.require_name\s*=\s*["']([^"']+)["']\s*$/)
218
+ match && match[1]
193
219
  end
194
220
 
195
221
  def build_fingerprint(base_config, lock_data)
@@ -246,14 +272,17 @@ module Rpremote
246
272
  @vm_name = value
247
273
  end
248
274
 
249
- def gem(github: nil, path: nil, branch: "main", commit: nil)
275
+ def gem(github: nil, path: nil, branch: "main", commit: nil, require: nil)
250
276
  sources = [github, path].compact
251
277
  raise DefinitionError, "gem requires exactly one of github or path" unless sources.length == 1
278
+ unless require.nil? || (require.is_a?(String) && !require.empty? && !require.match?(/[\x00-\x1f\x7f]/))
279
+ raise DefinitionError, "invalid mrbgem require name: #{require.inspect}"
280
+ end
252
281
 
253
282
  dependency = if github
254
- github_dependency(github, branch, commit)
283
+ github_dependency(github, branch, commit, require)
255
284
  else
256
- path_dependency(path, branch, commit)
285
+ path_dependency(path, branch, commit, require)
257
286
  end
258
287
  key = [dependency.type, dependency.source]
259
288
  raise DefinitionError, "duplicate mrbgem: #{dependency.source}" if dependencies.any? do |item|
@@ -267,21 +296,21 @@ module Rpremote
267
296
 
268
297
  attr_reader :directory
269
298
 
270
- def github_dependency(source, branch, commit)
299
+ def github_dependency(source, branch, commit, require_name)
271
300
  raise DefinitionError, "invalid GitHub mrbgem: #{source.inspect}" unless GITHUB_PATTERN.match?(source.to_s)
272
301
  raise DefinitionError, "GitHub mrbgem branch must not be empty" if branch.to_s.empty?
273
302
  raise DefinitionError, "invalid Git commit: #{commit.inspect}" if commit && !COMMIT_PATTERN.match?(commit.to_s)
274
303
 
275
304
  Dependency.new(type: :github, source: source, branch: branch,
276
- commit: commit&.downcase, path: nil)
305
+ commit: commit&.downcase, path: nil, require_name: require_name)
277
306
  end
278
307
 
279
- def path_dependency(source, branch, commit)
308
+ def path_dependency(source, branch, commit, require_name)
280
309
  raise DefinitionError, "local mrbgem path must not be empty" if source.to_s.empty?
281
310
  raise DefinitionError, "local mrbgem does not accept branch or commit" if branch != "main" || commit
282
311
 
283
312
  Dependency.new(type: :path, source: source, branch: nil, commit: nil,
284
- path: File.expand_path(source, directory))
313
+ path: File.expand_path(source, directory), require_name: require_name)
285
314
  end
286
315
  end
287
316
  end