rpremote 0.1.0 → 0.2.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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "picomodem"
5
+ require_relative "serial"
6
+ require_relative "target"
7
+
8
+ module Rpremote
9
+ class ConfigShow
10
+ def self.run(args, defaults:, output:)
11
+ new(defaults: defaults, output: output).run(args)
12
+ end
13
+
14
+ def initialize(defaults:, output:)
15
+ @defaults = defaults
16
+ @output = output
17
+ end
18
+
19
+ def run(args)
20
+ options = effective_options
21
+ parser = option_parser(options)
22
+ parser.parse!(args)
23
+ raise ArgumentError, "usage: rpremote config show [options]" unless args.empty?
24
+ raise ArgumentError, "--baud must be positive" unless options[:baud].positive?
25
+ raise ArgumentError, "--timeout must be positive" unless options[:timeout].positive?
26
+
27
+ print_options(options)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :defaults, :output
33
+
34
+ def effective_options
35
+ {
36
+ language: defaults.fetch(:language, Target::DEFAULT_LANGUAGE),
37
+ language_version: defaults.fetch(:language_version, Target::DEFAULT_LANGUAGE_VERSION),
38
+ board: defaults.fetch(:board, Target::DEFAULT_BOARD),
39
+ cache: defaults.fetch(:cache, Target::DEFAULT_CACHE_DIR),
40
+ firmware: defaults[:firmware],
41
+ mrbgems: defaults[:mrbgems],
42
+ mount: defaults[:mount],
43
+ port: defaults[:port],
44
+ baud: defaults.fetch(:baud, Serial::BAUD_RATE),
45
+ timeout: defaults.fetch(:timeout, PicoModem::DEFAULT_TIMEOUT)
46
+ }
47
+ end
48
+
49
+ def option_parser(options)
50
+ OptionParser.new do |opts|
51
+ opts.on("--language LANGUAGE") { |value| options[:language] = value }
52
+ opts.on("--language-version VERSION") { |value| options[:language_version] = value }
53
+ opts.on("--board BOARD") { |value| options[:board] = value }
54
+ opts.on("--cache DIR") { |value| options[:cache] = value }
55
+ opts.on("--firmware FILE") { |value| options[:firmware] = value }
56
+ opts.on("--mrbgems FILE") { |value| options[:mrbgems] = value }
57
+ opts.on("--no-mrbgems") { options[:mrbgems] = false }
58
+ opts.on("--mount DIR") { |value| options[:mount] = value }
59
+ opts.on("--port PORT") { |value| options[:port] = value }
60
+ opts.on("--baud RATE", Integer) { |value| options[:baud] = value }
61
+ opts.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
62
+ end
63
+ end
64
+
65
+ def print_options(options)
66
+ target = Target.new(
67
+ language: options[:language],
68
+ language_version: options[:language_version],
69
+ board: options[:board],
70
+ cache_dir: options[:cache],
71
+ firmware: options[:firmware]
72
+ )
73
+ output.puts("language=#{target.language}")
74
+ output.puts("language_version=#{target.language_version}")
75
+ output.puts("board=#{target.board}")
76
+ output.puts("cache=#{target.cache_dir}")
77
+ output.puts("firmware=#{target.firmware_path}")
78
+ output.puts("mrbgems=#{mrbgems_value(options[:mrbgems])}")
79
+ output.puts("mount=#{options[:mount] || "auto"}")
80
+ output.puts("port=#{options[:port] || "auto"}")
81
+ output.puts("baud=#{options[:baud]}")
82
+ output.puts("timeout=#{options[:timeout]}")
83
+ end
84
+
85
+ def mrbgems_value(value)
86
+ return "false" if value == false
87
+
88
+ value || "auto"
89
+ end
90
+ end
91
+ end
@@ -4,7 +4,7 @@ require "optparse"
4
4
 
5
5
  module Rpremote
6
6
  class DfuCommand
7
- DEFAULT_TIMEOUT = 10.0
7
+ DEFAULT_TIMEOUT = 20.0
8
8
  TYPES = { ".rb" => "RUBY", ".mrb" => "RITE" }.freeze
9
9
 
10
10
  def self.run(args, defaults:, output: $stdout, services: {})
@@ -30,6 +30,8 @@ module Rpremote
30
30
  type = options[:type] || type_for(source)
31
31
  port_path = services[:device].main_port(options[:port])
32
32
  verify_rite_version!(data, port_path, options, services) if type == "RITE"
33
+ output.puts("staging DFU #{type.downcase} app (#{data.bytesize} bytes): #{source} -> inactive slot on #{port_path}; " \
34
+ "this changes the staged boot application")
33
35
  services[:serial].open(port_path, baud: options[:baud]) do |port|
34
36
  services[:modem].new(port, timeout: options[:timeout]).dfu(data, type: type)
35
37
  end
@@ -122,9 +124,7 @@ module Rpremote
122
124
 
123
125
  def self.verify_rite_version!(data, port_path, options, services)
124
126
  bytecode_version = data.byteslice(0, 8)
125
- unless bytecode_version&.match?(/\ARITE\d{4}\z/)
126
- raise ArgumentError, "DFU RITE app must start with a RITE bytecode header"
127
- end
127
+ raise ArgumentError, "DFU RITE app must start with a RITE bytecode header" unless bytecode_version&.match?(/\ARITE\d{4}\z/)
128
128
 
129
129
  picoruby_version = services[:serial].open(port_path, baud: options[:baud]) do |port|
130
130
  services[:runner].new(port, timeout: options[:timeout]).run("p PICORUBY_VERSION")[/\d+\.\d+\.\d+/]
@@ -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)
@@ -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
data/lib/rpremote/help.rb CHANGED
@@ -1,35 +1,60 @@
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
+ dfu_app: "rpremote dfu app FILE [--type ruby|rite] [--port PORT] [--baud RATE] [--timeout SEC]",
13
+ dfu_compile: "rpremote dfu compile FILE [--output FILE] [--language LANGUAGE] [--language-version VERSION] [--cache DIR]",
14
+ dfu_status: "rpremote dfu status [--port PORT] [--baud RATE] [--timeout SEC]",
15
+ mrbgems: "rpremote mrbgems SUBCOMMAND [--file FILE] [--lockfile FILE]",
16
+ flash: "rpremote flash [--firmware FILE] [--language LANGUAGE] [--language-version VERSION] " \
17
+ "[--board BOARD] [--cache DIR] [--mount DIR] [--port PORT] [--timeout SEC]",
18
+ config_show: "rpremote config show [--language LANGUAGE] [--language-version VERSION] [--board BOARD] " \
19
+ "[--cache DIR] [--firmware FILE] [--mrbgems FILE|--no-mrbgems] [--mount DIR] " \
20
+ "[--port PORT] [--baud RATE] [--timeout SEC]",
21
+ ports: "rpremote ports",
22
+ run: "rpremote run FILE [--port PORT] [--baud RATE] [--timeout SEC] [--language LANGUAGE]",
23
+ monitor: "rpremote monitor [--port PORT] [--baud RATE] [--timeout SEC]",
24
+ repl: "rpremote repl [--port PORT] [--baud RATE] [--timeout SEC]",
25
+ exec: "rpremote exec CODE [--port PORT] [--baud RATE] [--timeout SEC] [--language LANGUAGE]",
26
+ reset: "rpremote reset [--port PORT] [--baud RATE] [--timeout SEC]",
27
+ fs_cp: "rpremote fs cp SOURCE DESTINATION [--port PORT] [--baud RATE] [--timeout SEC]",
28
+ fs_cat: "rpremote fs cat :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
29
+ fs_ls: "rpremote fs ls :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
30
+ fs_rm: "rpremote fs rm :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]",
31
+ fs_mkdir: "rpremote fs mkdir :/REMOTE/PATH [--port PORT] [--baud RATE] [--timeout SEC]"
32
+ }.freeze
33
+
34
+ TEXT = <<~HELP.freeze
6
35
  rpremote - R2P2 remote control for Raspberry Pi Pico 2
7
36
 
8
37
  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]
38
+ #{COMMAND_USAGE.fetch(:setup)}
39
+ #{COMMAND_USAGE.fetch(:build)}
40
+ #{COMMAND_USAGE.fetch(:build_clean)}
41
+ #{COMMAND_USAGE.fetch(:dfu_app)}
42
+ #{COMMAND_USAGE.fetch(:dfu_compile)}
43
+ #{COMMAND_USAGE.fetch(:dfu_status)}
44
+ #{COMMAND_USAGE.fetch(:mrbgems).sub("SUBCOMMAND", "check|list|lock|update")}
45
+ #{COMMAND_USAGE.fetch(:flash)}
46
+ #{COMMAND_USAGE.fetch(:config_show)}
47
+ #{COMMAND_USAGE.fetch(:ports)}
48
+ #{COMMAND_USAGE.fetch(:run)}
49
+ #{COMMAND_USAGE.fetch(:monitor)}
50
+ #{COMMAND_USAGE.fetch(:repl)}
51
+ #{COMMAND_USAGE.fetch(:exec)}
52
+ #{COMMAND_USAGE.fetch(:reset)}
53
+ #{COMMAND_USAGE.fetch(:fs_cp)}
54
+ #{COMMAND_USAGE.fetch(:fs_cat)}
55
+ #{COMMAND_USAGE.fetch(:fs_ls)}
56
+ #{COMMAND_USAGE.fetch(:fs_rm)}
57
+ #{COMMAND_USAGE.fetch(:fs_mkdir)}
33
58
 
34
59
  `rpremote build clean` removes only the project's generated `build/` directory.
35
60
 
@@ -52,9 +77,211 @@ module Rpremote
52
77
  --mount DIR use an explicit RP2350 BOOTSEL drive
53
78
  --port PORT use an explicit R2P2 CDC 0 device
54
79
  --baud RATE serial baud rate (default: 115200)
55
- --timeout SEC timeout in seconds (default: 10)
80
+ --timeout SEC timeout in seconds (default: 20)
56
81
  -h, --help show this help
57
82
  -V, --version show the version
58
83
  HELP
84
+
85
+ def self.requested?(command, args)
86
+ %w[help --help -h].include?(command) || args.include?("--help") || args.include?("-h")
87
+ end
88
+
89
+ def self.command_text(command, args)
90
+ command = args.shift if command == "help"
91
+ return TEXT if command.nil? || %w[help --help -h].include?(command)
92
+
93
+ case command
94
+ when "setup" then setup_text
95
+ when "build" then args.first == "clean" ? build_clean_text : build_text
96
+ when "dfu" then dfu_text(args.first)
97
+ when "mrbgems" then mrbgems_text(args.first)
98
+ when "flash" then flash_text
99
+ when "config" then config_text(args.first)
100
+ when "ports" then ports_text
101
+ when "run" then run_text
102
+ when "exec" then exec_text
103
+ when "monitor" then monitor_text
104
+ when "repl" then repl_text
105
+ when "reset" then reset_text
106
+ when "fs" then fs_text(args.first)
107
+ else TEXT
108
+ end
109
+ end
110
+
111
+ def self.setup_text
112
+ <<~HELP
113
+ Usage: #{COMMAND_USAGE.fetch(:setup)}
114
+
115
+ Creates config/setting.json when it does not exist, then downloads and prepares PicoRuby source.
116
+ Options: --language LANGUAGE (picoruby), --language-version VERSION (4.0.3), --cache DIR (firmware), --force.
117
+ This changes the project configuration and source cache but does not connect to a board.
118
+ HELP
119
+ end
120
+
121
+ def self.build_text
122
+ <<~HELP
123
+ Usage: #{COMMAND_USAGE.fetch(:build)}
124
+
125
+ Builds a custom UF2 for the selected target. It writes generated files under build/ and the selected firmware path.
126
+ Options: --language LANGUAGE, --language-version VERSION, --board BOARD (pico2), --cache DIR (firmware),
127
+ --firmware FILE, --mrbgems FILE, --no-mrbgems. A project Mrbgems file is used automatically by default.
128
+ HELP
129
+ end
130
+
131
+ def self.build_clean_text
132
+ <<~HELP
133
+ Usage: #{COMMAND_USAGE.fetch(:build_clean)}
134
+
135
+ Removes only the project's generated build/ directory. It does not remove firmware/, Mrbgems, or Mrbgems.lock.
136
+ HELP
137
+ end
138
+
139
+ def self.dfu_text(subcommand)
140
+ return dfu_app_text if subcommand == "app"
141
+ return dfu_compile_text if subcommand == "compile"
142
+ return dfu_status_text if subcommand == "status"
143
+
144
+ <<~HELP
145
+ Usage: #{COMMAND_USAGE.fetch(:dfu_app)}
146
+ #{COMMAND_USAGE.fetch(:dfu_compile)}
147
+ #{COMMAND_USAGE.fetch(:dfu_status)}
148
+
149
+ Stages and inspects PicoModem DFU applications. Run `rpremote dfu SUBCOMMAND --help` for details.
150
+ HELP
151
+ end
152
+
153
+ def self.dfu_app_text
154
+ <<~HELP
155
+ Usage: #{COMMAND_USAGE.fetch(:dfu_app)}
156
+
157
+ Transfers a Ruby source or matching RITE bytecode application to the inactive DFU slot. The next R2P2 restart tries it.
158
+ Options default to the configured or automatically selected CDC 0 port, 115200 baud, and 20 seconds.
159
+ The transfer changes the staged boot application; use `rpremote reset` to restart and require DFU.confirm after a successful boot.
160
+ HELP
161
+ end
162
+
163
+ def self.dfu_compile_text
164
+ <<~HELP
165
+ Usage: #{COMMAND_USAGE.fetch(:dfu_compile)}
166
+
167
+ Compiles Ruby source to bytecode that matches the selected PicoRuby version. The output defaults beside FILE.
168
+ It requires prepared PicoRuby source and does not connect to a board.
169
+ HELP
170
+ end
171
+
172
+ def self.dfu_status_text
173
+ <<~HELP
174
+ Usage: #{COMMAND_USAGE.fetch(:dfu_status)}
175
+
176
+ Prints the active and candidate DFU A/B slots. Defaults are the configured or automatically selected CDC 0 port,
177
+ 115200 baud, and 20 seconds. This command reads board state without changing it.
178
+ HELP
179
+ end
180
+
181
+ def self.mrbgems_text(subcommand)
182
+ action = subcommand || "check|list|lock|update"
183
+ <<~HELP
184
+ Usage: #{COMMAND_USAGE.fetch(:mrbgems).sub("SUBCOMMAND", action)}
185
+
186
+ Checks or lists build dependencies, writes a reproducible lock file, or updates locked GitHub commits.
187
+ `check` and `list` are read-only. `lock` writes Mrbgems.lock; `update` resolves new commits and rewrites it.
188
+ HELP
189
+ end
190
+
191
+ def self.flash_text
192
+ <<~HELP
193
+ Usage: #{COMMAND_USAGE.fetch(:flash)}
194
+
195
+ Copies the selected UF2 to an RP2350 BOOTSEL volume and waits for R2P2 to reconnect. It replaces persistent board firmware.
196
+ Defaults select pico2, firmware/picoruby-4.0.3-pico2.uf2, an automatic BOOTSEL mount and CDC 0 port, and 20 seconds.
197
+ HELP
198
+ end
199
+
200
+ def self.config_text(subcommand)
201
+ unless subcommand == "show"
202
+ return "Usage: #{COMMAND_USAGE.fetch(:config_show)}\n\nUse `rpremote config show --help` for the supported overrides.\n"
203
+ end
204
+
205
+ <<~HELP
206
+ Usage: #{COMMAND_USAGE.fetch(:config_show)}
207
+
208
+ Prints the configuration file, defaults, and command-line overrides after resolution. It does not connect to a board or change state.
209
+ HELP
210
+ end
211
+
212
+ def self.ports_text
213
+ "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"
214
+ end
215
+
216
+ def self.run_text
217
+ <<~HELP
218
+ Usage: #{COMMAND_USAGE.fetch(:run)}
219
+
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.
222
+ HELP
223
+ end
224
+
225
+ def self.exec_text
226
+ <<~HELP
227
+ Usage: #{COMMAND_USAGE.fetch(:exec)}
228
+
229
+ 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.
231
+ HELP
232
+ end
233
+
234
+ def self.monitor_text
235
+ <<~HELP
236
+ Usage: #{COMMAND_USAGE.fetch(:monitor)}
237
+
238
+ Opens a serial monitor for the selected CDC 0 port. Defaults are 115200 baud and 20 seconds; exit with Ctrl-].
239
+ HELP
240
+ end
241
+
242
+ def self.repl_text
243
+ <<~HELP
244
+ Usage: #{COMMAND_USAGE.fetch(:repl)}
245
+
246
+ Opens PicoIRB on the selected CDC 0 port. Defaults are 115200 baud and 20 seconds; exit with Ctrl-].
247
+ HELP
248
+ end
249
+
250
+ def self.reset_text
251
+ <<~HELP
252
+ Usage: #{COMMAND_USAGE.fetch(:reset)}
253
+
254
+ Reboots R2P2 and waits for reconnection. Defaults are the automatic CDC 0 port, 115200 baud, and 20 seconds.
255
+ HELP
256
+ end
257
+
258
+ def self.fs_text(subcommand)
259
+ return fs_subcommand_text(subcommand) if %w[cp cat ls rm mkdir].include?(subcommand)
260
+
261
+ <<~HELP
262
+ Usage: #{COMMAND_USAGE.fetch(:fs_cp)}
263
+ #{COMMAND_USAGE.fetch(:fs_cat)}
264
+ #{COMMAND_USAGE.fetch(:fs_ls)}
265
+ #{COMMAND_USAGE.fetch(:fs_rm)}
266
+ #{COMMAND_USAGE.fetch(:fs_mkdir)}
267
+
268
+ Uses :/REMOTE/PATH for R2P2 paths. Run `rpremote fs SUBCOMMAND --help` for details.
269
+ HELP
270
+ end
271
+
272
+ def self.fs_subcommand_text(subcommand)
273
+ usage = COMMAND_USAGE.fetch(:"fs_#{subcommand}")
274
+ effect = case subcommand
275
+ when "rm" then "Deletes the remote path permanently."
276
+ when "mkdir" then "Creates a remote directory."
277
+ else "Reads or transfers remote files."
278
+ end
279
+ <<~HELP
280
+ Usage: #{usage}
281
+
282
+ #{effect} Defaults are the automatic CDC 0 port, 115200 baud, and 20 seconds.
283
+ HELP
284
+ end
59
285
  end
60
286
  end
287
+ # 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"
@@ -24,7 +24,7 @@ module Rpremote
24
24
  READY = 0x01
25
25
 
26
26
  CHUNK_SIZE = 480
27
- DEFAULT_TIMEOUT = 10.0
27
+ DEFAULT_TIMEOUT = 20.0
28
28
  MAX_FRAME_BODY = 65_535
29
29
  DFU_MAGIC = "DFU\0".b
30
30
  DFU_VERSION = 1
@@ -87,9 +87,7 @@ module Rpremote
87
87
  when DONE_ACK
88
88
  status, remote_crc = parse_completion(payload)
89
89
  raise DeviceError, format("download failed with status 0x%02x", status) unless status == OK
90
- if total && data.bytesize != total
91
- raise ProtocolError, "file size mismatch: expected #{total}, received #{data.bytesize}"
92
- end
90
+ raise ProtocolError, "file size mismatch: expected #{total}, received #{data.bytesize}" if total && data.bytesize != total
93
91
 
94
92
  local_crc = Checksum.crc32(data)
95
93
  raise ChecksumError, checksum_mismatch("CRC32", local_crc, remote_crc, 8) unless remote_crc == local_crc
@@ -194,9 +192,7 @@ module Rpremote
194
192
  def expect_status(expected_command, expected_status, context)
195
193
  command, payload = receive_frame
196
194
  raise DeviceError, payload.force_encoding(Encoding::UTF_8) if command == ERROR
197
- unless command == expected_command
198
- raise ProtocolError, "unexpected response #{hex(command, 2)} while waiting for #{context}"
199
- end
195
+ raise ProtocolError, "unexpected response #{hex(command, 2)} while waiting for #{context}" unless command == expected_command
200
196
  raise ProtocolError, "#{context} has no status" if payload.empty?
201
197
  return if payload.getbyte(0) == expected_status
202
198
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ class PicoRubySourcePatch
5
+ JOB_PATH = "mrbgems/picoruby-shell/mrblib/job.rb"
6
+ PATCH_ALIASES = { "3.4.2" => "3.4.5" }.freeze
7
+
8
+ class Error < Rpremote::Error; end
9
+
10
+ attr_reader :version
11
+
12
+ def initialize(version:)
13
+ @version = version
14
+ end
15
+
16
+ def apply(source)
17
+ 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
+
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)
24
+ end
25
+
26
+ private
27
+
28
+ def git_apply?(source, patch, *options)
29
+ system("git", "apply", *options, patch, chdir: source, out: File::NULL, err: File::NULL)
30
+ end
31
+ end
32
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rpremote
4
4
  class Resetter
5
- DEFAULT_TIMEOUT = 10.0
5
+ DEFAULT_TIMEOUT = 20.0
6
6
  RETRY_INTERVAL = 0.1
7
7
  PROBE_TIMEOUT = 1.0
8
8
 
@@ -20,14 +20,14 @@ module Rpremote
20
20
  @path_factory = path_factory || method(:temporary_path)
21
21
  end
22
22
 
23
- def run(data)
23
+ def run(data, output: nil)
24
24
  remote_path = @path_factory.call
25
25
  shell = @shell_class.new(io, timeout: timeout)
26
26
  shell.synchronize!
27
27
  @modem_class.new(io, timeout: timeout).upload(remote_path, data)
28
28
  shell.synchronize!
29
29
  shell_ready = true
30
- shell.execute("./#{File.basename(remote_path)}")
30
+ output ? shell.execute("./#{File.basename(remote_path)}", output: output) : shell.execute("./#{File.basename(remote_path)}")
31
31
  ensure
32
32
  cleanup(shell, remote_path) if shell_ready && remote_path
33
33
  end
@@ -4,13 +4,14 @@ require "io/wait"
4
4
 
5
5
  module Rpremote
6
6
  class Shell
7
- DEFAULT_TIMEOUT = 10.0
7
+ DEFAULT_TIMEOUT = 20.0
8
8
  PROMPT_START = "\e[?25l\e[1G$> \e[0K".b
9
9
  PROMPT_END = "\e[?25h".b
10
10
  ECHO_START = "\e[?25l\e[1G$> ".b
11
11
  ERASE_LINE = "\e[0K".b
12
12
  CURSOR_POSITION_QUERY = "\e[6n".b
13
13
  CURSOR_POSITION_RESPONSE = "\e[1;1R".b
14
+ RUBY_EXCEPTION_STATUS = "\x1eR2P2:RUBY_EXCEPTION\x1f".b
14
15
 
15
16
  class Error < Rpremote::Error; end
16
17
  class TimeoutError < Error; end
@@ -42,11 +43,18 @@ module Rpremote
42
43
  nil
43
44
  end
44
45
 
45
- def execute(command)
46
+ def execute(command, output: nil)
46
47
  command = validate_command(command)
47
48
  send_command(command)
48
- response = read_until_prompt(after: ECHO_START + command.b + ERASE_LINE)
49
- extract_output(response, command)
49
+ echo = ECHO_START + command.b + ERASE_LINE
50
+ stream_state = {}
51
+ response = read_until_prompt(after: echo) { |buffer| stream_output(output, buffer, echo, stream_state) }
52
+ command_output = extract_output(response, command)
53
+ ruby_exception = command_output.include?(RUBY_EXCEPTION_STATUS)
54
+ command_output = remove_ruby_exception_status(command_output)
55
+ raise CommandError, "Ruby exception reported by R2P2" if ruby_exception
56
+
57
+ command_output
50
58
  rescue TimeoutError
51
59
  interrupt
52
60
  raise
@@ -78,6 +86,7 @@ module Rpremote
78
86
 
79
87
  buffer << read_available(deadline)
80
88
  answer_terminal_queries(buffer)
89
+ yield buffer if block_given?
81
90
  end
82
91
  end
83
92
 
@@ -95,6 +104,30 @@ module Rpremote
95
104
  response.byteslice((output_at + 1)...prompt_at) || +"".b
96
105
  end
97
106
 
107
+ def stream_output(output, response, echo, state)
108
+ return unless output
109
+
110
+ echo_at = response.rindex(echo)
111
+ return unless echo_at
112
+
113
+ output_at = response.index("\n", echo_at + echo.bytesize)
114
+ return unless output_at
115
+
116
+ output_at += 1
117
+ prompt_at = response.index(PROMPT_START, output_at)
118
+ safe_end = prompt_at || (response.rindex("\n", response.bytesize - 1).to_i + 1)
119
+ position = state.fetch(:position, output_at)
120
+ return if safe_end <= position
121
+
122
+ output.write(remove_ruby_exception_status(response.byteslice(position...safe_end)))
123
+ output.flush if output.respond_to?(:flush)
124
+ state[:position] = safe_end
125
+ end
126
+
127
+ def remove_ruby_exception_status(data)
128
+ data.gsub(RUBY_EXCEPTION_STATUS, "")
129
+ end
130
+
98
131
  def drain_input
99
132
  deadline = monotonic_time + 0.1
100
133
  buffer = +"".b
@@ -114,9 +147,7 @@ module Rpremote
114
147
  def read_available(deadline)
115
148
  remaining = deadline - monotonic_time
116
149
  raise TimeoutError, "timed out waiting for the R2P2 Shell after #{timeout} seconds" unless remaining.positive?
117
- unless selectable_io.wait_readable(remaining)
118
- raise TimeoutError, "timed out waiting for the R2P2 Shell after #{timeout} seconds"
119
- end
150
+ raise TimeoutError, "timed out waiting for the R2P2 Shell after #{timeout} seconds" unless selectable_io.wait_readable(remaining)
120
151
 
121
152
  data = io.read_nonblock(4096)
122
153
  raise IOError, "serial connection closed" if data.nil? || data.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rpremote
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/rpremote.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "rpremote/terminal"
19
19
  require_relative "rpremote/shell"
20
20
  require_relative "rpremote/runner"
21
21
  require_relative "rpremote/resetter"
22
+ require_relative "rpremote/picoruby_source_patch"
22
23
  require_relative "rpremote/language_source"
23
24
  require_relative "rpremote/mrbgems"
24
25
  require_relative "rpremote/mrbgems_command"