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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "mrbgems"
|
|
5
|
+
|
|
6
|
+
module Rpremote
|
|
7
|
+
class MrbgemsCommand
|
|
8
|
+
DEFAULT_PATH = Mrbgems::DEFAULT_PATH
|
|
9
|
+
|
|
10
|
+
def self.run(args, output: $stdout, mrbgems: Mrbgems)
|
|
11
|
+
command = args.shift
|
|
12
|
+
options = { path: DEFAULT_PATH, lock_path: nil }
|
|
13
|
+
OptionParser.new do |opts|
|
|
14
|
+
opts.on("--file FILE") { |value| options[:path] = value }
|
|
15
|
+
opts.on("--lockfile FILE") { |value| options[:lock_path] = value }
|
|
16
|
+
end.parse!(args)
|
|
17
|
+
raise ArgumentError, "mrbgems does not accept arguments" unless args.empty?
|
|
18
|
+
|
|
19
|
+
manager = mrbgems.new(**options)
|
|
20
|
+
case command
|
|
21
|
+
when "check"
|
|
22
|
+
dependencies = manager.check
|
|
23
|
+
output.puts("checked #{dependencies.length} mrbgems: #{manager.path}")
|
|
24
|
+
when "list"
|
|
25
|
+
list(manager, output)
|
|
26
|
+
when "lock", "update"
|
|
27
|
+
result = manager.lock(update: command == "update")
|
|
28
|
+
output.puts("locked #{result.fetch("gems").length} mrbgems: #{manager.lock_path}")
|
|
29
|
+
else
|
|
30
|
+
raise ArgumentError, "unknown mrbgems command: #{command || "(none)"}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.list(manager, output)
|
|
35
|
+
dependencies = manager.check
|
|
36
|
+
lock = manager.read_lock(required: false)
|
|
37
|
+
entries = lock&.fetch("gems", []) || []
|
|
38
|
+
dependencies.each do |dependency|
|
|
39
|
+
entry = entries.find do |item|
|
|
40
|
+
item["type"] == dependency.type.to_s && item["source"] == dependency.source
|
|
41
|
+
end
|
|
42
|
+
suffix = entry && (entry["commit"] || entry["sha256"])
|
|
43
|
+
output.puts([dependency.type, dependency.source, suffix&.slice(0, 12)].compact.join(" "))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :list
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
|
|
5
|
+
module Rpremote
|
|
6
|
+
class PicoModem
|
|
7
|
+
STX = 0x02
|
|
8
|
+
ACK = 0x06
|
|
9
|
+
|
|
10
|
+
FILE_READ = 0x01
|
|
11
|
+
FILE_WRITE = 0x02
|
|
12
|
+
DFU_START = 0x03
|
|
13
|
+
CHUNK = 0x04
|
|
14
|
+
ABORT = 0xFF
|
|
15
|
+
|
|
16
|
+
FILE_DATA = 0x81
|
|
17
|
+
FILE_ACK = 0x82
|
|
18
|
+
DFU_ACK = 0x83
|
|
19
|
+
CHUNK_ACK = 0x84
|
|
20
|
+
DONE_ACK = 0x8F
|
|
21
|
+
ERROR = 0xFE
|
|
22
|
+
|
|
23
|
+
OK = 0x00
|
|
24
|
+
READY = 0x01
|
|
25
|
+
|
|
26
|
+
CHUNK_SIZE = 480
|
|
27
|
+
DEFAULT_TIMEOUT = 10.0
|
|
28
|
+
MAX_FRAME_BODY = 65_535
|
|
29
|
+
DFU_MAGIC = "DFU\0".b
|
|
30
|
+
DFU_VERSION = 1
|
|
31
|
+
DFU_TYPES = %w[RUBY RITE].freeze
|
|
32
|
+
|
|
33
|
+
class Error < Rpremote::Error; end
|
|
34
|
+
class TimeoutError < Error; end
|
|
35
|
+
class ProtocolError < Error; end
|
|
36
|
+
class ChecksumError < ProtocolError; end
|
|
37
|
+
class DeviceError < Error; end
|
|
38
|
+
|
|
39
|
+
attr_reader :io, :timeout
|
|
40
|
+
|
|
41
|
+
def initialize(io, timeout: DEFAULT_TIMEOUT)
|
|
42
|
+
raise ArgumentError, "timeout must be positive" unless timeout.positive?
|
|
43
|
+
|
|
44
|
+
@io = io
|
|
45
|
+
@timeout = timeout
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def upload(path, data)
|
|
49
|
+
path = validate_path(path)
|
|
50
|
+
data = data.b
|
|
51
|
+
in_session do
|
|
52
|
+
send_frame(FILE_WRITE, [data.bytesize].pack("N") + path)
|
|
53
|
+
expect_status(FILE_ACK, READY, "device readiness")
|
|
54
|
+
send_chunks(data)
|
|
55
|
+
|
|
56
|
+
status, remote_crc = completion
|
|
57
|
+
raise DeviceError, format("upload failed with status 0x%02x", status) unless status == OK
|
|
58
|
+
|
|
59
|
+
local_crc = Checksum.crc32(data)
|
|
60
|
+
raise ChecksumError, checksum_mismatch("CRC32", local_crc, remote_crc, 8) unless remote_crc == local_crc
|
|
61
|
+
end
|
|
62
|
+
data.bytesize
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def download(path)
|
|
66
|
+
path = validate_path(path)
|
|
67
|
+
data = +"".b
|
|
68
|
+
total = nil
|
|
69
|
+
|
|
70
|
+
in_session do
|
|
71
|
+
send_frame(FILE_READ, path)
|
|
72
|
+
loop do
|
|
73
|
+
command, payload = receive_frame
|
|
74
|
+
case command
|
|
75
|
+
when FILE_DATA
|
|
76
|
+
if total.nil?
|
|
77
|
+
raise ProtocolError, "first FILE_DATA frame has no size" if payload.bytesize < 4
|
|
78
|
+
|
|
79
|
+
total = payload.unpack1("N")
|
|
80
|
+
data << payload.byteslice(4..)
|
|
81
|
+
else
|
|
82
|
+
data << payload
|
|
83
|
+
end
|
|
84
|
+
raise ProtocolError, "received more bytes than declared" if total && data.bytesize > total
|
|
85
|
+
|
|
86
|
+
send_frame(CHUNK_ACK, [OK].pack("C"))
|
|
87
|
+
when DONE_ACK
|
|
88
|
+
status, remote_crc = parse_completion(payload)
|
|
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
|
|
93
|
+
|
|
94
|
+
local_crc = Checksum.crc32(data)
|
|
95
|
+
raise ChecksumError, checksum_mismatch("CRC32", local_crc, remote_crc, 8) unless remote_crc == local_crc
|
|
96
|
+
|
|
97
|
+
break
|
|
98
|
+
when ERROR
|
|
99
|
+
raise DeviceError, payload.force_encoding(Encoding::UTF_8)
|
|
100
|
+
else
|
|
101
|
+
raise ProtocolError, format("unexpected response 0x%02x during download", command)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
data
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def dfu(data, type:)
|
|
109
|
+
data = data.b
|
|
110
|
+
type = validate_dfu_type(type)
|
|
111
|
+
header = [DFU_MAGIC, DFU_VERSION, type, data.bytesize, Checksum.crc32(data), 0].pack("a4Ca4NNn")
|
|
112
|
+
in_session do
|
|
113
|
+
send_frame(DFU_START, header)
|
|
114
|
+
expect_status(DFU_ACK, READY, "DFU readiness")
|
|
115
|
+
send_chunks(data)
|
|
116
|
+
expect_dfu_completion
|
|
117
|
+
end
|
|
118
|
+
data.bytesize
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def send_frame(command, payload = +"".b)
|
|
122
|
+
body = [command].pack("C") + payload.b
|
|
123
|
+
raise ProtocolError, "frame body is too large" if body.bytesize > MAX_FRAME_BODY
|
|
124
|
+
|
|
125
|
+
frame = [STX, body.bytesize].pack("Cn") + body + [Checksum.crc16(body)].pack("n")
|
|
126
|
+
write_all(frame)
|
|
127
|
+
frame.bytesize
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def receive_frame
|
|
131
|
+
deadline = monotonic_time + timeout
|
|
132
|
+
scan_for(STX, deadline, "PicoModem frame")
|
|
133
|
+
length = read_exact(2, deadline).unpack1("n")
|
|
134
|
+
raise ProtocolError, "invalid empty frame" if length.zero?
|
|
135
|
+
raise ProtocolError, "frame body is too large: #{length}" if length > MAX_FRAME_BODY
|
|
136
|
+
|
|
137
|
+
body = read_exact(length, deadline)
|
|
138
|
+
expected_crc = read_exact(2, deadline).unpack1("n")
|
|
139
|
+
actual_crc = Checksum.crc16(body)
|
|
140
|
+
raise ChecksumError, checksum_mismatch("CRC16", actual_crc, expected_crc, 4) unless actual_crc == expected_crc
|
|
141
|
+
|
|
142
|
+
[body.getbyte(0), body.byteslice(1..) || +"".b]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def in_session
|
|
148
|
+
entered = false
|
|
149
|
+
completed = false
|
|
150
|
+
enter_mode
|
|
151
|
+
entered = true
|
|
152
|
+
result = yield
|
|
153
|
+
completed = true
|
|
154
|
+
result
|
|
155
|
+
ensure
|
|
156
|
+
abort_transfer if entered && !completed
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def enter_mode
|
|
160
|
+
write_all([STX].pack("C"))
|
|
161
|
+
scan_for(ACK, monotonic_time + timeout, "PicoModem ACK")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def abort_transfer
|
|
165
|
+
send_frame(ABORT)
|
|
166
|
+
rescue IOError, SystemCallError, Error
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def completion
|
|
171
|
+
command, payload = receive_frame
|
|
172
|
+
raise DeviceError, payload.force_encoding(Encoding::UTF_8) if command == ERROR
|
|
173
|
+
raise ProtocolError, format("expected DONE_ACK, got 0x%02x", command) unless command == DONE_ACK
|
|
174
|
+
|
|
175
|
+
parse_completion(payload)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def expect_dfu_completion
|
|
179
|
+
command, payload = receive_frame
|
|
180
|
+
raise DeviceError, payload.force_encoding(Encoding::UTF_8) if command == ERROR
|
|
181
|
+
raise ProtocolError, format("expected DFU DONE_ACK, got 0x%02x", command) unless command == DONE_ACK
|
|
182
|
+
raise ProtocolError, "DFU DONE_ACK has no status" if payload.empty?
|
|
183
|
+
return if payload.getbyte(0) == OK
|
|
184
|
+
|
|
185
|
+
raise DeviceError, format("DFU failed with status 0x%02x", payload.getbyte(0))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_completion(payload)
|
|
189
|
+
raise ProtocolError, "DONE_ACK has no CRC32" if payload.bytesize < 5
|
|
190
|
+
|
|
191
|
+
payload.unpack("CN")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def expect_status(expected_command, expected_status, context)
|
|
195
|
+
command, payload = receive_frame
|
|
196
|
+
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
|
|
200
|
+
raise ProtocolError, "#{context} has no status" if payload.empty?
|
|
201
|
+
return if payload.getbyte(0) == expected_status
|
|
202
|
+
|
|
203
|
+
raise DeviceError, "#{context} failed with status #{hex(payload.getbyte(0), 2)}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def send_chunks(data)
|
|
207
|
+
offset = 0
|
|
208
|
+
while offset < data.bytesize
|
|
209
|
+
chunk = data.byteslice(offset, CHUNK_SIZE)
|
|
210
|
+
send_frame(CHUNK, chunk)
|
|
211
|
+
expect_status(CHUNK_ACK, OK, "chunk acknowledgement")
|
|
212
|
+
offset += chunk.bytesize
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_dfu_type(type)
|
|
217
|
+
type = String(type).upcase
|
|
218
|
+
return type if DFU_TYPES.include?(type)
|
|
219
|
+
|
|
220
|
+
raise ArgumentError, "DFU type must be one of: #{DFU_TYPES.join(", ")}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def validate_path(path)
|
|
224
|
+
path = String(path)
|
|
225
|
+
raise ArgumentError, "remote path must be absolute" unless path.start_with?("/")
|
|
226
|
+
raise ArgumentError, "remote path contains a null byte" if path.include?("\0")
|
|
227
|
+
raise ArgumentError, "remote path must not contain .." if path.split("/").include?("..")
|
|
228
|
+
|
|
229
|
+
path.b
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def scan_for(byte, deadline, description)
|
|
233
|
+
loop do
|
|
234
|
+
return if read_exact(1, deadline).getbyte(0) == byte
|
|
235
|
+
rescue TimeoutError
|
|
236
|
+
raise TimeoutError, "timed out waiting for #{description}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def read_exact(length, deadline)
|
|
241
|
+
buffer = +"".b
|
|
242
|
+
while buffer.bytesize < length
|
|
243
|
+
begin
|
|
244
|
+
remaining = deadline - monotonic_time
|
|
245
|
+
raise TimeoutError, "timed out after #{timeout} seconds" unless remaining.positive?
|
|
246
|
+
|
|
247
|
+
wait_readable(remaining)
|
|
248
|
+
chunk = io.read_nonblock(length - buffer.bytesize)
|
|
249
|
+
raise IOError, "serial connection closed" if chunk.nil? || chunk.empty?
|
|
250
|
+
|
|
251
|
+
buffer << chunk
|
|
252
|
+
rescue IO::WaitReadable
|
|
253
|
+
next
|
|
254
|
+
rescue EOFError
|
|
255
|
+
raise IOError, "serial connection closed"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
buffer
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def write_all(data)
|
|
262
|
+
offset = 0
|
|
263
|
+
while offset < data.bytesize
|
|
264
|
+
written = io.write(data.byteslice(offset..))
|
|
265
|
+
raise IOError, "serial connection closed while writing" unless written&.positive?
|
|
266
|
+
|
|
267
|
+
offset += written
|
|
268
|
+
end
|
|
269
|
+
io.flush if io.respond_to?(:flush)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def wait_readable(timeout_seconds)
|
|
273
|
+
selectable = io.respond_to?(:to_io) ? io.to_io : io
|
|
274
|
+
return unless selectable.respond_to?(:fileno)
|
|
275
|
+
|
|
276
|
+
return if selectable.wait_readable(timeout_seconds)
|
|
277
|
+
|
|
278
|
+
raise TimeoutError, "timed out after #{timeout} seconds"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def checksum_mismatch(name, local, remote, width)
|
|
282
|
+
"#{name} mismatch: local=#{hex(local, width)} remote=#{hex(remote, width)}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def hex(value, width)
|
|
286
|
+
"0x#{value.to_s(16).rjust(width, "0")}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def monotonic_time
|
|
290
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpremote
|
|
4
|
+
module RemotePath
|
|
5
|
+
PREFIX = ":"
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def remote?(path)
|
|
10
|
+
path.start_with?(PREFIX)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unwrap(path)
|
|
14
|
+
path.delete_prefix(PREFIX)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate(path)
|
|
18
|
+
raise ArgumentError, "path must be remote (prefix it with :)" unless remote?(path)
|
|
19
|
+
|
|
20
|
+
remote = unwrap(path)
|
|
21
|
+
raise ArgumentError, "remote path must be absolute" unless remote.start_with?("/")
|
|
22
|
+
raise ArgumentError, "remote path must not contain .." if remote.split("/").include?("..")
|
|
23
|
+
|
|
24
|
+
remote
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpremote
|
|
4
|
+
class Resetter
|
|
5
|
+
DEFAULT_TIMEOUT = 10.0
|
|
6
|
+
RETRY_INTERVAL = 0.1
|
|
7
|
+
PROBE_TIMEOUT = 1.0
|
|
8
|
+
|
|
9
|
+
class TimeoutError < Rpremote::Error; end
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
serial: Serial,
|
|
13
|
+
timeout: DEFAULT_TIMEOUT,
|
|
14
|
+
shell_class: Shell,
|
|
15
|
+
sleeper: nil,
|
|
16
|
+
clock: nil,
|
|
17
|
+
port_probe: nil
|
|
18
|
+
)
|
|
19
|
+
@serial = serial
|
|
20
|
+
@timeout = Float(timeout)
|
|
21
|
+
@shell_class = shell_class
|
|
22
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
23
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
24
|
+
@port_probe = port_probe || ->(path) { File.exist?(path) }
|
|
25
|
+
raise ArgumentError, "timeout must be positive" unless @timeout.positive?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset(port_path, baud: Serial::BAUD_RATE)
|
|
29
|
+
deadline = clock.call + timeout
|
|
30
|
+
send_reboot(port_path, baud)
|
|
31
|
+
wait_until("USB serial port to disconnect", deadline) { !port_probe.call(port_path) }
|
|
32
|
+
wait_until_ready(port_path, baud, deadline)
|
|
33
|
+
port_path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :serial, :timeout, :shell_class, :sleeper, :clock, :port_probe
|
|
39
|
+
|
|
40
|
+
def send_reboot(port_path, baud)
|
|
41
|
+
serial.open(port_path, baud: baud) do |port|
|
|
42
|
+
shell = shell_class.new(port, timeout: [timeout, PROBE_TIMEOUT].min)
|
|
43
|
+
shell.synchronize!
|
|
44
|
+
shell.send_command("reboot")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def wait_until_ready(port_path, baud, deadline)
|
|
49
|
+
wait_until("R2P2 Shell after reset", deadline) do
|
|
50
|
+
remaining = deadline - clock.call
|
|
51
|
+
shell_ready?(port_path, baud, [remaining, PROBE_TIMEOUT].min) if remaining.positive?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def wait_until(description, deadline)
|
|
56
|
+
loop do
|
|
57
|
+
return if yield
|
|
58
|
+
raise TimeoutError, "timed out waiting for #{description}" if clock.call >= deadline
|
|
59
|
+
|
|
60
|
+
sleeper.call(RETRY_INTERVAL)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shell_ready?(port_path, baud, probe_timeout)
|
|
65
|
+
serial.open(port_path, baud: baud) do |port|
|
|
66
|
+
shell_class.new(port, timeout: probe_timeout).synchronize!
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
rescue IOError, SystemCallError, Rpremote::Error
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpremote
|
|
4
|
+
class Runner
|
|
5
|
+
REMOTE_DIRECTORY = "/home"
|
|
6
|
+
|
|
7
|
+
attr_reader :io, :timeout
|
|
8
|
+
|
|
9
|
+
def initialize(
|
|
10
|
+
io,
|
|
11
|
+
timeout: Shell::DEFAULT_TIMEOUT,
|
|
12
|
+
modem_class: PicoModem,
|
|
13
|
+
shell_class: Shell,
|
|
14
|
+
path_factory: nil
|
|
15
|
+
)
|
|
16
|
+
@io = io
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@modem_class = modem_class
|
|
19
|
+
@shell_class = shell_class
|
|
20
|
+
@path_factory = path_factory || method(:temporary_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(data)
|
|
24
|
+
remote_path = @path_factory.call
|
|
25
|
+
shell = @shell_class.new(io, timeout: timeout)
|
|
26
|
+
shell.synchronize!
|
|
27
|
+
@modem_class.new(io, timeout: timeout).upload(remote_path, data)
|
|
28
|
+
shell.synchronize!
|
|
29
|
+
shell_ready = true
|
|
30
|
+
shell.execute("./#{File.basename(remote_path)}")
|
|
31
|
+
ensure
|
|
32
|
+
cleanup(shell, remote_path) if shell_ready && remote_path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def cleanup(shell, remote_path)
|
|
38
|
+
shell.execute("rm #{remote_path}")
|
|
39
|
+
rescue IOError, SystemCallError, Shell::Error
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def temporary_path
|
|
44
|
+
stamp = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1_000_000).to_i
|
|
45
|
+
"#{REMOTE_DIRECTORY}/.rpremote-run-#{Process.pid}-#{stamp}.rb"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Rpremote
|
|
6
|
+
class Serial
|
|
7
|
+
BAUD_RATE = 115_200
|
|
8
|
+
|
|
9
|
+
class ConfigurationError < Rpremote::Error; end
|
|
10
|
+
|
|
11
|
+
def self.open(path, baud: BAUD_RATE)
|
|
12
|
+
port = new(path, baud: baud)
|
|
13
|
+
return port unless block_given?
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
yield port
|
|
17
|
+
ensure
|
|
18
|
+
port.close
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :path, :baud, :io
|
|
23
|
+
|
|
24
|
+
def initialize(path, baud: BAUD_RATE)
|
|
25
|
+
@path = path
|
|
26
|
+
@baud = Integer(baud)
|
|
27
|
+
configure!
|
|
28
|
+
@io = File.open(path, File::RDWR)
|
|
29
|
+
@io.binmode
|
|
30
|
+
@io.sync = true
|
|
31
|
+
rescue SystemCallError => e
|
|
32
|
+
raise ConfigurationError, "cannot open serial port #{path}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read_nonblock(length)
|
|
36
|
+
io.read_nonblock(length)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write(data)
|
|
40
|
+
io.write(data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def flush
|
|
44
|
+
io.flush
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def close
|
|
48
|
+
io.close unless io.closed?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def closed?
|
|
52
|
+
io.closed?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_io
|
|
56
|
+
io
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def configure!
|
|
62
|
+
output, status = Open3.capture2e(
|
|
63
|
+
"stty", "-f", path, baud.to_s, "cs8", "-cstopb", "-parenb", "raw", "-echo"
|
|
64
|
+
)
|
|
65
|
+
return if status.success?
|
|
66
|
+
|
|
67
|
+
raise ConfigurationError, "cannot configure serial port #{path}: #{output.strip}"
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
raise ConfigurationError, "stty was not found; rpremote currently requires macOS"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Rpremote
|
|
6
|
+
class SetupCommand
|
|
7
|
+
def self.run(args, defaults:, config:, config_filename:, output: $stdout)
|
|
8
|
+
options = {
|
|
9
|
+
force: false,
|
|
10
|
+
cache_dir: defaults.fetch(:cache, Target::DEFAULT_CACHE_DIR),
|
|
11
|
+
language: defaults.fetch(:language, Target::DEFAULT_LANGUAGE),
|
|
12
|
+
language_version: defaults.fetch(:language_version, Target::DEFAULT_LANGUAGE_VERSION)
|
|
13
|
+
}
|
|
14
|
+
OptionParser.new do |parser|
|
|
15
|
+
parser.on("--force") { options[:force] = true }
|
|
16
|
+
parser.on("--cache DIR") { |value| options[:cache_dir] = value }
|
|
17
|
+
parser.on("--language LANGUAGE") { |value| options[:language] = value }
|
|
18
|
+
parser.on("--language-version VERSION") { |value| options[:language_version] = value }
|
|
19
|
+
end.parse!(args)
|
|
20
|
+
raise ArgumentError, "setup does not accept arguments" unless args.empty?
|
|
21
|
+
|
|
22
|
+
target = Target.new(**options.slice(:cache_dir, :language, :language_version))
|
|
23
|
+
Language.validate!(target.language)
|
|
24
|
+
result = config.setup(filename: config_filename)
|
|
25
|
+
output.puts("#{result.created ? "created" : "exists"} config: #{result.path}")
|
|
26
|
+
path = LanguageSource.new(
|
|
27
|
+
language: target.language,
|
|
28
|
+
version: target.language_version,
|
|
29
|
+
cache_dir: target.cache_dir
|
|
30
|
+
).setup(force: options[:force])
|
|
31
|
+
output.puts("installed #{target.language} #{target.language_version}: #{path}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|