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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module Rpremote
6
+ class Shell
7
+ DEFAULT_TIMEOUT = 10.0
8
+ PROMPT_START = "\e[?25l\e[1G$> \e[0K".b
9
+ PROMPT_END = "\e[?25h".b
10
+ ECHO_START = "\e[?25l\e[1G$> ".b
11
+ ERASE_LINE = "\e[0K".b
12
+ CURSOR_POSITION_QUERY = "\e[6n".b
13
+ CURSOR_POSITION_RESPONSE = "\e[1;1R".b
14
+
15
+ class Error < Rpremote::Error; end
16
+ class TimeoutError < Error; end
17
+ class ProtocolError < Error; end
18
+ class CommandError < Error; end
19
+
20
+ def self.quote_argument(argument)
21
+ argument = String(argument)
22
+ raise ArgumentError, "shell argument contains a control character" if argument.match?(/[\x00-\x1f\x7f]/)
23
+ return "'#{argument}'" unless argument.include?("'")
24
+ return %("#{argument}") unless argument.include?('"')
25
+
26
+ raise ArgumentError, "shell argument cannot contain both quote characters"
27
+ end
28
+
29
+ attr_reader :io, :timeout
30
+
31
+ def initialize(io, timeout: DEFAULT_TIMEOUT)
32
+ raise ArgumentError, "timeout must be positive" unless timeout.positive?
33
+
34
+ @io = io
35
+ @timeout = timeout
36
+ end
37
+
38
+ def synchronize!
39
+ drain_input
40
+ write_all("\r".b)
41
+ read_until_prompt
42
+ nil
43
+ end
44
+
45
+ def execute(command)
46
+ command = validate_command(command)
47
+ send_command(command)
48
+ response = read_until_prompt(after: ECHO_START + command.b + ERASE_LINE)
49
+ extract_output(response, command)
50
+ rescue TimeoutError
51
+ interrupt
52
+ raise
53
+ end
54
+
55
+ def send_command(command)
56
+ command = validate_command(command)
57
+ write_all("#{command}\r".b)
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ def validate_command(command)
64
+ command = String(command)
65
+ raise ArgumentError, "shell command must not be empty" if command.empty?
66
+ raise ArgumentError, "shell command contains a control character" if command.match?(/[\x00-\x1f\x7f]/)
67
+
68
+ command
69
+ end
70
+
71
+ def read_until_prompt(wait: timeout, after: nil)
72
+ deadline = monotonic_time + wait
73
+ buffer = +"".b
74
+ loop do
75
+ boundary = after ? buffer.index(after) : 0
76
+ prompt_at = buffer.index(PROMPT_START, boundary + (after&.bytesize || 0)) if boundary
77
+ return buffer if prompt_at && buffer.index(PROMPT_END, prompt_at + PROMPT_START.bytesize)
78
+
79
+ buffer << read_available(deadline)
80
+ answer_terminal_queries(buffer)
81
+ end
82
+ end
83
+
84
+ def extract_output(response, command)
85
+ echo = ECHO_START + command.b + ERASE_LINE
86
+ echo_at = response.rindex(echo)
87
+ raise ProtocolError, "R2P2 Shell did not echo the command" unless echo_at
88
+
89
+ prompt_at = response.index(PROMPT_START, echo_at + echo.bytesize)
90
+ raise ProtocolError, "R2P2 Shell prompt was not found after the command" unless prompt_at
91
+
92
+ output_at = response.index("\n", echo_at + echo.bytesize)
93
+ raise ProtocolError, "R2P2 Shell response has no command boundary" unless output_at
94
+
95
+ response.byteslice((output_at + 1)...prompt_at) || +"".b
96
+ end
97
+
98
+ def drain_input
99
+ deadline = monotonic_time + 0.1
100
+ buffer = +"".b
101
+ loop do
102
+ remaining = deadline - monotonic_time
103
+ break unless remaining.positive? && selectable_io.wait_readable(remaining)
104
+
105
+ buffer << io.read_nonblock(4096)
106
+ rescue IO::WaitReadable
107
+ next
108
+ rescue EOFError
109
+ raise IOError, "serial connection closed"
110
+ end
111
+ answer_terminal_queries(buffer)
112
+ end
113
+
114
+ def read_available(deadline)
115
+ remaining = deadline - monotonic_time
116
+ raise TimeoutError, "timed out waiting for the R2P2 Shell after #{timeout} seconds" unless remaining.positive?
117
+ unless selectable_io.wait_readable(remaining)
118
+ raise TimeoutError, "timed out waiting for the R2P2 Shell after #{timeout} seconds"
119
+ end
120
+
121
+ data = io.read_nonblock(4096)
122
+ raise IOError, "serial connection closed" if data.nil? || data.empty?
123
+
124
+ data
125
+ rescue IO::WaitReadable
126
+ retry
127
+ rescue EOFError
128
+ raise IOError, "serial connection closed"
129
+ end
130
+
131
+ def interrupt
132
+ write_all("\x03".b)
133
+ read_until_prompt(wait: [timeout, 1.0].min)
134
+ rescue IOError, SystemCallError, Error
135
+ nil
136
+ end
137
+
138
+ def answer_terminal_queries(buffer)
139
+ write_all(CURSOR_POSITION_RESPONSE) while buffer.sub!(CURSOR_POSITION_QUERY, "")
140
+ end
141
+
142
+ def write_all(data)
143
+ offset = 0
144
+ while offset < data.bytesize
145
+ written = io.write(data.byteslice(offset..))
146
+ raise IOError, "serial connection closed while writing" unless written&.positive?
147
+
148
+ offset += written
149
+ end
150
+ io.flush if io.respond_to?(:flush)
151
+ end
152
+
153
+ def selectable_io
154
+ io.respond_to?(:to_io) ? io.to_io : io
155
+ end
156
+
157
+ def monotonic_time
158
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ class Target
5
+ DEFAULT_LANGUAGE = "picoruby"
6
+ DEFAULT_LANGUAGE_VERSION = "4.0.3"
7
+ DEFAULT_BOARD = "pico2"
8
+ DEFAULT_CACHE_DIR = "firmware"
9
+ IDENTIFIER_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
10
+
11
+ attr_reader :language, :language_version, :board, :cache_dir
12
+
13
+ def initialize(
14
+ language: DEFAULT_LANGUAGE,
15
+ language_version: DEFAULT_LANGUAGE_VERSION,
16
+ board: DEFAULT_BOARD,
17
+ cache_dir: DEFAULT_CACHE_DIR,
18
+ firmware: nil
19
+ )
20
+ @language = validate_identifier(:language, language)
21
+ @language_version = validate_identifier(:language_version, language_version)
22
+ @board = validate_identifier(:board, board)
23
+ @cache_dir = String(cache_dir).gsub("{version}", @language_version)
24
+ @firmware = firmware
25
+ raise ArgumentError, "cache must not be empty" if @cache_dir.empty?
26
+ end
27
+
28
+ def source_dir(root: Dir.pwd)
29
+ File.expand_path(File.join(cache_dir, "#{language}-#{language_version}"), root)
30
+ end
31
+
32
+ def firmware_path(root: nil)
33
+ path = @firmware || File.join(cache_dir, "#{language}-#{language_version}-#{board}.uf2")
34
+ root ? File.expand_path(path, root) : path
35
+ end
36
+
37
+ private
38
+
39
+ def validate_identifier(name, value)
40
+ value = String(value)
41
+ return value if IDENTIFIER_PATTERN.match?(value)
42
+
43
+ raise ArgumentError, "invalid #{name}: #{value.inspect}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Rpremote
6
+ class Terminal
7
+ EXIT_BYTE = 0x1D
8
+ BUFFER_SIZE = 4096
9
+
10
+ attr_reader :serial_io, :input, :output
11
+
12
+ def initialize(serial_io, input: $stdin, output: $stdout, selector: nil)
13
+ @serial_io = serial_io
14
+ @input = input
15
+ @output = output
16
+ @selector = selector || ->(readers) { IO.select(readers) }
17
+ end
18
+
19
+ def run(exit_sequence: nil)
20
+ with_raw_input { relay }
21
+ ensure
22
+ leave_interactive_mode(exit_sequence)
23
+ end
24
+
25
+ private
26
+
27
+ def with_raw_input(&)
28
+ if input.respond_to?(:tty?) && input.tty? && input.respond_to?(:raw)
29
+ input.raw(&)
30
+ else
31
+ yield
32
+ end
33
+ end
34
+
35
+ def relay
36
+ readers = [selectable_serial, input]
37
+ loop do
38
+ ready = @selector.call(readers)
39
+ break unless ready
40
+
41
+ return if ready.first.any? { |stream| !relay_stream(stream) }
42
+ end
43
+ rescue EOFError, Errno::EIO
44
+ nil
45
+ end
46
+
47
+ def relay_stream(stream)
48
+ if stream.equal?(selectable_serial)
49
+ data = serial_io.read_nonblock(BUFFER_SIZE)
50
+ return false if data.nil? || data.empty?
51
+
52
+ output.write(data)
53
+ output.flush if output.respond_to?(:flush)
54
+ true
55
+ else
56
+ relay_input?
57
+ end
58
+ rescue IO::WaitReadable
59
+ true
60
+ end
61
+
62
+ def relay_input?
63
+ data = input.read_nonblock(BUFFER_SIZE)
64
+ return false if data.nil? || data.empty?
65
+
66
+ exit_at = data.index(EXIT_BYTE.chr)
67
+ if exit_at&.positive?
68
+ serial_io.write(data.byteslice(0...exit_at))
69
+ serial_io.flush if serial_io.respond_to?(:flush)
70
+ end
71
+ return false if exit_at
72
+
73
+ serial_io.write(data)
74
+ serial_io.flush if serial_io.respond_to?(:flush)
75
+ true
76
+ end
77
+
78
+ def selectable_serial
79
+ @selectable_serial ||= serial_io.respond_to?(:to_io) ? serial_io.to_io : serial_io
80
+ end
81
+
82
+ def leave_interactive_mode(sequence)
83
+ return unless sequence
84
+
85
+ serial_io.write(sequence)
86
+ serial_io.flush if serial_io.respond_to?(:flush)
87
+ rescue IOError, SystemCallError
88
+ nil
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rpremote.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rpremote/version"
4
+
5
+ module Rpremote
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "rpremote/checksum"
10
+ require_relative "rpremote/config"
11
+ require_relative "rpremote/target"
12
+ require_relative "rpremote/language"
13
+ require_relative "rpremote/remote_path"
14
+ require_relative "rpremote/picomodem"
15
+ require_relative "rpremote/device"
16
+ require_relative "rpremote/dfu_compiler"
17
+ require_relative "rpremote/serial"
18
+ require_relative "rpremote/terminal"
19
+ require_relative "rpremote/shell"
20
+ require_relative "rpremote/runner"
21
+ require_relative "rpremote/resetter"
22
+ require_relative "rpremote/language_source"
23
+ require_relative "rpremote/mrbgems"
24
+ require_relative "rpremote/mrbgems_command"
25
+ require_relative "rpremote/flasher"
26
+ require_relative "rpremote/builder"
27
+ require_relative "rpremote/dfu_command"
28
+ require_relative "rpremote/setup_command"
29
+ require_relative "rpremote/build_command"
30
+ require_relative "rpremote/flash_command"