rpremote 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.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "build_command"
5
+ require_relative "dfu_command"
6
+ require_relative "flash_command"
7
+ require_relative "help"
8
+ require_relative "mrbgems_command"
9
+ require_relative "setup_command"
10
+
11
+ module Rpremote
12
+ class CLI
13
+ def self.start(argv = ARGV, config: Config, **options)
14
+ new(**options).run(argv, config: config)
15
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError, Rpremote::Error => e
16
+ options.fetch(:stderr, $stderr).puts("rpremote: #{e.message}")
17
+ 1
18
+ end
19
+
20
+ def initialize(
21
+ stdout: $stdout,
22
+ stderr: $stderr,
23
+ serial: Serial,
24
+ device: Device,
25
+ flasher: Flasher
26
+ )
27
+ @stdout = stdout
28
+ @stderr = stderr
29
+ @serial = serial
30
+ @device = device
31
+ @flasher = flasher
32
+ end
33
+
34
+ def run(argv, config: Config)
35
+ args = argv.dup
36
+ @config_class = config
37
+ @config_filename, config_required = config.extract_option!(args)
38
+ command = args.shift
39
+ @config_options = config.load_command(command, filename: config_filename, required: config_required)
40
+ dispatch(command, args)
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :stdout, :stderr, :serial, :device, :flasher,
47
+ :config_class, :config_filename, :config_options
48
+
49
+ def dispatch(command, args)
50
+ case command
51
+ when nil, "help", "--help", "-h"
52
+ stdout.puts(Help::TEXT)
53
+ when "--version", "-V"
54
+ stdout.puts(Rpremote::VERSION)
55
+ when "ports"
56
+ ports(args)
57
+ when "setup", "flash", "build", "dfu", "mrbgems"
58
+ project_command(command, args)
59
+ when "run", "monitor", "repl", "exec", "reset", "fs"
60
+ remote_command(command, args)
61
+ else
62
+ raise ArgumentError, "unknown command: #{command}"
63
+ end
64
+ end
65
+
66
+ def project_command(command, args)
67
+ case command
68
+ when "setup"
69
+ SetupCommand.run(
70
+ args,
71
+ defaults: config_options,
72
+ config: config_class,
73
+ config_filename: config_filename,
74
+ output: stdout
75
+ )
76
+ when "flash"
77
+ FlashCommand.run(args, defaults: config_options, output: stdout, flasher: flasher)
78
+ when "build"
79
+ BuildCommand.run(args, defaults: config_options, output: stdout, error: stderr)
80
+ when "dfu"
81
+ DfuCommand.run(
82
+ args, defaults: config_options, output: stdout, services: { serial: serial, device: device }
83
+ )
84
+ when "mrbgems"
85
+ MrbgemsCommand.run(args, output: stdout)
86
+ end
87
+ end
88
+
89
+ def remote_command(command, args)
90
+ case command
91
+ when "run" then run_file(args)
92
+ when "monitor" then interactive(args)
93
+ when "repl" then interactive(args, repl: true)
94
+ when "exec" then execute_code(args)
95
+ when "reset" then reset(args)
96
+ when "fs" then fs(args)
97
+ end
98
+ end
99
+
100
+ def ports(args)
101
+ raise ArgumentError, "ports does not accept arguments" unless args.empty?
102
+
103
+ found = device.ports
104
+ if found.empty?
105
+ stdout.puts("No R2P2 serial ports found.")
106
+ else
107
+ found.each { |path| stdout.puts(path) }
108
+ end
109
+ end
110
+
111
+ def fs(args)
112
+ subcommand = args.shift
113
+ options = parse_connection_options(args)
114
+ case subcommand
115
+ when "cp"
116
+ copy(args, options)
117
+ when "cat"
118
+ cat(args, options)
119
+ when "ls", "rm", "mkdir"
120
+ filesystem_shell_command(subcommand, args, options)
121
+ else
122
+ raise ArgumentError, "unknown fs command: #{subcommand || "(none)"}"
123
+ end
124
+ end
125
+
126
+ def run_file(args)
127
+ options = parse_connection_options(args)
128
+ raise ArgumentError, "usage: rpremote run FILE [options]" unless args.length == 1
129
+
130
+ source = args.first
131
+ data = File.binread(source)
132
+ stdout.write(execute_temporary(data, options))
133
+ rescue Errno::ENOENT => e
134
+ raise ArgumentError, e.message
135
+ end
136
+
137
+ def execute_code(args)
138
+ options = parse_connection_options(args)
139
+ raise ArgumentError, "usage: rpremote exec CODE [options]" unless args.length == 1
140
+
141
+ stdout.write(execute_temporary(args.first, options))
142
+ end
143
+
144
+ def execute_temporary(data, options)
145
+ Language.validate!(options[:language])
146
+ port_path = device.main_port(options[:port])
147
+ serial.open(port_path, baud: options[:baud]) do |port|
148
+ Runner.new(port, timeout: options[:timeout]).run(data)
149
+ end
150
+ end
151
+
152
+ def interactive(args, repl: false)
153
+ options = parse_connection_options(args)
154
+ command = repl ? "repl" : "monitor"
155
+ raise ArgumentError, "#{command} does not accept arguments" unless args.empty?
156
+
157
+ port_path = device.main_port(options[:port])
158
+ stderr.puts("connected: #{port_path} at #{options[:baud]} baud (Ctrl-] to exit)")
159
+ serial.open(port_path, baud: options[:baud]) do |port|
160
+ if repl
161
+ shell = Shell.new(port, timeout: options[:timeout])
162
+ shell.synchronize!
163
+ shell.send_command("irb")
164
+ end
165
+ Terminal.new(port, output: stdout).run(exit_sequence: repl ? "\x03\x04".b : nil)
166
+ end
167
+ end
168
+
169
+ def reset(args)
170
+ options = parse_connection_options(args, default_timeout: Resetter::DEFAULT_TIMEOUT)
171
+ raise ArgumentError, "reset does not accept arguments" unless args.empty?
172
+
173
+ port_path = device.main_port(options[:port])
174
+ Resetter.new(serial: serial, timeout: options[:timeout]).reset(port_path, baud: options[:baud])
175
+ stdout.puts("reset R2P2: #{port_path}")
176
+ end
177
+
178
+ def parse_connection_options(args, default_timeout: PicoModem::DEFAULT_TIMEOUT)
179
+ options = {
180
+ port: config_options[:port],
181
+ baud: config_options.fetch(:baud, Serial::BAUD_RATE),
182
+ timeout: config_options.fetch(:timeout, default_timeout),
183
+ language: config_options.fetch(:language, Target::DEFAULT_LANGUAGE)
184
+ }
185
+ parser = OptionParser.new do |opts|
186
+ opts.on("--port PORT") { |value| options[:port] = value }
187
+ opts.on("--baud RATE", Integer) { |value| options[:baud] = value }
188
+ opts.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
189
+ opts.on("--language LANGUAGE") { |value| options[:language] = value }
190
+ end
191
+ parser.parse!(args)
192
+ raise ArgumentError, "--baud must be positive" unless options[:baud].positive?
193
+ raise ArgumentError, "--timeout must be positive" unless options[:timeout].positive?
194
+
195
+ options
196
+ end
197
+
198
+ def copy(args, options)
199
+ raise ArgumentError, "usage: rpremote fs cp SOURCE DESTINATION [options]" unless args.length == 2
200
+
201
+ source, destination = args
202
+ source_remote = RemotePath.remote?(source)
203
+ destination_remote = RemotePath.remote?(destination)
204
+ if source_remote == destination_remote
205
+ raise ArgumentError, "exactly one cp path must be remote (prefix remote paths with :)"
206
+ end
207
+
208
+ if source_remote
209
+ data = with_modem(options) { |modem| modem.download(RemotePath.unwrap(source)) }
210
+ File.binwrite(local_destination(destination, source), data)
211
+ stdout.puts("downloaded #{data.bytesize} bytes: #{source} -> #{destination}")
212
+ else
213
+ data = File.binread(source)
214
+ with_modem(options) { |modem| modem.upload(RemotePath.unwrap(destination), data) }
215
+ stdout.puts("uploaded #{data.bytesize} bytes: #{source} -> #{destination}")
216
+ end
217
+ rescue Errno::ENOENT => e
218
+ raise ArgumentError, e.message
219
+ end
220
+
221
+ def cat(args, options)
222
+ raise ArgumentError, "usage: rpremote fs cat :/REMOTE/PATH [options]" unless args.length == 1
223
+ raise ArgumentError, "cat path must be remote (prefix it with :)" unless RemotePath.remote?(args.first)
224
+
225
+ stdout.write(with_modem(options) { |modem| modem.download(RemotePath.unwrap(args.first)) })
226
+ end
227
+
228
+ def filesystem_shell_command(command, args, options)
229
+ usage = "usage: rpremote fs #{command} :/REMOTE/PATH [options]"
230
+ raise ArgumentError, usage unless args.length == 1
231
+
232
+ path = RemotePath.validate(args.first)
233
+ output = with_shell(options) do |shell|
234
+ shell.execute("#{command} #{Shell.quote_argument(path)}")
235
+ end
236
+ ensure_filesystem_success!(command, output)
237
+ stdout.write(output)
238
+ end
239
+
240
+ def ensure_filesystem_success!(command, output)
241
+ failed = command == "ls" ? output.start_with?("ls:") : !output.empty?
242
+ raise Shell::CommandError, output.strip if failed
243
+ end
244
+
245
+ def with_modem(options)
246
+ port_path = device.main_port(options[:port])
247
+ serial.open(port_path, baud: options[:baud]) do |port|
248
+ yield PicoModem.new(port, timeout: options[:timeout])
249
+ end
250
+ end
251
+
252
+ def with_shell(options)
253
+ port_path = device.main_port(options[:port])
254
+ serial.open(port_path, baud: options[:baud]) do |port|
255
+ shell = Shell.new(port, timeout: options[:timeout])
256
+ shell.synchronize!
257
+ yield shell
258
+ end
259
+ end
260
+
261
+ def local_destination(destination, source)
262
+ return destination unless File.directory?(destination)
263
+
264
+ File.join(destination, File.basename(RemotePath.unwrap(source)))
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Rpremote
7
+ class Config
8
+ DEFAULT_PATH = "config/setting.json"
9
+ OPTION_TYPES = {
10
+ port: String,
11
+ mount: String,
12
+ cache: String,
13
+ language_version: String,
14
+ board: String,
15
+ firmware: String,
16
+ baud: Integer,
17
+ timeout: Numeric,
18
+ language: String,
19
+ mrbgems: [String, FalseClass].freeze
20
+ }.freeze
21
+ COMMAND_OPTIONS = {
22
+ "setup" => %i[cache language language_version],
23
+ "build" => %i[cache language language_version board firmware mrbgems],
24
+ "dfu" => %i[cache language language_version port baud timeout],
25
+ "flash" => %i[cache language language_version board firmware mount port timeout],
26
+ "run" => %i[port baud timeout language],
27
+ "exec" => %i[port baud timeout language],
28
+ "reset" => %i[port baud timeout],
29
+ "monitor" => %i[port baud timeout],
30
+ "repl" => %i[port baud timeout],
31
+ "fs" => %i[port baud timeout],
32
+ "ports" => [],
33
+ "mrbgems" => []
34
+ }.freeze
35
+
36
+ Result = Data.define(:path, :created)
37
+
38
+ class Error < Rpremote::Error; end
39
+
40
+ def self.extract_option!(args)
41
+ filename = nil
42
+ index = 0
43
+ while index < args.length
44
+ argument = args[index]
45
+ break if argument == "--"
46
+
47
+ value, consumed = config_argument(args, index)
48
+ unless consumed
49
+ index += 1
50
+ next
51
+ end
52
+ raise Error, "--config specified more than once" if filename
53
+ raise Error, "--config requires a value" if value.nil? || value.empty?
54
+
55
+ filename = value
56
+ args.slice!(index, consumed)
57
+ end
58
+ [filename || DEFAULT_PATH, !filename.nil?]
59
+ end
60
+
61
+ def self.load_command(command, filename:, required:)
62
+ return {} if command.nil? || %w[help --help -h --version -V].include?(command)
63
+ return {} unless COMMAND_OPTIONS.key?(command)
64
+
65
+ load(command, filename: filename, required: required && command != "setup")
66
+ end
67
+
68
+ def self.load(command, filename: DEFAULT_PATH, required: false, cwd: Dir.pwd)
69
+ path = File.expand_path(filename, cwd)
70
+ text = File.read(path)
71
+ values = parse(text, path)
72
+ validate!(values)
73
+ values.slice(*COMMAND_OPTIONS.fetch(command, []))
74
+ rescue Errno::ENOENT
75
+ raise Error, "config file does not exist: #{path}" if required
76
+
77
+ {}
78
+ end
79
+
80
+ def self.setup(filename: DEFAULT_PATH, cwd: Dir.pwd)
81
+ path = File.expand_path(filename, cwd)
82
+ FileUtils.mkdir_p(File.dirname(path))
83
+ File.open(path, "wx") { |file| file.write("{}\n") }
84
+ Result.new(path: path, created: true)
85
+ rescue Errno::EEXIST
86
+ Result.new(path: path, created: false)
87
+ rescue SystemCallError => e
88
+ raise Error, "cannot create config file #{path}: #{e.message}"
89
+ end
90
+
91
+ def self.parse(text, path)
92
+ parsed = JSON.parse(text, symbolize_names: true)
93
+ raise Error, "config file must contain a JSON object: #{path}" unless parsed.is_a?(Hash)
94
+
95
+ parsed
96
+ rescue JSON::ParserError => e
97
+ raise Error, "invalid config file #{path}: #{e.message}"
98
+ end
99
+ private_class_method :parse
100
+
101
+ def self.validate!(values)
102
+ values.each do |key, value|
103
+ expected = OPTION_TYPES[key]
104
+ raise Error, "unknown config option: #{key}" unless expected
105
+ raise Error, "config option #{key} must be a #{type_name(expected)}" unless valid_value?(value, expected)
106
+ next unless %i[baud timeout].include?(key)
107
+ next if value.positive?
108
+
109
+ raise Error, "config option #{key} must be positive"
110
+ end
111
+ end
112
+ private_class_method :validate!
113
+
114
+ def self.valid_value?(value, expected)
115
+ types = Array(expected)
116
+ types.any? { |type| value.is_a?(type) } && (!value.is_a?(String) || !value.empty?)
117
+ end
118
+ private_class_method :valid_value?
119
+
120
+ def self.type_name(type)
121
+ return type.map { |item| type_name(item) }.join(" or ") if type.is_a?(Array)
122
+ return "false" if type == FalseClass
123
+
124
+ type == Numeric ? "number" : type.name.downcase
125
+ end
126
+ private_class_method :type_name
127
+
128
+ def self.config_argument(args, index)
129
+ argument = args[index]
130
+ return [args[index + 1], 2] if argument == "--config"
131
+ return [argument.delete_prefix("--config="), 1] if argument.start_with?("--config=")
132
+
133
+ [nil, nil]
134
+ end
135
+ private_class_method :config_argument
136
+ end
137
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ class Device
5
+ GLOBS = ["/dev/cu.usbmodem*", "/dev/tty.usbmodem*"].freeze
6
+
7
+ class NotFoundError < Rpremote::Error; end
8
+ class MultipleDevicesError < Rpremote::Error; end
9
+
10
+ class << self
11
+ def ports
12
+ GLOBS.flat_map { |pattern| Dir.glob(pattern) }.sort
13
+ end
14
+
15
+ def main_port(explicit_port = nil, available_ports: ports)
16
+ return validate_explicit_port(explicit_port) if explicit_port
17
+
18
+ callout_ports = available_ports.grep(%r{\A/dev/cu\.})
19
+ candidates = callout_ports.empty? ? available_ports : callout_ports
20
+ candidates = candidates.select { |path| cdc_zero?(path, candidates) }
21
+
22
+ case candidates.length
23
+ when 0
24
+ raise NotFoundError, "R2P2 serial port not found; connect the Pico 2 or use --port PORT"
25
+ when 1
26
+ candidates.first
27
+ else
28
+ raise MultipleDevicesError,
29
+ "multiple R2P2 devices found; use --port PORT (#{candidates.join(", ")})"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def validate_explicit_port(path)
36
+ raise NotFoundError, "serial port not found: #{path}" unless File.exist?(path)
37
+
38
+ path
39
+ end
40
+
41
+ # PicoRuby 3.4.x exposes two CDC interfaces and 4.x exposes three.
42
+ # macOS numbers the interfaces with trailing 1, 3, and (when present) 5.
43
+ # CDC 0 is therefore the first (trailing 1) interface in each USB group.
44
+ def cdc_zero?(path, available_ports)
45
+ basename = File.basename(path)
46
+ siblings = ports_for_group(path, available_ports)
47
+ siblings.length >= 2 && basename.end_with?("1")
48
+ end
49
+
50
+ def ports_for_group(path, available_ports)
51
+ prefix = path.sub(/[135]\z/, "")
52
+ available_ports.select { |candidate| candidate.sub(/[135]\z/, "") == prefix }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rpremote
6
+ class DfuCommand
7
+ DEFAULT_TIMEOUT = 10.0
8
+ TYPES = { ".rb" => "RUBY", ".mrb" => "RITE" }.freeze
9
+
10
+ def self.run(args, defaults:, output: $stdout, services: {})
11
+ default_services = { serial: Serial, device: Device, modem: PicoModem, runner: Runner, compiler: DfuCompiler }
12
+ services = default_services.merge(services)
13
+ subcommand = args.shift
14
+ case subcommand
15
+ when "app" then stage(args, defaults, output, services)
16
+ when "compile" then compile(args, defaults, output, services[:compiler])
17
+ when "status" then status(args, defaults, output, services)
18
+ else
19
+ raise ArgumentError,
20
+ "usage: rpremote dfu app FILE [options], dfu compile FILE [options], or dfu status [options]"
21
+ end
22
+ end
23
+
24
+ def self.stage(args, defaults, output, services)
25
+ options = parse_app_options(args, defaults)
26
+ raise ArgumentError, "usage: rpremote dfu app FILE [options]" unless args.length == 1
27
+
28
+ source = args.first
29
+ data = read_source(source)
30
+ type = options[:type] || type_for(source)
31
+ port_path = services[:device].main_port(options[:port])
32
+ verify_rite_version!(data, port_path, options, services) if type == "RITE"
33
+ services[:serial].open(port_path, baud: options[:baud]) do |port|
34
+ services[:modem].new(port, timeout: options[:timeout]).dfu(data, type: type)
35
+ end
36
+ output.puts("staged DFU #{type.downcase} app (#{data.bytesize} bytes): #{source}")
37
+ output.puts("restart R2P2 to activate it; the app must call DFU.confirm after a successful boot")
38
+ end
39
+ private_class_method :stage
40
+
41
+ def self.compile(args, defaults, output, compiler)
42
+ options = parse_compile_options(args, defaults)
43
+ raise ArgumentError, "usage: rpremote dfu compile FILE [options]" unless args.length == 1
44
+
45
+ target = Target.new(
46
+ language: options[:language], language_version: options[:language_version], cache_dir: options[:cache]
47
+ )
48
+ compiler.compile(args.first, target: target, destination: options[:output], output: output)
49
+ end
50
+ private_class_method :compile
51
+
52
+ def self.status(args, defaults, output, services)
53
+ options = parse_app_options(args, defaults)
54
+ raise ArgumentError, "usage: rpremote dfu status [options]" unless args.empty?
55
+
56
+ port_path = services[:device].main_port(options[:port])
57
+ code = <<~'RUBY'
58
+ require "dfu"
59
+ status = DFU.status
60
+ puts "active_slot=#{status["active_slot"]}"
61
+ puts "try_slot=#{status["try_slot"]}"
62
+ puts "boot_count=#{status["boot_count"]}/#{status["max_boot_attempts"]}"
63
+ %w[a b].each { |slot| item = status["slot_#{slot}"]; puts "slot_#{slot}=#{item["state"]} #{item["ext"] || "-"}" }
64
+ RUBY
65
+ result = services[:serial].open(port_path, baud: options[:baud]) do |port|
66
+ services[:runner].new(port, timeout: options[:timeout]).run(code)
67
+ end
68
+ output.write(result)
69
+ end
70
+ private_class_method :status
71
+
72
+ def self.parse_app_options(args, defaults)
73
+ options = {
74
+ port: defaults[:port],
75
+ baud: defaults.fetch(:baud, Serial::BAUD_RATE),
76
+ timeout: defaults.fetch(:timeout, DEFAULT_TIMEOUT),
77
+ type: nil
78
+ }
79
+ OptionParser.new do |parser|
80
+ parser.on("--type TYPE", %w[ruby rite]) { |value| options[:type] = value.upcase }
81
+ parser.on("--port PORT") { |value| options[:port] = value }
82
+ parser.on("--baud RATE", Integer) { |value| options[:baud] = value }
83
+ parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
84
+ end.parse!(args)
85
+ raise ArgumentError, "--baud must be positive" unless options[:baud].positive?
86
+ raise ArgumentError, "--timeout must be positive" unless options[:timeout].positive?
87
+
88
+ options
89
+ end
90
+ private_class_method :parse_app_options
91
+
92
+ def self.parse_compile_options(args, defaults)
93
+ options = {
94
+ language: defaults.fetch(:language, Target::DEFAULT_LANGUAGE),
95
+ language_version: defaults.fetch(:language_version, Target::DEFAULT_LANGUAGE_VERSION),
96
+ cache: defaults.fetch(:cache, Target::DEFAULT_CACHE_DIR),
97
+ output: nil
98
+ }
99
+ OptionParser.new do |parser|
100
+ parser.on("--output FILE") { |value| options[:output] = value }
101
+ parser.on("--language LANGUAGE") { |value| options[:language] = value }
102
+ parser.on("--language-version VERSION") { |value| options[:language_version] = value }
103
+ parser.on("--cache DIR") { |value| options[:cache] = value }
104
+ end.parse!(args)
105
+ options
106
+ end
107
+ private_class_method :parse_compile_options
108
+
109
+ def self.type_for(path)
110
+ TYPES.fetch(File.extname(path).downcase) do
111
+ raise ArgumentError, "cannot infer DFU type from #{path}; use --type ruby or --type rite"
112
+ end
113
+ end
114
+ private_class_method :type_for
115
+
116
+ def self.read_source(source)
117
+ File.binread(source)
118
+ rescue Errno::ENOENT
119
+ raise ArgumentError, "DFU app file not found: #{source} (current directory: #{Dir.pwd})"
120
+ end
121
+ private_class_method :read_source
122
+
123
+ def self.verify_rite_version!(data, port_path, options, services)
124
+ 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
128
+
129
+ picoruby_version = services[:serial].open(port_path, baud: options[:baud]) do |port|
130
+ services[:runner].new(port, timeout: options[:timeout]).run("p PICORUBY_VERSION")[/\d+\.\d+\.\d+/]
131
+ end
132
+ unless picoruby_version
133
+ raise Rpremote::Error,
134
+ "connected R2P2 did not report PICORUBY_VERSION; use a .rb app or update R2P2"
135
+ end
136
+ device_version = rite_version_for(picoruby_version)
137
+ return if bytecode_version == device_version
138
+
139
+ raise Rpremote::Error,
140
+ "RITE bytecode version #{bytecode_version} does not match connected R2P2 #{device_version}; " \
141
+ "compile with rpremote dfu compile using the installed PicoRuby version"
142
+ end
143
+ private_class_method :verify_rite_version!
144
+
145
+ def self.rite_version_for(picoruby_version)
146
+ case picoruby_version.split(".").first
147
+ when "3" then "RITE0300"
148
+ when "4" then "RITE0400"
149
+ else
150
+ raise Rpremote::Error, "unsupported connected PicoRuby version: #{picoruby_version}"
151
+ end
152
+ end
153
+ private_class_method :rite_version_for
154
+ end
155
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rpremote
6
+ class DfuCompiler
7
+ class Error < Rpremote::Error; end
8
+
9
+ def self.compile(source, target:, destination: nil, output: $stdout, runner: nil)
10
+ raise Error, "DFU bytecode compilation currently supports only picoruby" unless target.language == "picoruby"
11
+ raise ArgumentError, "DFU source must have a .rb extension" unless File.extname(source).downcase == ".rb"
12
+
13
+ source = File.expand_path(source)
14
+ raise ArgumentError, "DFU source file not found: #{source}" unless File.file?(source)
15
+
16
+ compiler = find_compiler(target.source_dir)
17
+ destination ||= source.sub(/\.rb\z/i, ".mrb")
18
+ destination = File.expand_path(destination)
19
+ FileUtils.mkdir_p(File.dirname(destination))
20
+ success = (runner || method(:run)).call(compiler, "-o", destination, source)
21
+ raise Error, "PicoRuby compiler failed: #{compiler}" unless success
22
+
23
+ rite_version = rite_version(File.binread(destination, 8))
24
+ output.puts("compiled #{rite_version} app: #{source} -> #{destination}")
25
+ destination
26
+ end
27
+
28
+ def self.find_compiler(source_dir)
29
+ %w[picorbc mrbc].each do |name|
30
+ path = File.join(source_dir, "build/host/bin", name)
31
+ return path if File.executable?(path)
32
+ end
33
+ raise Error, "PicoRuby compiler is not built in #{source_dir}; build the selected PicoRuby source first"
34
+ end
35
+ private_class_method :find_compiler
36
+
37
+ def self.run(*command)
38
+ system(*command)
39
+ end
40
+ private_class_method :run
41
+
42
+ def self.rite_version(data)
43
+ version = data.b.byteslice(0, 8)
44
+ return version if version&.match?(/\ARITE\d{4}\z/)
45
+
46
+ raise Error, "PicoRuby compiler did not produce a RITE bytecode file"
47
+ end
48
+ private_class_method :rite_version
49
+ end
50
+ end