rpremote 0.2.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.
@@ -8,16 +8,21 @@ module Rpremote
8
8
  TYPES = { ".rb" => "RUBY", ".mrb" => "RITE" }.freeze
9
9
 
10
10
  def self.run(args, defaults:, output: $stdout, services: {})
11
- default_services = { serial: Serial, device: Device, modem: PicoModem, runner: Runner, compiler: DfuCompiler }
11
+ default_services = {
12
+ serial: Serial, device: Device, modem: PicoModem, runner: Runner,
13
+ compiler: DfuCompiler
14
+ }
12
15
  services = default_services.merge(services)
13
16
  subcommand = args.shift
14
17
  case subcommand
15
18
  when "app" then stage(args, defaults, output, services)
16
19
  when "compile" then compile(args, defaults, output, services[:compiler])
17
20
  when "status" then status(args, defaults, output, services)
21
+ when "remove" then remove(args, defaults, output, services)
18
22
  else
19
23
  raise ArgumentError,
20
- "usage: rpremote dfu app FILE [options], dfu compile FILE [options], or dfu status [options]"
24
+ "usage: rpremote dfu app FILE [options], dfu compile FILE [options], " \
25
+ "dfu status [options], or dfu remove [options]"
21
26
  end
22
27
  end
23
28
 
@@ -52,7 +57,7 @@ module Rpremote
52
57
  private_class_method :compile
53
58
 
54
59
  def self.status(args, defaults, output, services)
55
- options = parse_app_options(args, defaults)
60
+ options = parse_connection_options(args, defaults)
56
61
  raise ArgumentError, "usage: rpremote dfu status [options]" unless args.empty?
57
62
 
58
63
  port_path = services[:device].main_port(options[:port])
@@ -71,6 +76,45 @@ module Rpremote
71
76
  end
72
77
  private_class_method :status
73
78
 
79
+ def self.remove(args, defaults, output, services)
80
+ options = parse_connection_options(args, defaults)
81
+ raise ArgumentError, "usage: rpremote dfu remove [options]" unless args.empty?
82
+
83
+ port_path = services[:device].main_port(options[:port])
84
+ code = <<~'RUBY'
85
+ require "dfu"
86
+ # A confirmed DFU launcher may have returned after leaving a Sandbox
87
+ # worker alive. Stop known Processing workers before waiting for the
88
+ # Shell prompt; deleting their source files does not stop RAM tasks.
89
+ begin
90
+ $imu_processing_stream.close if $imu_processing_stream
91
+ $imu_processing_stream = nil
92
+ rescue Exception
93
+ $imu_processing_stream = nil
94
+ end
95
+ begin
96
+ $mpu6050_processing_stream.close if $mpu6050_processing_stream
97
+ $mpu6050_processing_stream = nil
98
+ rescue Exception
99
+ $mpu6050_processing_stream = nil
100
+ end
101
+ DFU::Meta.recover
102
+ DFU::Meta.save(DFU::Meta.deep_copy(DFU::Meta::DEFAULT))
103
+ %w[a b].each do |slot|
104
+ %w[rb mrb].each do |ext|
105
+ path = "#{ENV['HOME']}/app_#{slot}.#{ext}"
106
+ File.unlink(path) if File.exist?(path)
107
+ end
108
+ end
109
+ RUBY
110
+ output.puts("removing all DFU boot applications from #{port_path}; this permanently clears both A/B slots")
111
+ services[:serial].open(port_path, baud: options[:baud]) do |port|
112
+ services[:runner].new(port, timeout: options[:timeout]).run(code)
113
+ end
114
+ output.puts("removed all DFU boot applications; run `rpremote reset` to stop the running application")
115
+ end
116
+ private_class_method :remove
117
+
74
118
  def self.parse_app_options(args, defaults)
75
119
  options = {
76
120
  port: defaults[:port],
@@ -91,6 +135,24 @@ module Rpremote
91
135
  end
92
136
  private_class_method :parse_app_options
93
137
 
138
+ def self.parse_connection_options(args, defaults)
139
+ options = {
140
+ port: defaults[:port],
141
+ baud: defaults.fetch(:baud, Serial::BAUD_RATE),
142
+ timeout: defaults.fetch(:timeout, DEFAULT_TIMEOUT)
143
+ }
144
+ OptionParser.new do |parser|
145
+ parser.on("--port PORT") { |value| options[:port] = value }
146
+ parser.on("--baud RATE", Integer) { |value| options[:baud] = value }
147
+ parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
148
+ end.parse!(args)
149
+ raise ArgumentError, "--baud must be positive" unless options[:baud].positive?
150
+ raise ArgumentError, "--timeout must be positive" unless options[:timeout].positive?
151
+
152
+ options
153
+ end
154
+ private_class_method :parse_connection_options
155
+
94
156
  def self.parse_compile_options(args, defaults)
95
157
  options = {
96
158
  language: defaults.fetch(:language, Target::DEFAULT_LANGUAGE),
@@ -33,7 +33,10 @@ module Rpremote
33
33
  }
34
34
  OptionParser.new do |parser|
35
35
  parser.on("--cache DIR") { |value| options[:cache_dir] = value }
36
- 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
37
40
  parser.on("--language LANGUAGE") { |value| options[:language] = value }
38
41
  parser.on("--language-version VERSION") { |value| options[:language_version] = value }
39
42
  parser.on("--board BOARD") { |value| options[:board] = value }
@@ -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
@@ -9,9 +9,14 @@ module Rpremote
9
9
  build: "rpremote build [--language LANGUAGE] [--language-version VERSION] [--board BOARD] " \
10
10
  "[--firmware FILE] [--cache DIR] [--mrbgems FILE|--no-mrbgems]",
11
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]",
12
16
  dfu_app: "rpremote dfu app FILE [--type ruby|rite] [--port PORT] [--baud RATE] [--timeout SEC]",
13
17
  dfu_compile: "rpremote dfu compile FILE [--output FILE] [--language LANGUAGE] [--language-version VERSION] [--cache DIR]",
14
18
  dfu_status: "rpremote dfu status [--port PORT] [--baud RATE] [--timeout SEC]",
19
+ dfu_remove: "rpremote dfu remove [--port PORT] [--baud RATE] [--timeout SEC]",
15
20
  mrbgems: "rpremote mrbgems SUBCOMMAND [--file FILE] [--lockfile FILE]",
16
21
  flash: "rpremote flash [--firmware FILE] [--language LANGUAGE] [--language-version VERSION] " \
17
22
  "[--board BOARD] [--cache DIR] [--mount DIR] [--port PORT] [--timeout SEC]",
@@ -19,12 +24,13 @@ module Rpremote
19
24
  "[--cache DIR] [--firmware FILE] [--mrbgems FILE|--no-mrbgems] [--mount DIR] " \
20
25
  "[--port PORT] [--baud RATE] [--timeout SEC]",
21
26
  ports: "rpremote ports",
22
- run: "rpremote run FILE [--port PORT] [--baud RATE] [--timeout SEC] [--language LANGUAGE]",
27
+ run: "rpremote run FILE [--port PORT] [--baud RATE] [--timeout SEC] [--reset-on-timeout] [--language LANGUAGE]",
23
28
  monitor: "rpremote monitor [--port PORT] [--baud RATE] [--timeout SEC]",
24
29
  repl: "rpremote repl [--port PORT] [--baud RATE] [--timeout SEC]",
25
30
  exec: "rpremote exec CODE [--port PORT] [--baud RATE] [--timeout SEC] [--language LANGUAGE]",
26
31
  reset: "rpremote reset [--port PORT] [--baud RATE] [--timeout SEC]",
27
- fs_cp: "rpremote fs cp SOURCE DESTINATION [--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]",
28
34
  fs_cat: "rpremote fs cat :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
29
35
  fs_ls: "rpremote fs ls :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
30
36
  fs_rm: "rpremote fs rm :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
@@ -38,9 +44,12 @@ module Rpremote
38
44
  #{COMMAND_USAGE.fetch(:setup)}
39
45
  #{COMMAND_USAGE.fetch(:build)}
40
46
  #{COMMAND_USAGE.fetch(:build_clean)}
47
+ #{COMMAND_USAGE.fetch(:bootsel)}
48
+ #{COMMAND_USAGE.fetch(:deploy)}
41
49
  #{COMMAND_USAGE.fetch(:dfu_app)}
42
50
  #{COMMAND_USAGE.fetch(:dfu_compile)}
43
51
  #{COMMAND_USAGE.fetch(:dfu_status)}
52
+ #{COMMAND_USAGE.fetch(:dfu_remove)}
44
53
  #{COMMAND_USAGE.fetch(:mrbgems).sub("SUBCOMMAND", "check|list|lock|update")}
45
54
  #{COMMAND_USAGE.fetch(:flash)}
46
55
  #{COMMAND_USAGE.fetch(:config_show)}
@@ -51,6 +60,7 @@ module Rpremote
51
60
  #{COMMAND_USAGE.fetch(:exec)}
52
61
  #{COMMAND_USAGE.fetch(:reset)}
53
62
  #{COMMAND_USAGE.fetch(:fs_cp)}
63
+ #{COMMAND_USAGE.fetch(:fs_push)}
54
64
  #{COMMAND_USAGE.fetch(:fs_cat)}
55
65
  #{COMMAND_USAGE.fetch(:fs_ls)}
56
66
  #{COMMAND_USAGE.fetch(:fs_rm)}
@@ -66,11 +76,11 @@ module Rpremote
66
76
  --language-version VERSION
67
77
  use R2P2/PicoRuby 4.0.3 or 3.4.2 (default: 4.0.3)
68
78
  --cache DIR use another project cache directory
69
- --mrbgems FILE use an explicit Mrbgems definition during build
70
- --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
71
81
  --board BOARD
72
82
  select pico2 or pico2_w (default: pico2)
73
- --firmware FILE build to, or flash from, this UF2 path
83
+ --firmware FILE build to, or deploy or flash from, this UF2 path
74
84
  --language LANGUAGE
75
85
  select the remote language (default: picoruby)
76
86
  --config FILE use another configuration file
@@ -93,6 +103,8 @@ module Rpremote
93
103
  case command
94
104
  when "setup" then setup_text
95
105
  when "build" then args.first == "clean" ? build_clean_text : build_text
106
+ when "bootsel" then bootsel_text
107
+ when "deploy" then deploy_text
96
108
  when "dfu" then dfu_text(args.first)
97
109
  when "mrbgems" then mrbgems_text(args.first)
98
110
  when "flash" then flash_text
@@ -112,7 +124,7 @@ module Rpremote
112
124
  <<~HELP
113
125
  Usage: #{COMMAND_USAGE.fetch(:setup)}
114
126
 
115
- Creates config/setting.json when it does not exist, then downloads and prepares PicoRuby source.
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.
116
128
  Options: --language LANGUAGE (picoruby), --language-version VERSION (4.0.3), --cache DIR (firmware), --force.
117
129
  This changes the project configuration and source cache but does not connect to a board.
118
130
  HELP
@@ -136,15 +148,40 @@ module Rpremote
136
148
  HELP
137
149
  end
138
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
+
139
174
  def self.dfu_text(subcommand)
140
175
  return dfu_app_text if subcommand == "app"
141
176
  return dfu_compile_text if subcommand == "compile"
142
177
  return dfu_status_text if subcommand == "status"
178
+ return dfu_remove_text if subcommand == "remove"
143
179
 
144
180
  <<~HELP
145
181
  Usage: #{COMMAND_USAGE.fetch(:dfu_app)}
146
182
  #{COMMAND_USAGE.fetch(:dfu_compile)}
147
183
  #{COMMAND_USAGE.fetch(:dfu_status)}
184
+ #{COMMAND_USAGE.fetch(:dfu_remove)}
148
185
 
149
186
  Stages and inspects PicoModem DFU applications. Run `rpremote dfu SUBCOMMAND --help` for details.
150
187
  HELP
@@ -178,6 +215,16 @@ module Rpremote
178
215
  HELP
179
216
  end
180
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
+
181
228
  def self.mrbgems_text(subcommand)
182
229
  action = subcommand || "check|list|lock|update"
183
230
  <<~HELP
@@ -217,8 +264,10 @@ module Rpremote
217
264
  <<~HELP
218
265
  Usage: #{COMMAND_USAGE.fetch(:run)}
219
266
 
220
- Uploads FILE to R2P2, relays output, then removes the temporary remote file. Defaults are the automatic CDC 0 port,
221
- 115200 baud, 20 seconds, and picoruby. It exits nonzero when compatible R2P2 firmware reports a Ruby exception.
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.
222
271
  HELP
223
272
  end
224
273
 
@@ -227,7 +276,8 @@ module Rpremote
227
276
  Usage: #{COMMAND_USAGE.fetch(:exec)}
228
277
 
229
278
  Runs short Ruby CODE through a temporary remote file, then removes it. Defaults are the automatic CDC 0 port,
230
- 115200 baud, 20 seconds, and picoruby. It exits nonzero when compatible R2P2 firmware reports a Ruby exception.
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.
231
281
  HELP
232
282
  end
233
283
 
@@ -256,10 +306,11 @@ module Rpremote
256
306
  end
257
307
 
258
308
  def self.fs_text(subcommand)
259
- return fs_subcommand_text(subcommand) if %w[cp cat ls rm mkdir].include?(subcommand)
309
+ return fs_subcommand_text(subcommand) if %w[cp push cat ls rm mkdir].include?(subcommand)
260
310
 
261
311
  <<~HELP
262
312
  Usage: #{COMMAND_USAGE.fetch(:fs_cp)}
313
+ #{COMMAND_USAGE.fetch(:fs_push)}
263
314
  #{COMMAND_USAGE.fetch(:fs_cat)}
264
315
  #{COMMAND_USAGE.fetch(:fs_ls)}
265
316
  #{COMMAND_USAGE.fetch(:fs_rm)}
@@ -274,7 +325,9 @@ module Rpremote
274
325
  effect = case subcommand
275
326
  when "rm" then "Deletes the remote path permanently."
276
327
  when "mkdir" then "Creates a remote directory."
277
- else "Reads or transfers remote files."
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."
278
331
  end
279
332
  <<~HELP
280
333
  Usage: #{usage}
@@ -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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Rpremote
9
+ class NukeFirmware
10
+ URL = "https://github.com/raspberrypi/pico-sdk-prebuilts/releases/latest/download/nuke_universal.uf2"
11
+ FILENAME = "nuke_universal.uf2"
12
+ MAX_REDIRECTS = 5
13
+
14
+ class DownloadError < Rpremote::Error; end
15
+
16
+ def initialize(directory: Target::DEFAULT_CACHE_DIR, fetcher: nil)
17
+ @directory = File.expand_path(directory)
18
+ @fetcher = fetcher || method(:download)
19
+ end
20
+
21
+ def setup(force: false)
22
+ FileUtils.mkdir_p(directory)
23
+ return path if File.size?(path) && !force
24
+
25
+ temporary = "#{path}.part"
26
+ File.binwrite(temporary, fetcher.call(URL))
27
+ raise DownloadError, "downloaded nuke_universal.uf2 is empty" unless File.size?(temporary)
28
+
29
+ File.rename(temporary, path)
30
+ path
31
+ rescue SystemCallError => e
32
+ raise DownloadError, "cannot store nuke_universal.uf2: #{e.message}"
33
+ ensure
34
+ FileUtils.rm_f(temporary) if temporary
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :directory, :fetcher
40
+
41
+ def path
42
+ File.join(directory, FILENAME)
43
+ end
44
+
45
+ def download(url, redirects = MAX_REDIRECTS)
46
+ raise DownloadError, "too many redirects while downloading nuke_universal.uf2" if redirects.negative?
47
+
48
+ uri = URI(url)
49
+ raise DownloadError, "nuke_universal.uf2 download requires HTTPS" unless uri.is_a?(URI::HTTPS)
50
+
51
+ request = Net::HTTP::Get.new(uri)
52
+ request["User-Agent"] = "rpremote/#{Rpremote::VERSION}"
53
+ response = Net::HTTP.start(
54
+ uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60
55
+ ) { |http| http.request(request) }
56
+
57
+ case response
58
+ when Net::HTTPSuccess then response.body
59
+ when Net::HTTPRedirection
60
+ location = response["location"]
61
+ raise DownloadError, "nuke_universal.uf2 download redirect has no location" unless location
62
+
63
+ download(URI.join(url, location).to_s, redirects - 1)
64
+ else
65
+ raise DownloadError, "nuke_universal.uf2 download failed: HTTP #{response.code} #{response.message}"
66
+ end
67
+ rescue SocketError, SystemCallError, Timeout::Error => e
68
+ raise DownloadError, "nuke_universal.uf2 download failed: #{e.message}"
69
+ end
70
+ end
71
+ end
@@ -3,6 +3,7 @@
3
3
  module Rpremote
4
4
  class PicoRubySourcePatch
5
5
  JOB_PATH = "mrbgems/picoruby-shell/mrblib/job.rb"
6
+ BOOTSEL_PATH = "mrbgems/picoruby-machine/include/machine.h"
6
7
  PATCH_ALIASES = { "3.4.2" => "3.4.5" }.freeze
7
8
 
8
9
  class Error < Rpremote::Error; end
@@ -14,19 +15,35 @@ module Rpremote
14
15
  end
15
16
 
16
17
  def apply(source)
18
+ return unless Dir.exist?(source)
19
+
17
20
  patch_version = PATCH_ALIASES.fetch(version, version)
18
- patch = File.expand_path("../../patches/picoruby-#{patch_version}-ruby-exception-status.patch", __dir__)
19
- return unless File.file?(patch) && File.file?(File.join(source, JOB_PATH))
20
- return if git_apply?(source, patch, "--reverse", "--check")
21
+ patches(patch_version).each do |patch, prerequisite|
22
+ next unless File.file?(patch) && File.file?(File.join(source, prerequisite))
23
+ next if prerequisite == BOOTSEL_PATH && File.read(File.join(source, prerequisite)).include?("Machine_bootsel")
24
+
25
+ next if git_apply?(source, patch, "--reverse", "--check")
21
26
 
22
- raise Error, "cannot apply rpremote patch to PicoRuby #{version}" unless git_apply?(source, patch, "--check")
23
- raise Error, "cannot apply rpremote patch to PicoRuby #{version}" unless git_apply?(source, patch)
27
+ raise Error, "cannot apply rpremote patch to PicoRuby #{version}" unless git_apply?(source, patch, "--check")
28
+ raise Error, "cannot apply rpremote patch to PicoRuby #{version}" unless git_apply?(source, patch)
29
+ end
30
+ nil
24
31
  end
25
32
 
26
33
  private
27
34
 
35
+ def patches(patch_version)
36
+ definitions = {
37
+ "ruby-exception-status" => JOB_PATH,
38
+ "bootsel" => BOOTSEL_PATH
39
+ }
40
+ definitions.map do |name, prerequisite|
41
+ [File.expand_path("../../patches/picoruby-#{patch_version}-#{name}.patch", __dir__), prerequisite]
42
+ end
43
+ end
44
+
28
45
  def git_apply?(source, patch, *options)
29
- system("git", "apply", *options, patch, chdir: source, out: File::NULL, err: File::NULL)
46
+ system("git", "apply", "--unidiff-zero", *options, patch, chdir: source, out: File::NULL, err: File::NULL)
30
47
  end
31
48
  end
32
49
  end