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,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