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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.ja.md +122 -0
- data/README.md +124 -0
- data/RELEASING.md +54 -0
- data/THIRD_PARTY_NOTICES.md +29 -0
- data/exe/rpremote +7 -0
- data/lib/rpremote/build_command.rb +38 -0
- data/lib/rpremote/builder.rb +123 -0
- data/lib/rpremote/checksum.rb +27 -0
- data/lib/rpremote/cli.rb +267 -0
- data/lib/rpremote/config.rb +137 -0
- data/lib/rpremote/device.rb +56 -0
- data/lib/rpremote/dfu_command.rb +155 -0
- data/lib/rpremote/dfu_compiler.rb +50 -0
- data/lib/rpremote/flash_command.rb +45 -0
- data/lib/rpremote/flasher.rb +131 -0
- data/lib/rpremote/help.rb +60 -0
- data/lib/rpremote/language.rb +15 -0
- data/lib/rpremote/language_source.rb +126 -0
- data/lib/rpremote/mrbgems.rb +288 -0
- data/lib/rpremote/mrbgems_command.rb +48 -0
- data/lib/rpremote/picomodem.rb +293 -0
- data/lib/rpremote/remote_path.rb +27 -0
- data/lib/rpremote/resetter.rb +73 -0
- data/lib/rpremote/runner.rb +48 -0
- data/lib/rpremote/serial.rb +72 -0
- data/lib/rpremote/setup_command.rb +34 -0
- data/lib/rpremote/shell.rb +161 -0
- data/lib/rpremote/target.rb +46 -0
- data/lib/rpremote/terminal.rb +91 -0
- data/lib/rpremote/version.rb +5 -0
- data/lib/rpremote.rb +30 -0
- data/sig/rpremote.rbs +404 -0
- data/tasks/firmware_build.rb +108 -0
- data/tasks/release.rake +12 -0
- data/tasks/release_check.rb +83 -0
- metadata +82 -0
data/lib/rpremote/cli.rb
ADDED
|
@@ -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
|