esp_flash 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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module EspFlash
6
+ # A serial terminal for the device. Keystrokes reach the device byte for byte, terminal replies included
7
+ # (the answer to a "ESC [5n"/"ESC [6n" query, which the picoruby-shell sends to detect the terminal), and
8
+ # what the device sends is written out as it arrives. Ctrl-C goes to the device too.
9
+ #
10
+ # Ctrl-] quit
11
+ # Ctrl-T Ctrl-R reset the device (Ctrl-T Ctrl-H lists the menu)
12
+ #
13
+ # When the ELF and the toolchain's addr2line are available, addresses in a panic's backtrace are decoded.
14
+ #
15
+ # On a terminal, a line feed from the device also returns the cursor to the start of the line (the
16
+ # terminal is in raw mode, which would leave that to the device), and ESP-IDF log lines are colored by
17
+ # their level ("I (123) tag: ..." green, W yellow, E red) as esp-idf-monitor does.
18
+ class Monitor
19
+ EXIT_KEY = 0x1d # Ctrl-]
20
+ MENU_KEY = 0x14 # Ctrl-T
21
+ LOG_COLORS = { "I" => "\e[0;32m", "W" => "\e[0;33m", "E" => "\e[1;31m" }.freeze
22
+ COLOR_RESET = "\e[0m"
23
+ LOG_HEADER = /\A([IWE]) \([\d:. -]+\)/n
24
+ LOG_HEADER_PREFIX = /\A(?:[IWE](?: (?:\([\d:. -]*)?)?)?\z/n # what may still become a LOG_HEADER
25
+ XTENSA_TARGETS = %w[esp32 esp32s2 esp32s3].freeze
26
+
27
+ def initialize(path, baud:, target: nil, elf: nil, reset: true, input: $stdin, output: $stdout, terminal: nil)
28
+ @path = path
29
+ @baud = baud
30
+ @reset = reset
31
+ @input = input
32
+ @output = output
33
+ @terminal = terminal.nil? ? output.respond_to?(:tty?) && output.tty? : terminal
34
+ @line_start = true # the next byte from the device starts a line
35
+ @held = "".b # the start of a line that may be a log header, until the rest of it arrives
36
+ @colored = false # a log line's color is on
37
+ @elf = elf if elf && File.exist?(elf)
38
+ @addr2line = find_addr2line(target) if @elf
39
+ @menu = false
40
+ @line = "".b
41
+ @out_mutex = Mutex.new
42
+ end
43
+
44
+ def run
45
+ @port = SerialPort.open(@path, @baud)
46
+ status "--- Monitoring #{@path}#{" at #{@baud} baud" if @port.resettable?}. " \
47
+ "Quit: Ctrl-] Reset: Ctrl-T Ctrl-R Help: Ctrl-T Ctrl-H ---"
48
+ reset_device if @reset
49
+ reader = Thread.new { read_loop }
50
+ raw_input { input_loop }
51
+ ensure
52
+ reader&.kill
53
+ @port&.close
54
+ end
55
+
56
+ private
57
+
58
+ def input_loop
59
+ loop do
60
+ data = begin
61
+ @input.readpartial(1024)
62
+ rescue EOFError
63
+ return
64
+ end
65
+ out = "".b
66
+ data.each_byte do |byte|
67
+ if @menu
68
+ @menu = false
69
+ case byte
70
+ when 0x12, 0x52, 0x72 # Ctrl-R, R, r
71
+ send_to_device(out)
72
+ out = "".b
73
+ reset_device
74
+ when MENU_KEY then out << byte # Ctrl-T Ctrl-T sends Ctrl-T itself
75
+ when 0x18, 0x58, 0x78 # Ctrl-X, X, x
76
+ send_to_device(out)
77
+ return
78
+ else help
79
+ end
80
+ elsif byte == EXIT_KEY
81
+ send_to_device(out)
82
+ return
83
+ elsif byte == MENU_KEY
84
+ @menu = true
85
+ else
86
+ out << byte
87
+ end
88
+ end
89
+ send_to_device(out)
90
+ end
91
+ end
92
+
93
+ def read_loop
94
+ loop do
95
+ data = @port.read(4096, 0.2)
96
+ next flush_held if data.empty?
97
+
98
+ @out_mutex.synchronize do
99
+ @output.write(@terminal ? render(data) : data)
100
+ @output.flush
101
+ end
102
+ decode_backtrace(data) if @addr2line
103
+ rescue Disconnected
104
+ reconnect
105
+ end
106
+ end
107
+
108
+ # A USB serial device can vanish for a moment while the chip resets (e.g. the USB-Serial/JTAG one).
109
+ def reconnect
110
+ status "--- #{@path} disconnected; waiting for it to come back (Ctrl-] to quit) ---"
111
+ @port.close
112
+ loop do
113
+ sleep 0.5
114
+ begin
115
+ @port = SerialPort.open(@path, @baud)
116
+ break
117
+ rescue Error
118
+ next
119
+ end
120
+ end
121
+ status "--- Reconnected to #{@path} ---"
122
+ end
123
+
124
+ # For a terminal: "\n" => "\r\n" (unless after "\r"), and log lines colored by their level.
125
+ def render(data)
126
+ data = @held + data
127
+ @held = "".b
128
+ out = "".b
129
+ pos = 0
130
+ while pos < data.bytesize
131
+ if @line_start
132
+ rest = data.byteslice(pos..)
133
+ if (header = LOG_HEADER.match(rest))
134
+ out << LOG_COLORS[header[1]]
135
+ @colored = true
136
+ elsif LOG_HEADER_PREFIX.match?(rest)
137
+ @held = rest
138
+ break
139
+ end
140
+ @line_start = false
141
+ end
142
+ newline = data.index("\n", pos)
143
+ unless newline
144
+ out << data.byteslice(pos..)
145
+ break
146
+ end
147
+ line = data.byteslice(pos, newline - pos)
148
+ line = line.chomp("\r")
149
+ out << line
150
+ out << COLOR_RESET if @colored
151
+ out << "\r\n"
152
+ @colored = false
153
+ @line_start = true
154
+ pos = newline + 1
155
+ end
156
+ out
157
+ end
158
+
159
+ # Writes out what render held back, when no more of the line came.
160
+ def flush_held
161
+ return if @held.empty?
162
+
163
+ @out_mutex.synchronize do
164
+ @output.write(@held)
165
+ @output.flush
166
+ end
167
+ @held = "".b
168
+ @line_start = false
169
+ end
170
+
171
+ def send_to_device(bytes)
172
+ @port.write(bytes) unless bytes.empty?
173
+ rescue Disconnected, IOError
174
+ nil # read_loop reconnects
175
+ end
176
+
177
+ def reset_device
178
+ return status("--- #{@path} cannot reset the device ---") unless @port.resettable?
179
+
180
+ status "--- Resetting the device ---"
181
+ EspFlash.hard_reset(@port)
182
+ end
183
+
184
+ def help
185
+ status "--- Ctrl-]: quit Ctrl-T Ctrl-R: reset Ctrl-T Ctrl-X: quit Ctrl-T Ctrl-T: send Ctrl-T ---"
186
+ end
187
+
188
+ def status(message)
189
+ @out_mutex.synchronize do
190
+ @output.write("\r\n#{message}\r\n")
191
+ @output.flush
192
+ end
193
+ end
194
+
195
+ # Raw mode so that every key (and every terminal reply) is read as it comes, without echo or signals.
196
+ def raw_input(&block)
197
+ return yield unless @input.respond_to?(:tty?) && @input.tty?
198
+
199
+ @input.raw(intr: false) do
200
+ windows_vt_mode(&block)
201
+ end
202
+ end
203
+
204
+ # The Windows console answers "ESC [6n" and sends keys as escape sequences only in its VT modes.
205
+ def windows_vt_mode
206
+ return yield unless Gem.win_platform?
207
+
208
+ require "fiddle/import"
209
+ k32 = Module.new do
210
+ extend Fiddle::Importer
211
+ dlload "kernel32.dll"
212
+ extern "void* GetStdHandle(long)"
213
+ extern "int GetConsoleMode(void*, void*)"
214
+ extern "int SetConsoleMode(void*, unsigned long)"
215
+ end
216
+ saved = [[-10, 0x0200], [-11, 0x0004]].filter_map do |std, flag| # VT input / VT processing
217
+ handle = k32.GetStdHandle(std)
218
+ mode = [0].pack("V")
219
+ next if k32.GetConsoleMode(handle, mode).zero?
220
+
221
+ mode = mode.unpack1("V")
222
+ k32.SetConsoleMode(handle, mode | flag)
223
+ [handle, mode]
224
+ end
225
+ begin
226
+ yield
227
+ ensure
228
+ saved.each { |handle, mode| k32.SetConsoleMode(handle, mode) }
229
+ end
230
+ end
231
+
232
+ def find_addr2line(target)
233
+ names = if XTENSA_TARGETS.include?(target)
234
+ ["xtensa-esp-elf-addr2line", "xtensa-#{target}-elf-addr2line"]
235
+ else
236
+ ["riscv32-esp-elf-addr2line"]
237
+ end
238
+ exts = Gem.win_platform? ? [".exe", ""] : [""]
239
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).product(names, exts).each do |dir, name, ext|
240
+ path = File.join(dir, name + ext)
241
+ return path if File.file?(path) && File.executable?(path)
242
+ end
243
+ nil
244
+ end
245
+
246
+ # Decodes the code addresses of a panic's "Backtrace:" line (Xtensa) or "MEPC"/"RA" registers (RISC-V).
247
+ def decode_backtrace(data)
248
+ @line << data
249
+ while (newline = @line.index("\n"))
250
+ line = @line.slice!(0..newline)
251
+ next unless line.match?(/Backtrace:|^\s*(PC|MEPC|RA)\s*:/n)
252
+
253
+ addresses = line.scan(/0x4[0-9a-f]{7}/in).uniq
254
+ next if addresses.empty?
255
+
256
+ decoded = IO.popen([@addr2line, "-pfiaC", "-e", @elf, *addresses], err: File::NULL, &:read).to_s
257
+ lines = decoded.lines.map(&:chomp).reject { |l| l.include?("?? ??:0") }
258
+ @out_mutex.synchronize do
259
+ lines.each { |l| @output.write("--- #{l}\r\n") }
260
+ @output.flush
261
+ end
262
+ end
263
+ @line = @line.byteslice(-1024..) if @line.bytesize > 4096
264
+ rescue SystemCallError
265
+ @addr2line = nil
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+ require "socket"
5
+
6
+ module EspFlash
7
+ # A serial port without any gem or C extension. Every backend has the same interface:
8
+ # read(max, timeout) up to +max+ bytes, waiting up to +timeout+ seconds for the first one ("" if none)
9
+ # write(bytes)
10
+ # baud = rate
11
+ # set_lines(dtr:, rts:) asserts (true) / deasserts (false) DTR and RTS; nil leaves a line as it is
12
+ # discard_input drops whatever has been received so far
13
+ # resettable? false when DTR/RTS do not reach a chip (a TCP socket)
14
+ # close
15
+ #
16
+ # "socket://HOST:PORT" opens a TCP connection instead (e.g. QEMU's UART).
17
+ module SerialPort
18
+ def self.open(path, baud)
19
+ if path.start_with?("socket://")
20
+ TcpPort.new(path, baud)
21
+ elsif Gem.win_platform?
22
+ WindowsPort.new(path, baud)
23
+ else
24
+ PosixPort.new(path, baud)
25
+ end
26
+ end
27
+
28
+ # Serial ports that are likely to be a USB-connected board.
29
+ def self.candidates
30
+ if Gem.win_platform?
31
+ WindowsPort.list
32
+ elsif RUBY_PLATFORM.include?("darwin")
33
+ Dir["/dev/cu.{usbserial,usbmodem,SLAB_USBtoUART,wchusbserial}*"].sort
34
+ else
35
+ Dir["/dev/tty{USB,ACM}*"].sort
36
+ end
37
+ end
38
+
39
+ # The port to use: +given+ (e.g. ENV['PORT']) if set, the only candidate, or one the user picks when
40
+ # there are several and stdin is a terminal.
41
+ def self.resolve(given)
42
+ return given if given && !given.empty?
43
+
44
+ ports = candidates
45
+ case ports.size
46
+ when 0
47
+ raise Error, "No serial port found. Connect the board, or set PORT (e.g. PORT=/dev/ttyUSB0)."
48
+ when 1
49
+ ports.first
50
+ else
51
+ raise Error, "Several serial ports found (#{ports.join(", ")}); set PORT to pick one." unless $stdin.tty?
52
+
53
+ $stderr.puts "Several serial ports found:"
54
+ ports.each_with_index { |port, i| $stderr.puts " #{i + 1}) #{port}" }
55
+ $stderr.print "Select one [1-#{ports.size}]: "
56
+ index = Integer($stdin.gets.to_s.strip, exception: false)
57
+ raise Error, "No port selected." unless index && (1..ports.size).cover?(index)
58
+
59
+ ports[index - 1]
60
+ end
61
+ end
62
+
63
+ # A USB serial device on Linux/macOS, set up with stty(1) and its modem lines driven with ioctl(2).
64
+ class PosixPort
65
+ LINUX = RUBY_PLATFORM.include?("linux")
66
+ TIOCMGET = LINUX ? 0x5415 : 0x4004746a
67
+ TIOCMSET = LINUX ? 0x5418 : 0x8004746d
68
+ TIOCM_DTR = 0x002
69
+ TIOCM_RTS = 0x004
70
+
71
+ # struct termios2 (asm-generic/termbits.h): 4 x u32 flags, c_line, c_cc[19], u32 c_ispeed/c_ospeed.
72
+ # BOTHER takes any rate from c_ispeed/c_ospeed.
73
+ TCGETS2 = 0x802c542a
74
+ TCSETS2 = 0x402c542b
75
+ TERMIOS2 = "V4Ca19VV"
76
+ CBAUD = 0o010017
77
+ BOTHER = 0o010000
78
+ CSIZE = 0o000060
79
+ CS8 = 0o000060
80
+ CSTOPB = 0o000100
81
+ CREAD = 0o000200
82
+ PARENB = 0o000400
83
+ CLOCAL = 0o004000
84
+ CRTSCTS = 0o20000000000
85
+ VTIME = 5
86
+ VMIN = 6
87
+
88
+ attr_reader :path
89
+
90
+ def initialize(path, baud)
91
+ @path = path
92
+ @io = File.open(path, File::RDWR | File::NOCTTY | File::NONBLOCK)
93
+ @io.binmode
94
+ @io.sync = true
95
+ self.baud = baud
96
+ rescue SystemCallError => e
97
+ @io&.close
98
+ raise Error, "Cannot open #{path}: #{e.message}"
99
+ end
100
+
101
+ def resettable? = true
102
+
103
+ # ESP-IDF's USB-Serial/JTAG controller (VID:PID 303a:1001), known on Linux only (nil elsewhere).
104
+ def usb_jtag_serial?
105
+ return nil unless LINUX
106
+
107
+ dir = File.realpath("/sys/class/tty/#{File.basename(File.realpath(@path))}/device")
108
+ dir = File.dirname(dir) until dir == "/" || File.exist?(File.join(dir, "idVendor"))
109
+ return false if dir == "/"
110
+
111
+ File.read(File.join(dir, "idVendor")).strip == "303a" && File.read(File.join(dir, "idProduct")).strip == "1001"
112
+ rescue SystemCallError
113
+ nil
114
+ end
115
+
116
+ # Raw 8N1 without flow control at +baud+.
117
+ def baud=(baud)
118
+ LINUX ? linux_termios2(baud) : stty(baud)
119
+ rescue SystemCallError => e
120
+ raise Error, "Cannot set #{@path} to #{baud} baud: #{e.message}"
121
+ end
122
+
123
+ def read(max, timeout)
124
+ return "".b unless @io.wait_readable(timeout)
125
+
126
+ data = @io.read_nonblock(max, exception: false)
127
+ data.is_a?(String) ? data : "".b
128
+ rescue EOFError, Errno::EIO, Errno::ENXIO => e
129
+ raise Disconnected, "#{@path}: #{e.message}"
130
+ end
131
+
132
+ def write(bytes)
133
+ @io.write(bytes)
134
+ rescue Errno::EIO, Errno::ENXIO => e
135
+ raise Disconnected, "#{@path}: #{e.message}"
136
+ end
137
+
138
+ # Both lines change in one ioctl when both are given (what esptool's "tight" reset relies on).
139
+ def set_lines(dtr: nil, rts: nil)
140
+ buf = [0].pack("i")
141
+ @io.ioctl(TIOCMGET, buf)
142
+ bits = buf.unpack1("i")
143
+ bits = dtr ? bits | TIOCM_DTR : bits & ~TIOCM_DTR unless dtr.nil?
144
+ bits = rts ? bits | TIOCM_RTS : bits & ~TIOCM_RTS unless rts.nil?
145
+ @io.ioctl(TIOCMSET, [bits].pack("i"))
146
+ end
147
+
148
+ def discard_input
149
+ loop { break if read(4096, 0).empty? }
150
+ end
151
+
152
+ def close
153
+ @io.close unless @io.closed?
154
+ end
155
+
156
+ private
157
+
158
+ def linux_termios2(baud)
159
+ buf = "\0".b * 44
160
+ @io.ioctl(TCGETS2, buf)
161
+ _iflag, _oflag, cflag, _lflag, line, cc, = buf.unpack(TERMIOS2)
162
+ cflag &= ~(CBAUD | CSIZE | CSTOPB | PARENB | CRTSCTS)
163
+ cflag |= BOTHER | CS8 | CREAD | CLOCAL
164
+ cc = cc.b
165
+ cc.setbyte(VTIME, 0)
166
+ cc.setbyte(VMIN, 1)
167
+ @io.ioctl(TCSETS2, [0, 0, cflag, 0, line, cc, baud, baud].pack(TERMIOS2))
168
+ end
169
+
170
+ # stty(1) sets up its stdin, which is this port (kept open, so the settings stay).
171
+ def stty(baud)
172
+ ok = system("stty", baud.to_s, "raw", "-echo", "cs8", "-cstopb", "-parenb", "-crtscts", "clocal", "cread",
173
+ in: @io, err: File::NULL)
174
+ raise Error, "Cannot set #{@path} to #{baud} baud (stty failed)" unless ok
175
+ end
176
+ end
177
+
178
+ # A TCP connection, e.g. to QEMU's UART. There are no modem lines, so the chip cannot be reset.
179
+ class TcpPort
180
+ attr_reader :path
181
+
182
+ def initialize(path, _baud)
183
+ @path = path
184
+ host, port = path.delete_prefix("socket://").split(":", 2)
185
+ @io = Socket.tcp(host, Integer(port), connect_timeout: 5)
186
+ @io.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
187
+ rescue SystemCallError, SocketError, ArgumentError, TypeError => e
188
+ raise Error, "Cannot connect to #{path}: #{e.message}"
189
+ end
190
+
191
+ def resettable? = false
192
+ def usb_jtag_serial? = false
193
+ def baud=(_baud); end
194
+ def set_lines(dtr: nil, rts: nil); end
195
+
196
+ def read(max, timeout)
197
+ return "".b unless @io.wait_readable(timeout)
198
+
199
+ @io.read_nonblock(max)
200
+ rescue IO::WaitReadable
201
+ "".b
202
+ rescue EOFError, SystemCallError => e
203
+ raise Disconnected, "#{@path}: #{e.message}"
204
+ end
205
+
206
+ def write(bytes)
207
+ @io.write(bytes)
208
+ rescue SystemCallError => e
209
+ raise Disconnected, "#{@path}: #{e.message}"
210
+ end
211
+
212
+ def discard_input
213
+ loop { break if read(4096, 0).empty? }
214
+ end
215
+
216
+ def close
217
+ @io.close unless @io.closed?
218
+ end
219
+ end
220
+
221
+ # A COM port on Windows, through kernel32 with Fiddle.
222
+ class WindowsPort
223
+ GENERIC_READ_WRITE = 0xC0000000
224
+ OPEN_EXISTING = 3
225
+ MAXDWORD = 0xFFFFFFFF
226
+ SETRTS = 3
227
+ CLRRTS = 4
228
+ SETDTR = 5
229
+ CLRDTR = 6
230
+ PURGE_RXCLEAR = 0x0008
231
+
232
+ def self.kernel32
233
+ @kernel32 ||= begin
234
+ require "fiddle/import"
235
+ Module.new do
236
+ extend Fiddle::Importer
237
+ dlload "kernel32.dll"
238
+ extern "void* CreateFileW(void*, unsigned long, unsigned long, void*, unsigned long, unsigned long, void*)"
239
+ extern "int GetCommState(void*, void*)"
240
+ extern "int SetCommState(void*, void*)"
241
+ extern "int SetCommTimeouts(void*, void*)"
242
+ extern "int ReadFile(void*, void*, unsigned long, void*, void*)"
243
+ extern "int WriteFile(void*, void*, unsigned long, void*, void*)"
244
+ extern "int EscapeCommFunction(void*, unsigned long)"
245
+ extern "int PurgeComm(void*, unsigned long)"
246
+ extern "int CloseHandle(void*)"
247
+ extern "unsigned long GetLastError()"
248
+ end
249
+ end
250
+ end
251
+
252
+ def self.list
253
+ require "win32/registry"
254
+ Win32::Registry::HKEY_LOCAL_MACHINE.open('HARDWARE\DEVICEMAP\SERIALCOMM') do |key|
255
+ key.map { |_name, _type, value| value }.sort_by { |name| name[/\d+/].to_i }
256
+ end
257
+ rescue LoadError, StandardError
258
+ []
259
+ end
260
+
261
+ attr_reader :path
262
+
263
+ def initialize(path, baud)
264
+ @path = path
265
+ @k = self.class.kernel32
266
+ name = path.start_with?("\\\\.\\") ? path : "\\\\.\\#{path}"
267
+ @handle = @k.CreateFileW("#{name}\0".encode("UTF-16LE"), GENERIC_READ_WRITE, 0, nil, OPEN_EXISTING, 0, nil)
268
+ if @handle.to_i == (1 << (8 * Fiddle::SIZEOF_VOIDP)) - 1 || @handle.to_i == -1
269
+ raise Error, "Cannot open #{path} (error #{@k.GetLastError})"
270
+ end
271
+
272
+ @dtr = @rts = false
273
+ @read_timeout = nil
274
+ self.baud = baud
275
+ end
276
+
277
+ def resettable? = true
278
+ def usb_jtag_serial? = nil
279
+
280
+ # The DCB also carries the DTR/RTS state, so a baud change keeps the lines as they are.
281
+ def baud=(baud)
282
+ @baud = baud
283
+ dcb = "\0".b * 28
284
+ dcb[0, 4] = [28].pack("V")
285
+ check(@k.GetCommState(@handle, dcb), "GetCommState")
286
+ flags = 0x0001 # fBinary; no parity check, no flow control
287
+ flags |= 0x0010 if @dtr # fDtrControl = DTR_CONTROL_ENABLE
288
+ flags |= 0x1000 if @rts # fRtsControl = RTS_CONTROL_ENABLE
289
+ dcb[4, 4] = [baud].pack("V")
290
+ dcb[8, 4] = [flags].pack("V")
291
+ dcb[18, 3] = [8, 0, 0].pack("C3") # 8 data bits, no parity, 1 stop bit
292
+ check(@k.SetCommState(@handle, dcb), "SetCommState")
293
+ end
294
+
295
+ def read(max, timeout)
296
+ ms = (timeout * 1000).ceil
297
+ if @read_timeout != ms
298
+ # Return as soon as any byte is there, or after +ms+ when nothing comes.
299
+ timeouts = ms.zero? ? [MAXDWORD, 0, 0, 0, 0] : [MAXDWORD, MAXDWORD, ms, 0, 0]
300
+ check(@k.SetCommTimeouts(@handle, timeouts.pack("V5")), "SetCommTimeouts")
301
+ @read_timeout = ms
302
+ end
303
+ buf = "\0".b * max
304
+ count = [0].pack("V")
305
+ raise Disconnected, "#{@path}: ReadFile failed" if @k.ReadFile(@handle, buf, max, count, nil).zero?
306
+
307
+ buf.byteslice(0, count.unpack1("V"))
308
+ end
309
+
310
+ def write(bytes)
311
+ bytes = bytes.b
312
+ until bytes.empty?
313
+ count = [0].pack("V")
314
+ raise Disconnected, "#{@path}: WriteFile failed" if @k.WriteFile(@handle, bytes, bytes.bytesize, count, nil).zero?
315
+
316
+ bytes = bytes.byteslice(count.unpack1("V")..)
317
+ end
318
+ end
319
+
320
+ # DTR first: usbser.sys only passes a DTR change on together with the next RTS one.
321
+ def set_lines(dtr: nil, rts: nil)
322
+ unless dtr.nil?
323
+ @dtr = dtr
324
+ @k.EscapeCommFunction(@handle, dtr ? SETDTR : CLRDTR)
325
+ end
326
+ return if rts.nil? && dtr.nil?
327
+
328
+ @rts = rts unless rts.nil?
329
+ @k.EscapeCommFunction(@handle, @rts ? SETRTS : CLRRTS)
330
+ end
331
+
332
+ def discard_input
333
+ @k.PurgeComm(@handle, PURGE_RXCLEAR)
334
+ end
335
+
336
+ def close
337
+ @k.CloseHandle(@handle) if @handle
338
+ @handle = nil
339
+ end
340
+
341
+ private
342
+
343
+ def check(result, what)
344
+ raise Error, "#{what} failed on #{@path} (error #{@k.GetLastError})" if result.zero?
345
+ end
346
+ end
347
+ end
348
+ end