From 74fe3023caf67fc3cd216b2f56ecb0e0ce8333e6 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Sat, 20 Jul 2019 04:01:14 -0400 Subject: [PATCH 1/8] Use FFI only for configuration, and utilize RubyIO for all IO. This adds proper async IO, but breaks API compatibility because I renamed BAUDE_RATE to BAUD_RATE. Adds SerialPort compatibility class. Requires patches to work on JRuby prior to 9.2.8.0 on Windows. --- lib/rubyserial.rb | 130 +++++++++++++ lib/rubyserial/linux_constants.rb | 16 +- lib/rubyserial/osx_constants.rb | 15 +- lib/rubyserial/posix.rb | 167 +++++++---------- lib/rubyserial/version.rb | 2 +- lib/rubyserial/windows.rb | 238 +++++++++++++++--------- lib/rubyserial/windows_constants.rb | 16 +- spec/rubyserial_spec.rb | 4 +- spec/serial_arduino_spec.rb | 274 ++++++++++++++++++++++++++++ 9 files changed, 655 insertions(+), 207 deletions(-) create mode 100644 spec/serial_arduino_spec.rb diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index a98bc88..d835cf2 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -10,6 +10,7 @@ class Error < IOError end end +# Load the appropriate RubySerial::Builder if RubySerial::ON_WINDOWS require 'rubyserial/windows_constants' require 'rubyserial/windows' @@ -21,3 +22,132 @@ class Error < IOError end require 'rubyserial/posix' end + +# Generic IO interface +class SerialIO < IO + include RubySerial::Includes + def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, parent: SerialIO, blocking: true) + serial, name, fd = RubySerial::Builder.build(parent: parent, address: address, baud: baud_rate, data_bits: data_bits, parity: parity, stop_bits: stop_bits, blocking: blocking) + serial.send :name=, name + serial.send :fd=, fd + serial + end + + # TODO: reconfigure, etc + + def inspect + "#<#{self.class.name}:#{name}>" + end + + attr_reader :name + + private + attr_writer :name, :fd + attr_reader :fd +end + +# serial-port-style interface +class SerialPort < IO + include RubySerial::Includes + def self.new(device, *params) + raise "NNNNNNN" if device.is_a? Integer + listargs = *params + listargs = listargs["baud"], listargs["data_bits"], listargs["stop_bits"], listargs["parity"] if listargs.is_a? Hash + baud, data, stop, par = *listargs + + args = {parent: SerialPort, + address: device, + baud: baud, + blocking: true} + + # use defaults, not nil + args[:data_bits] = data if data + args[:parity] = par if par + args[:stop_bits] = stop if stop + + serial, name, fd = RubySerial::Builder.build(**args) + serial.send :name=, name + serial.send :fd=, fd + serial + end + + def self.open(*args) + arg = SerialPort.new(*args) + begin + yield arg + ensure + arg.close + end + end + NONE = :none + SPACE = :space + MARK = :mark + EVEN = :even + ODD = :odd + + def hupcl= value + value = !!value + reconfigure(false, hupcl: value) + value + end + + def baud= value + reconfigure(false, baud: value) + end + def data_bits= value + reconfigure(false, data_bits: value) + end + def parity= value + reconfigure(false, parity: value) + end + def stop_bits= value + reconfigure(false, stop_bits: value) + end + + def reconfigzure(**kwargs) + RubySerial::Builder.reconfigure(@fd, false, **kwargs) + kwargs.to_a[0][1] + end + + private + attr_writer :name, :fd + +end + +# rubyserial-style interface +class Serial < SerialIO + def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1) + super(address, baud_rate, data_bits, parity, stop_bits, parent: Serial, blocking: false) + end + + def read(*args) + res = super + res.nil? ? '' : res + end + + def gets(sep=$/, limit=nil) + if block_given? + loop do + yield(get_until_sep(sep, limit)) + end + else + get_until_sep(sep, limit) + end + end + + private + + def get_until_sep(sep, limit) + sep = "\n\n" if sep == '' + # This allows the method signature to be (sep) or (limit) + (limit = sep; sep="\n") if sep.is_a? Integer + bytes = [] + loop do + current_byte = getbyte + bytes << current_byte unless current_byte.nil? + break if (bytes.last(sep.bytes.to_a.size) == sep.bytes.to_a) || ((bytes.size == limit) if limit) + end + + bytes.map { |e| e.chr }.join + end +end diff --git a/lib/rubyserial/linux_constants.rb b/lib/rubyserial/linux_constants.rb index 3dfeac9..7dd875b 100644 --- a/lib/rubyserial/linux_constants.rb +++ b/lib/rubyserial/linux_constants.rb @@ -16,11 +16,19 @@ module Posix IGNPAR = 0000004 PARENB = 0000400 PARODD = 0001000 + PARITY_FIELD = PARENB | PARODD CSTOPB = 0000100 CREAD = 0000200 CLOCAL = 0004000 VMIN = 6 NCCS = 32 + IXON = 0002000 + IXANY = 0004000 + IXOFF = 0010000 + CRTSCTS = 020000000000 + CSIZE = 0000060 + CBAUD = 0010017 + HUPCL = 0002000 DATA_BITS = { 5 => 0000000, @@ -29,7 +37,7 @@ module Posix 8 => 0000060 } - BAUDE_RATES = { + BAUD_RATES = { 0 => 0000000, 50 => 0000001, 75 => 0000002, @@ -217,12 +225,8 @@ class Termios < FFI::Struct :c_ospeed, :uint end - attach_function :ioctl, [ :int, :ulong, RubySerial::Posix::Termios], :int, blocking: true attach_function :tcsetattr, [ :int, :int, RubySerial::Posix::Termios ], :int, blocking: true + attach_function :tcgetattr, [ :int, RubySerial::Posix::Termios ], :int, blocking: true attach_function :fcntl, [:int, :int, :varargs], :int, blocking: true - attach_function :open, [:pointer, :int], :int, blocking: true - attach_function :close, [:int], :int, blocking: true - attach_function :write, [:int, :pointer, :int],:int, blocking: true - attach_function :read, [:int, :pointer, :int],:int, blocking: true end end diff --git a/lib/rubyserial/osx_constants.rb b/lib/rubyserial/osx_constants.rb index 9eee3f5..7c534dd 100644 --- a/lib/rubyserial/osx_constants.rb +++ b/lib/rubyserial/osx_constants.rb @@ -13,14 +13,22 @@ module Posix IGNPAR = 0x00000004 PARENB = 0x00001000 PARODD = 0x00002000 + PARITY_FIELD = PARENB | PARODD VMIN = 16 VTIME = 17 CLOCAL = 0x00008000 CSTOPB = 0x00000400 CREAD = 0x00000800 CCTS_OFLOW = 0x00010000 # Clearing this disables RTS AND CTS. + CRTS_IFLOW = 0x00020000 TCSANOW = 0 NCCS = 20 + IXON = 0x00000200 + IXOFF = 0x00000400 + IXANY = 0x00000800 + CRTSCTS = CCTS_OFLOW | CRTS_IFLOW + CSIZE = 0x00000300 + HUPCL = 0x00004000 DATA_BITS = { 5 => 0x00000000, @@ -29,7 +37,7 @@ module Posix 8 => 0x00000300 } - BAUDE_RATES = { + BAUD_RATES = { 0 => 0, 50 => 50, 75 => 75, @@ -187,10 +195,7 @@ class Termios < FFI::Struct end attach_function :tcsetattr, [ :int, :int, RubySerial::Posix::Termios ], :int, blocking: true + attach_function :tcgetattr, [ :int, RubySerial::Posix::Termios ], :int, blocking: true attach_function :fcntl, [:int, :int, :varargs], :int, blocking: true - attach_function :open, [:pointer, :int], :int, blocking: true - attach_function :close, [:int], :int, blocking: true - attach_function :write, [:int, :pointer, :int],:int, blocking: true - attach_function :read, [:int, :pointer, :int],:int, blocking: true end end diff --git a/lib/rubyserial/posix.rb b/lib/rubyserial/posix.rb index 4b097a4..b66129b 100644 --- a/lib/rubyserial/posix.rb +++ b/lib/rubyserial/posix.rb @@ -1,132 +1,97 @@ # Copyright (c) 2014-2016 The Hybrid Group -class Serial - def initialize(address, baude_rate=9600, data_bits=8, parity=:none, stop_bits=1) - file_opts = RubySerial::Posix::O_RDWR | RubySerial::Posix::O_NOCTTY | RubySerial::Posix::O_NONBLOCK - @fd = RubySerial::Posix.open(address, file_opts) - - if @fd == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] - else - @open = true - end - - fl = RubySerial::Posix.fcntl(@fd, RubySerial::Posix::F_GETFL, :int, 0) - if fl == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] +class RubySerial::Builder + def self.build(address: , parent: IO, baud: 9600, data_bits: 8, parity: :none, stop_bits: 1, blocking: true, clear_config: true) + fd = IO::sysopen(address, File::RDWR | File::NOCTTY | File::NONBLOCK) + + # enable blocking mode + if blocking + fl = ffi_call(:fcntl, fd, RubySerial::Posix::F_GETFL, :int, 0) + ffi_call(:fcntl, fd, RubySerial::Posix::F_SETFL, :int, ~RubySerial::Posix::O_NONBLOCK & fl) end - err = RubySerial::Posix.fcntl(@fd, RubySerial::Posix::F_SETFL, :int, ~RubySerial::Posix::O_NONBLOCK & fl) - if err == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] - end + # Update the terminal settings + reconfigure(fd, clear_config, min: (blocking ? 1 : 0), baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits) - @config = build_config(baude_rate, data_bits, parity, stop_bits) - - err = RubySerial::Posix.tcsetattr(@fd, RubySerial::Posix::TCSANOW, @config) - if err == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] + file = parent.send(:for_fd, fd, File::RDWR | File::SYNC) + file._posix_fd = fd + unless file.tty? + raise ArgumentError, "not a serial port: #{address}" end + [file, address, fd] end - def closed? - !@open + def self.reconfigure(fd, clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) + # Update the terminal settings + config = RubySerial::Posix::Termios.new + ffi_call(:tcgetattr, fd, config) + edit_config(config, clear_config, baud_rate: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, hupcl: hupcl, min: min) + ffi_call(:tcsetattr, fd, RubySerial::Posix::TCSANOW, config) end - def close - err = RubySerial::Posix.close(@fd) - if err == -1 + private + + def self.ffi_call target, *args + res = RubySerial::Posix.send(target, *args) + if res == -1 raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] - else - @open = false end + res end - def write(data) - data = data.to_s - n = 0 - while data.size > n do - buff = FFI::MemoryPointer.from_string(data[n..-1].to_s) - i = RubySerial::Posix.write(@fd, buff, buff.size-1) - if i == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] + def self.set config, field, flag, value, map = nil + return if value.nil? + trueval = if map.nil? + if !!value == value # boolean values set to the flag + value ? flag : 0 else - n = n+i + value end + else + map[value] end - - # return number of bytes written - n - end - - def read(size) - buff = FFI::MemoryPointer.new :char, size - i = RubySerial::Posix.read(@fd, buff, size) - if i == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] - end - buff.get_bytes(0, i) + raise RubySerial::Error, "Values out of range: #{value}" unless trueval.is_a? Integer + # mask the whole field, and set new value + config[field] = (config[field] & ~flag) | trueval end - def getbyte - buff = FFI::MemoryPointer.new :char, 1 - i = RubySerial::Posix.read(@fd, buff, 1) - if i == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] + def self.edit_config(config, clear, min: nil, baud_rate: nil, data_bits: nil, parity: nil, stop_bits: nil, hupcl: nil) + if clear + # reset everything except for flow settings + config[:c_iflag] &= (RubySerial::Posix::IXON | RubySerial::Posix::IXOFF | RubySerial::Posix::IXANY | RubySerial::Posix::CRTSCTS) + config[:c_iflag] |= RubySerial::Posix::IGNPAR + config[:c_oflag] = 0 + config[:c_cflag] = RubySerial::Posix::CREAD | RubySerial::Posix::CLOCAL + config[:c_lflag] = 0 end - if i == 0 - nil - else - buff.get_bytes(0,1).bytes.first - end - end + config[:cc_c][RubySerial::Posix::VMIN] = min unless min.nil? - def gets(sep=$/, limit=nil) - if block_given? - loop do - yield(get_until_sep(sep, limit)) + unless baud_rate.nil? + # Masking in baud rate on OS X would corrupt the settings. + if RubySerial::ON_LINUX + set config, :c_cflag, RubySerial::Posix::CBAUD, baud_rate, RubySerial::Posix::BAUD_RATES end - else - get_until_sep(sep, limit) - end - end - - private - def get_until_sep(sep, limit) - sep = "\n\n" if sep == '' - # This allows the method signature to be (sep) or (limit) - (limit = sep; sep="\n") if sep.is_a? Integer - bytes = [] - loop do - current_byte = getbyte - bytes << current_byte unless current_byte.nil? - break if (bytes.last(sep.bytes.to_a.size) == sep.bytes.to_a) || ((bytes.size == limit) if limit) + config[:c_ospeed] = config[:c_ispeed] = RubySerial::Posix::BAUD_RATES[baud_rate] end - bytes.map { |e| e.chr }.join - end - - def build_config(baude_rate, data_bits, parity, stop_bits) - config = RubySerial::Posix::Termios.new + set config, :c_cflag, RubySerial::Posix::CSIZE, data_bits, RubySerial::Posix::DATA_BITS + set config, :c_cflag, RubySerial::Posix::PARITY_FIELD, parity, RubySerial::Posix::PARITY + set config, :c_cflag, RubySerial::Posix::CSTOPB, stop_bits, RubySerial::Posix::STOPBITS + set config, :c_cflag, RubySerial::Posix::HUPCL, hupcl - config[:c_iflag] = RubySerial::Posix::IGNPAR - config[:c_ispeed] = RubySerial::Posix::BAUDE_RATES[baude_rate] - config[:c_ospeed] = RubySerial::Posix::BAUDE_RATES[baude_rate] - config[:c_cflag] = RubySerial::Posix::DATA_BITS[data_bits] | - RubySerial::Posix::CREAD | - RubySerial::Posix::CLOCAL | - RubySerial::Posix::PARITY[parity] | - RubySerial::Posix::STOPBITS[stop_bits] - - # Masking in baud rate on OS X would corrupt the settings. - if RubySerial::ON_LINUX - config[:c_cflag] = config[:c_cflag] | RubySerial::Posix::BAUDE_RATES[baude_rate] - end + config + end +end - config[:cc_c][RubySerial::Posix::VMIN] = 0 +# Module that must be included in the parent class for RubySerial::Builder to work correctly +module RubySerial::Includes + def reconfigure(clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) + RubySerial::Builder.reconfigure(@_rs_fd, clear_config, hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, min: min) + end - config + def _posix_fd= fd + @_rs_fd = fd end end diff --git a/lib/rubyserial/version.rb b/lib/rubyserial/version.rb index 71a0b46..5a2fd25 100644 --- a/lib/rubyserial/version.rb +++ b/lib/rubyserial/version.rb @@ -2,6 +2,6 @@ module RubySerial unless const_defined?('VERSION') - VERSION = "0.6.0" + VERSION = "1.0.0" end end diff --git a/lib/rubyserial/windows.rb b/lib/rubyserial/windows.rb index 04f35ba..d00eed9 100644 --- a/lib/rubyserial/windows.rb +++ b/lib/rubyserial/windows.rb @@ -1,116 +1,178 @@ # Copyright (c) 2014-2016 The Hybrid Group -class Serial - def initialize(address, baude_rate=9600, data_bits=8, parity=:none, stop_bits=1) - file_opts = RubySerial::Win32::GENERIC_READ | RubySerial::Win32::GENERIC_WRITE - @fd = RubySerial::Win32.CreateFileA("\\\\.\\#{address}", file_opts, 0, nil, RubySerial::Win32::OPEN_EXISTING, 0, nil) - err = FFI.errno - if err != 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[err] - else - @open = true - end +class RubySerial::Builder + def self.build(address: , parent: IO, baud: 9600, data_bits: 8, parity: :none, stop_bits: 1, blocking: true, clear_config: true, winfix: :auto) # TODO: blocking & clear_config + fd = IO::sysopen("\\\\.\\#{address}", File::RDWR) + + # enable blocking mode TODO + + hndl = RubySerial::WinC._get_osfhandle(fd) + # TODO: check errno + + winfix_out = [] # TODO: remove winfix + # Update the terminal settings + _reconfigure(winfix_out, hndl, clear_config, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits) # TODO: min: (blocking ? 1 : 0), + + ffi_call :SetupComm, hndl, 64, 64 + + win32_update_readmode :blocking, hndl + + file = parent.send(:for_fd, fd, File::RDWR) + # windows has no idea + #unless file.tty? + # raise ArgumentError, "not a serial port: #{address}" + #end + file._win32_hndl = hndl + file._winfix = winfix + #file.dtr = false + [file, address, fd] + end + + WIN32_READMODES = { + :blocking => [0, 0, 0], + :partial => [2, 0, 0], + :nonblocking => [0xffff_ffff, 0, 0] + } - RubySerial::Win32::DCB.new.tap do |dcb| - dcb[:dcblength] = RubySerial::Win32::DCB::Sizeof - err = RubySerial::Win32.GetCommState @fd, dcb - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] + def self.win32_update_readmode(mode, hwnd) + t = RubySerial::Win32::CommTimeouts.new + ffi_call :GetCommTimeouts, hwnd, t + raise "ack TODO" if WIN32_READMODES[mode].nil? + t[:read_interval_timeout], t[:read_total_timeout_multiplier], t[:read_total_timeout_constant] = *WIN32_READMODES[mode] + # do we need to set these? + #timeouts[:write_total_timeout_multiplier] #= 1 + #timeouts[:write_total_timeout_constant] #= 10 + # puts "comT: #{w32_pct t}" + ffi_call :SetCommTimeouts, hwnd, t + end + + + + def self._reconfigure(io, hndl, clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) + + + # Update the terminal settings + dcb = RubySerial::Win32::DCB.new + dcb[:dcblength] = RubySerial::Win32::DCB::Sizeof + ffi_call :GetCommState, hndl, dcb + dcb[:baudrate] = baud if baud + dcb[:bytesize] = data_bits if data_bits + dcb[:stopbits] = RubySerial::Win32::DCB::STOPBITS[stop_bits] if stop_bits + dcb[:parity] = RubySerial::Win32::DCB::PARITY[parity] if parity + + dcb[:flags] &= ~(0x3000) # clear + #doreset = + unless hupcl.nil? + cfl = (dcb[:flags] & 48) / 16 + dtr = hupcl + rts = hupcl ? 0 : 0 + # p cfl + dcb[:flags] &= ~(48 + 0x3000) # clear + dcb[:flags] |= 16 if dtr # set + dcb[:flags] |= 0x1000*rts # set + if cfl > 0 && !dtr + # TODO: ??? end - dcb[:baudrate] = baude_rate - dcb[:bytesize] = data_bits - dcb[:stopbits] = RubySerial::Win32::DCB::STOPBITS[stop_bits] - dcb[:parity] = RubySerial::Win32::DCB::PARITY[parity] - err = RubySerial::Win32.SetCommState @fd, dcb - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] end - end +# dcb[:flags] &= ~48 +# DTR control + #p dcb[:flags] # 4225, binary, txcontinuexonxoff, rtscontrol=1 + #p w32_dab(dcb) + #4241 = 4225 +fDtrControl=1 + ffi_call :SetCommState, hndl, dcb - RubySerial::Win32::CommTimeouts.new.tap do |timeouts| - timeouts[:read_interval_timeout] = 10 - timeouts[:read_total_timeout_multiplier] = 1 - timeouts[:read_total_timeout_constant] = 10 - timeouts[:write_total_timeout_multiplier] = 1 - timeouts[:write_total_timeout_constant] = 10 - err = RubySerial::Win32.SetCommTimeouts @fd, timeouts - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] - end - end end - def read(size) - buff = FFI::MemoryPointer.new :char, size - count = FFI::MemoryPointer.new :uint32, 1 - err = RubySerial::Win32.ReadFile(@fd, buff, size, count, nil) - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] - end - buff.get_bytes(0, count.read_int) + def self.w32_dab(t) + [ :dcblength, + :baudrate, + :flags, + :wreserved, + :xonlim, + :xofflim, + :bytesize, + :parity, + :stopbits, + :xonchar, + :xoffchar, + :errorchar, + :eofchar, + :evtchar, + :wreserved1].map{|x|t[x]} end - def getbyte - buff = FFI::MemoryPointer.new :char, 1 - count = FFI::MemoryPointer.new :uint32, 1 - err = RubySerial::Win32.ReadFile(@fd, buff, 1, count, nil) - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] - end + def self.w32_pct(t) + [:read_interval_timeout, + :read_total_timeout_multiplier, + :read_total_timeout_constant, + :write_total_timeout_multiplier, + :write_total_timeout_constant].map{|f| t[f]} + end - if count.read_int == 0 - nil - else - buff.get_bytes(0, 1).bytes.first - end + def self.ffi_call who, *args + res = RubySerial::Win32.send who, *args + if res == 0 + raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] + end + res end - def write(data) - buff = FFI::MemoryPointer.from_string(data.to_s) - count = FFI::MemoryPointer.new :uint32, 1 - err = RubySerial::Win32.WriteFile(@fd, buff, buff.size-1, count, nil) - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] - end - count.read_int +end +# Copyright (c) 2014-2016 The Hybrid Group + +module RubySerial::Includes + def readpartial(*args, _bypass: false) + change_win32_mode :partial unless _bypass + super(*args) end - def gets(sep=$/, limit=nil) - if block_given? - loop do - yield(get_until_sep(sep, limit)) - end + def read_nonblock(maxlen, buf=nil, exception: true) + change_win32_mode :nonblocking + if buf.nil? + readpartial(maxlen, _bypass: true) else - get_until_sep(sep, limit) + readpartial(maxlen, buf, _bypass: true) end + rescue EOFError + raise IO::EAGAINWaitReadable, "Resource temporarily unavailable - read would block" end - def close - err = RubySerial::Win32.CloseHandle(@fd) - if err == 0 - raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] - else - @open = false + [:read, :pread, :readbyte, :readchar, :readline, :readlines, :sysread, :getbyte, :getc, :gets].each do |name| + define_method name do |*args| + change_win32_mode :blocking + super(*args) end end - def closed? - !@open + def write_nonblock(*args) + # TODO: support write_nonblock on windows + write(*args) end - private + def change_win32_mode type + return if @_win32_curr_read_mode == type + # Ugh, have to change the mode now + RubySerial::Builder.win32_update_readmode(type, @_rs_hwnd) + @_win32_curr_read_mode = type + end - def get_until_sep(sep, limit) - sep = "\n\n" if sep == '' - # This allows the method signature to be (sep) or (limit) - (limit = sep; sep="\n") if sep.is_a? Integer - bytes = [] - loop do - current_byte = getbyte - bytes << current_byte unless current_byte.nil? - break if (bytes.last(sep.bytes.to_a.size) == sep.bytes.to_a) || ((bytes.size == limit) if limit) - end + def _win32_hndl= hwnd + @_rs_hwnd = hwnd + @_win32_curr_read_mode = :blocking + end + + def reconfigure(clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) + RubySerial::Builder._reconfigure(self, @_rs_hwnd, clear_config, hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, min: min) + end + + def dtr= val + RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 5 : 6) + end + + def rts= val + RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 3 : 4) + end - bytes.map { |e| e.chr }.join + def _winfix= val end end diff --git a/lib/rubyserial/windows_constants.rb b/lib/rubyserial/windows_constants.rb index 514cc69..2353806 100644 --- a/lib/rubyserial/windows_constants.rb +++ b/lib/rubyserial/windows_constants.rb @@ -1,6 +1,15 @@ # Copyright (c) 2014-2016 The Hybrid Group module RubySerial + module WinC + extend FFI::Library + ffi_lib 'msvcrt' + ffi_convention :stdcall + + attach_function :_open_osfhandle, [:pointer, :int], :int, blocking: true + attach_function :_get_osfhandle, [:int], :pointer, blocking: true + end + module Win32 extend FFI::Library ffi_lib 'kernel32' @@ -277,13 +286,12 @@ class CommTimeouts < FFI::Struct :write_total_timeout_constant, :uint32 end - attach_function :CreateFileA, [:pointer, :uint32, :uint32, :pointer, :uint32, :uint32, :pointer], :pointer, blocking: true - attach_function :CloseHandle, [:pointer], :int, blocking: true - attach_function :ReadFile, [:pointer, :pointer, :uint32, :pointer, :pointer], :int32, blocking: true - attach_function :WriteFile, [:pointer, :pointer, :uint32, :pointer, :pointer], :int32, blocking: true + attach_function :SetupComm, [:pointer, :uint32, :uint32], :int32, blocking: true attach_function :GetCommState, [:pointer, RubySerial::Win32::DCB], :int32, blocking: true attach_function :SetCommState, [:pointer, RubySerial::Win32::DCB], :int32, blocking: true attach_function :GetCommTimeouts, [:pointer, RubySerial::Win32::CommTimeouts], :int32, blocking: true attach_function :SetCommTimeouts, [:pointer, RubySerial::Win32::CommTimeouts], :int32, blocking: true + # TODO, expose this? + attach_function :EscapeCommFunction, [:pointer, :uint32], :int32, blocking: true end end diff --git a/spec/rubyserial_spec.rb b/spec/rubyserial_spec.rb index 7380284..e0a545c 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -172,7 +172,7 @@ @sp.close rate = 600 @sp = Serial.new(@ports[1], rate) - fd = @sp.instance_variable_get(:@fd) + fd = @sp.send :fd module RubySerial module Posix attach_function :tcgetattr, [ :int, RubySerial::Posix::Termios ], :int, blocking: true @@ -180,7 +180,7 @@ module Posix end termios = RubySerial::Posix::Termios.new RubySerial::Posix::tcgetattr(fd, termios) - expect(termios[:c_ispeed]).to eql(RubySerial::Posix::BAUDE_RATES[rate]) + expect(termios[:c_ispeed]).to eql(RubySerial::Posix::BAUD_RATES[rate]) end end end diff --git a/spec/serial_arduino_spec.rb b/spec/serial_arduino_spec.rb new file mode 100644 index 0000000..b7d50a6 --- /dev/null +++ b/spec/serial_arduino_spec.rb @@ -0,0 +1,274 @@ +require 'rubyserial' +require 'timeout' + +describe "serialport" do + before do + @ports = [] + if RubySerial::ON_WINDOWS + @port = "COM3" + else + @port = "/dev/ttyUSB0"# SerialPort + end + + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + end + NAR = "narwhales are cool" + + after do + @ser.close + end + + it "should have the arduino" do + Timeout::timeout(3) do + #@ser.dtr = true + dat = @ser.read(1) + expect(dat).not_to be_nil + expect(dat.length).to be(1) + expect(['z', 'w', 'y']).to include(dat) + end + end + it "should read all" do + sleep 3.2 # ensure some data exists + Timeout::timeout(1) do + dat = @ser.readpartial(1024) + expect(dat).not_to be_nil + expect(dat.length).to be >= 2 + expect(dat.length).to be <= 512 + end + end +=begin + it "should test" do + puts "start" + #@ser.dtr = true + p @ser.read(1) + p @ser.read(1) + p "dtr = false" + #@ser.dtr = false + p @ser.write("e") + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + p "dtr = true" + #@ser.dtr = true + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + p "dtr = false" + #@ser.dtr = false + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + p @ser.read(1) + end +=end + it "should reset" do + @ser.hupcl = true + @ser.read_nonblock(1024) rescue nil # remove the old garbage if it exists + @ser.close + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + Timeout::timeout(3) do + dat = @ser.read(1) + dat = @ser.read(1) if /[wy]/ === dat + expect(dat).to eq("z") + end + end + + + it "should not reset" do + Timeout::timeout(4) do + @ser.hupcl = false + @ser.close # stil resets + @ser = SerialPort.new(@port, 57600, 8, 1, :none) # reopen with hupcl set + + sleep 0.25 + @ser.readpartial(1024) # remove the z + + @ser.close # should NOT reset + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + expect(@ser.read(1)).not_to eq("z") + end + end + + it "should write" do + Timeout::timeout(2) do + @ser.write "iy" + @ser.flush + @ser.readpartial(1024) # remove the z + + @ser << "ex" + + dat = @ser.read(4) + expect(dat).not_to be_nil + expect(dat).to eq("echo") + end + end + + it "should timeout" do + #expect(@ser.readpartial(1024)).to end_with("z") + Timeout::timeout(2) do + @ser.write "a12345" + dat = @ser.readpartial(1024) + expect(dat).not_to be_nil + expect(dat).to end_with("12345") + end + end + it "should capture partial" do + #expect(@ser.readpartial(1024)).to end_with("z") + Timeout::timeout(2) do + @ser.write "riiwwwwa12345" + dat = @ser.readpartial(1024) + expect(dat).not_to be_nil + expect(dat).to end_with("AB") + + dat = @ser.readpartial(1024) + expect(dat).not_to be_nil + expect(dat).to eq("12345") + end + end + it "should wait" do + #expect(@ser.readpartial(1024)).to end_with("z") + Timeout::timeout(2) do + @ser.write "riwewwwwa98765" + dat = @ser.readpartial(1024) # remove the A + expect(dat).not_to be_nil + expect(dat).to end_with("A") + + dat = @ser.read(9) + expect(dat).not_to be_nil + expect(dat).to eq("echo98765") + end + end + + it "should reset (with write)" do + Timeout::timeout(4) do + @ser.hupcl = true + @ser.write_nonblock("eiiiny") + sleep 0.1 + @ser.readpartial(1024) # remove the garbage + @ser.close + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + + expect(@ser.read(1)).to eq("z") + expect(@ser.write_nonblock("iiiiir")).to eq(6) + expect(@ser.read(3)).to eq("ABC") + end + end + + + it "should not reset (with write)" do + Timeout::timeout(4) do + @ser.hupcl = false + @ser.close # stil resets + @ser = SerialPort.new(@port, 57600, 8, 1, :none) # reopen with hupcl set + @ser.write "ey" + sleep 1 + @ser.readpartial(1024) # remove the z + @ser << "riin" + @ser.flush # windows flush??? + sleep 0.1 + dat = @ser.read_nonblock(2 + NAR.length) + expect(dat).to eq("AB#{NAR}") + + @ser.close # should NOT reset + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + @ser << "ie" + sleep 0.1 + expect(@ser.readpartial(1024)).to eq("Cecho") + @ser.hupcl = true + end + end + + + it "should support changing baud" do + Timeout::timeout(6) do + expect(@ser.read(1)).to eq("z") + @ser.puts "ye" + @ser.hupcl = true + sleep 0.1 + dat = @ser.readpartial(1024) + if dat.end_with? "echo!!" + expect(dat).to end_with("echo!!") # mri on windows + else + expect(dat).to end_with("echo!") + end + + @ser.print ["b", 19200, 6].pack("aVC") + @ser.flush + sleep 0.150 # wait for new baud to appear + @ser.baud = 19200 + #sleep 1 + #p @ser.readpartial(NAR.length) + expect(@ser.read(1)).to eq("B") + @ser << "n" + sleep 0.1 + dat = @ser.readpartial(NAR.length) + expect(dat).to eq(NAR) + + + @ser.print ["b", 9600, 6].pack("aVC") + @ser.flush + sleep 0.150 # wait for new baud to appear + @ser.baud = 9600 + expect(@ser.read(1)).to eq("B") + @ser << "e" + sleep 0.1 + dat = @ser.readpartial(5) + expect(dat).to eq("echo") + end + end + + + it "should support changing settings" do + Timeout::timeout(12) do + expect(@ser.read(1)).to eq("z") + @ser.puts "ye" + @ser.hupcl = false + sleep 0.5 + dat = @ser.readpartial(1024) + if dat.end_with? "echo!!" + expect(dat).to end_with("echo!!") # mri on windows + else + expect(dat).to end_with("echo!") + end + + @ser.print ["b", 19200, 0x2e].pack("aVC") + @ser.flush + sleep 0.050 # wait for new baud to appear + @ser.baud = 19200 + @ser.data_bits = 8 + @ser.parity = :even + expect(@ser.stop_bits = 2).to eq(2) + #sleep 1 + #p @ser.readpartial(NAR.length) + expect(@ser.read(1)).to eq("B") + @ser << "n" + sleep 0.1 + dat = @ser.readpartial(NAR.length) + expect(dat).to eq(NAR) + + + @ser.print ["b", 19200, 0x34].pack("aVC") + @ser.flush + sleep 0.050 # wait for new baud to appear + @ser.baud = 19200 + @ser.data_bits = 7 + @ser.parity = :odd + @ser.stop_bits = 1 + expect(@ser.read(1)).to eq("B") + @ser << "e" + sleep 0.1 + dat = @ser.readpartial(5) + expect(dat).to eq("echo") + end + end + + it "should be inspectable" do + @ser.hupcl = true + expect(@ser.to_s).to start_with "# Date: Sun, 21 Jul 2019 21:20:59 -0400 Subject: [PATCH 2/8] Clean up configuration, add serialport test, and doc it all --- README.md | 2 +- lib/rubyserial.rb | 228 ++++++++++++++++++++++------ lib/rubyserial/configuration.rb | 23 +++ lib/rubyserial/linux_constants.rb | 5 +- lib/rubyserial/osx_constants.rb | 4 + lib/rubyserial/posix.rb | 99 ++++++++---- lib/rubyserial/version.rb | 2 + lib/rubyserial/windows.rb | 197 +++++++++++------------- lib/rubyserial/windows_constants.rb | 45 ++++++ spec/rubyserial_spec.rb | 5 - spec/serial_arduino_spec.rb | 12 +- spec/serialport_spec.rb | 140 +++++++++++++++++ 12 files changed, 568 insertions(+), 194 deletions(-) create mode 100644 lib/rubyserial/configuration.rb create mode 100644 spec/serialport_spec.rb diff --git a/README.md b/README.md index 6c5dd5a..62b7f21 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ RubySerial is a simple Ruby gem for reading from and writing to serial ports. Unlike other Ruby serial port implementations, it supports all of the most popular Ruby implementations (MRI, JRuby, & Rubinius) on the most popular operating systems (OSX, Linux, & Windows). And it does not require any native compilation thanks to using RubyFFI [https://github.com/ffi/ffi](https://github.com/ffi/ffi). -The interface to RubySerial should be (mostly) compatible with other Ruby serialport gems, so you should be able to drop in the new gem, change the `require` and use it as a replacement. If not, please let us know so we can address any issues. +The interface to RubySerial should be compatible with other Ruby serialport gems, so you should be able to drop in the new gem, change the `require` and use it as a replacement. If not, please let us know so we can address any issues. [![Build Status](https://travis-ci.org/hybridgroup/rubyserial.svg)](https://travis-ci.org/hybridgroup/rubyserial) [![Build status](https://ci.appveyor.com/api/projects/status/946nlaqy4443vb99/branch/master?svg=true)](https://ci.appveyor.com/project/zankich/rubyserial/branch/master) diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index d835cf2..a8811f8 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -1,15 +1,26 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch require 'rbconfig' require 'ffi' +## +# Low-level API. May change between minor releases. For stability, see {Serial} or {SerialPort}. +# To build serial ports with this API, see {RubySerial::Builder} module RubySerial + # @!visibility private ON_WINDOWS = RbConfig::CONFIG['host_os'] =~ /mswin|windows|mingw/i + # @!visibility private ON_LINUX = RbConfig::CONFIG['host_os'] =~ /linux/i + + # Error thrown for most RubySerial-specific operations. Originates + # from ffi errors. class Error < IOError end end +require 'rubyserial/configuration' + # Load the appropriate RubySerial::Builder if RubySerial::ON_WINDOWS require 'rubyserial/windows_constants' @@ -23,22 +34,31 @@ class Error < IOError require 'rubyserial/posix' end -# Generic IO interface +# Mid-level API. A simple no-fluff serial port interface. Is an IO object. class SerialIO < IO include RubySerial::Includes - def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, parent: SerialIO, blocking: true) - serial, name, fd = RubySerial::Builder.build(parent: parent, address: address, baud: baud_rate, data_bits: data_bits, parity: parity, stop_bits: stop_bits, blocking: blocking) - serial.send :name=, name + ## + # Creates a new {SerialIO} object for the given serial port configuration + # @param [RubySerial::Configuration] config The configuration to open + # @param [Class] parent The parent class to instantiate. Must be a subclass of {SerialIO} + # @return [SerialIO] An intance of parent + # @raise [Errno::ENOENT] If not a valid file + # @raise [RubySerial::Error] If not a valid TTY device (only on non-windows platforms), or any other general FFI error + # @raise [ArgumentError] If arguments are invalid + def self.new(config, parent=SerialIO) + serial, fd, _ = RubySerial::Builder.build(parent, config) + serial.send :name=, config.device serial.send :fd=, fd serial end - # TODO: reconfigure, etc - + # @!visibility private + # Don't doc Object methods def inspect - "#<#{self.class.name}:#{name}>" + "#<#{self.class.name}:#{name}>" # TODO: closed end + # @return [String] The name of the serial port attr_reader :name private @@ -46,85 +66,205 @@ def inspect attr_reader :fd end -# serial-port-style interface +# SerialPort gem style interface. Roughly compatible with the SerialPort gem. Recommended. High-level API, and stable. class SerialPort < IO include RubySerial::Includes + ## + # Creates a new {SerialPort} instance with an API roughly compatible with the SerialPort gem. + # @example + # SerialPort.new("/dev/ttyS0", "baud" => 9600, "data_bits" => 8, "stop_bits" => 1, "parity" => :none) #=> # + # SerialPort.new("/dev/ttyS0", 9600, 8, 1, :none) #=> # + # @param [String] device The serial port name to open + # @return [SerialPort] An opened serial port + # @raise [Errno::ENOENT] If not a valid file + # @raise [RubySerial::Error] Any other general FFI error + # @raise [ArgumentError] If not a valid TTY device (only on non-windows platforms), or if arguments are invalid + # + # @overload new(device, baud=nil, data_bits=nil, stop_bits=nil, parity=nil) + # @param [Integer] baud The baud to open the serial port with, or nil to use the current baud + # @param [Integer] data_bits The number of data_bits to open the serial port with, or nil to use the current data_bits + # @param [Integer] stop_bits The number of stop_bits to open the serial port with, or nil to use the current stop_bits + # @param [Symbol] parity The parity to open the serial port with, or nil to use the current parity. Valid values are: `:none`, `:even`, and `:odd` + # + # @overload new(device, hash) + # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash def self.new(device, *params) - raise "NNNNNNN" if device.is_a? Integer - listargs = *params - listargs = listargs["baud"], listargs["data_bits"], listargs["stop_bits"], listargs["parity"] if listargs.is_a? Hash - baud, data, stop, par = *listargs - - args = {parent: SerialPort, - address: device, - baud: baud, - blocking: true} - - # use defaults, not nil - args[:data_bits] = data if data - args[:parity] = par if par - args[:stop_bits] = stop if stop - - serial, name, fd = RubySerial::Builder.build(**args) - serial.send :name=, name - serial.send :fd=, fd - serial + raise ArgumentError, "Not Implemented. Please use a full path #{device}" if device.is_a? Integer + baud, *listargs = *params + baud, *listargs = baud["baud"], baud["data_bits"], baud["stop_bits"], baud["parity"] if baud.is_a? Hash and listargs == [] + data, stop, par = *listargs + + args = RubySerial::Configuration.from(device: device, baud: baud, enable_blocking: true, data_bits: data, parity: par, stop_bits: stop, clear_config: true) + + begin + serial, _, config = RubySerial::Builder.build(SerialPort, args) + serial.send :name=, device + serial.instance_variable_set :@config, config + serial + rescue RubySerial::Error => e + if e.message == "ENOTTY" + raise ArgumentError, "not a serial port" + else + raise + end + end end - def self.open(*args) - arg = SerialPort.new(*args) + ## + # Creates a new {SerialPort} instance with an API roughly compatible with the SerialPort gem. + # With no associated block, {.open} is a synonym for {.new}. If the optional code block is given, + # it will be passed io as an argument, and the {SerialPort} will automatically be closed when the block + # terminates. In this instance, {.open} returns the value of the block. + # @see .new + # @example + # SerialPort.open("/dev/ttyS0", "baud" => 9600, "data_bits" => 8, "stop_bits" => 1, "parity" => :none) { |s| + # s #=> # + # } + # SerialPort.open("/dev/ttyS0", 9600, 8, 1, :none) { |s| + # s #=> # + # } + # + # @raise [Errno::ENOENT] If not a valid file + # @raise [RubySerial::Error] Any other general FFI error + # @raise [ArgumentError] If not a valid TTY device (only on non-windows platforms), or if arguments are invalid + # + # @overload open(device, baud=nil, data_bits=nil, stop_bits=nil, parity=nil) + # Creates a new {SerialPort} and returns it. + # @return [SerialPort] An opened serial port + # @param [Integer] baud The baud to open the serial port with, or nil to use the current baud + # @param [Integer] data_bits The number of data_bits to open the serial port with, or nil to use the current data_bits + # @param [Integer] stop_bits The number of stop_bits to open the serial port with, or nil to use the current stop_bits + # @param [Symbol] parity The parity to open the serial port with, or nil to use the current parity. Valid values are: `:none`, `:even`, and `:odd` + # @overload open(device, hash) + # Creates a new {SerialPort} and returns it. + # @return [SerialPort] An opened serial port + # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash + # @overload open(device, baud=nil, data_bits=nil, stop_bits=nil, parity=nil) + # Creates a new {SerialPort} and pass it to the provided block, closing automatically. + # @return [Object] What the block returns + # @yieldparam io [SerialPort] An opened serial port + # @param [Integer] baud The baud to open the serial port with, or nil to use the current baud + # @param [Integer] data_bits The number of data_bits to open the serial port with, or nil to use the current data_bits + # @param [Integer] stop_bits The number of stop_bits to open the serial port with, or nil to use the current stop_bits + # @param [Symbol] parity The parity to open the serial port with, or nil to use the current parity. Valid values are: `:none`, `:even`, and `:odd` + # @overload open(device, hash) + # Creates a new {SerialPort} and pass it to the provided block, closing automatically. + # @return [Object] What the block returns + # @yieldparam io [SerialPort] An opened serial port + # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash + def self.open(device, *params) + arg = SerialPort.new(device, *params) + return arg unless block_given? begin yield arg ensure arg.close end end + NONE = :none - SPACE = :space - MARK = :mark EVEN = :even ODD = :odd + # @return [String] the name of the serial port + attr_reader :name + + # @!attribute hupcl + # @return [Boolean] the value of the hupcl flag (posix) or DtrControl is set to start high (windows). Note that you must re-open the port twice to have it take effect def hupcl= value value = !!value - reconfigure(false, hupcl: value) + @config = reconfigure(hupcl: value) value end + def hupcl + @config.hupcl + end + + # @!attribute baud + # @return [Integer] the baud of this serial port def baud= value - reconfigure(false, baud: value) + @config = reconfigure(baud: value) + value end + + def baud + @config.baud + end + + # @!attribute data_bits + # @return [Integer] the number of data bits (typically 8 or 7) def data_bits= value - reconfigure(false, data_bits: value) + @config = reconfigure(data_bits: value) + value end + + def data_bits + @config.data_bits + end + + # @!attribute parity + # @return [Symbol] the parity, one of `:none`, `:even`, or `:odd` def parity= value - reconfigure(false, parity: value) + @config = reconfigure(parity: value) + value end + + def parity + @config.parity + end + + # @!attribute stop_bits + # @return [Integer] the number of stop bits (either 1 or 2) def stop_bits= value - reconfigure(false, stop_bits: value) + @config = reconfigure(stop_bits: value) + value end - def reconfigzure(**kwargs) - RubySerial::Builder.reconfigure(@fd, false, **kwargs) - kwargs.to_a[0][1] + def stop_bits + @config.stop_bits end private - attr_writer :name, :fd - + attr_writer :name end -# rubyserial-style interface +# Custom rubyserial Serial interface. High-level API, and stable. class Serial < SerialIO - def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1) - super(address, baud_rate, data_bits, parity, stop_bits, parent: Serial, blocking: false) + + # Creates a new {Serial} instance, + # @example + # Serial.new("/dev/ttyS0", 9600, 8, :none, 1) #=> # + # @param [String] address The serial port name to open + # @param [Integer] baud_rate The baud to open the serial port with, or nil to use the current baud + # @param [Integer] data_bits The number of data_bits to open the serial port with, or nil to use the current data_bits + # @param [Integer] stop_bits The number of stop_bits to open the serial port with, or nil to use the current stop_bits + # @param [Boolean] enable_blocking If we should enable blocking IO. By default all IO is nonblocking + # @param [Symbol] parity The parity to open the serial port with, or nil to use the current parity. Valid values are: `:none`, `:even`, and `:odd` + # @return [SerialPort] An opened serial port + # @raise [Errno::ENOENT] If not a valid file + # @raise [RubySerial::Error] If not a valid TTY device (only on non-windows platforms), or any other general FFI error + # @raise [ArgumentError] If arguments are invalid + def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, enable_blocking=false) + super(RubySerial::Configuration.from( + device: address, + baud: baud_rate, + data_bits: data_bits, + parity: parity, + stop_bits: stop_bits, + enable_blocking: enable_blocking, + clear_config: true), Serial) end + # @!visibility private + # Don't doc IO methods def read(*args) res = super res.nil? ? '' : res end + # @!visibility private + # Don't doc IO methods def gets(sep=$/, limit=nil) if block_given? loop do diff --git a/lib/rubyserial/configuration.rb b/lib/rubyserial/configuration.rb new file mode 100644 index 0000000..48c0cfa --- /dev/null +++ b/lib/rubyserial/configuration.rb @@ -0,0 +1,23 @@ +# Copyright (c) 2019 Patrick Plenefisch + + +module RubySerial + # TODO: flow_control , :read_timeout, :write_timeout) + + # Configuration Struct passed to create a serial port, or returned back from a reconfiguration. Shows all current configurations. + # When passed as a request, nil means no update, any other value is a request to update. + # @example + # cfg = Configuration.new + # cfg.baud = 9600 + # cfg[:device] = "COM1" + # cfg[:baud] #=> 9600 + Configuration = Struct.new(:device, :baud, :data_bits, :parity, :stop_bits, :hupcl, :enable_blocking, :clear_config) + class Configuration + # Builds a Configuration object using the given keyword arguments + # @example + # Configuration.from(baud: 9600) #=> # + def self.from(**kwargs) + kwargs.each_with_object(new) {|(arg, val),o| o[arg] = val} + end + end +end diff --git a/lib/rubyserial/linux_constants.rb b/lib/rubyserial/linux_constants.rb index 7dd875b..837e333 100644 --- a/lib/rubyserial/linux_constants.rb +++ b/lib/rubyserial/linux_constants.rb @@ -1,8 +1,11 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch module RubySerial + # @api private + # @!visibility private module Posix - extend FFI::Library + extend FFI::Library ffi_lib FFI::Library::LIBC O_NONBLOCK = 00004000 diff --git a/lib/rubyserial/osx_constants.rb b/lib/rubyserial/osx_constants.rb index 7c534dd..8f82134 100644 --- a/lib/rubyserial/osx_constants.rb +++ b/lib/rubyserial/osx_constants.rb @@ -1,6 +1,10 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch + module RubySerial + # @api private + # @!visibility private module Posix extend FFI::Library ffi_lib FFI::Library::LIBC diff --git a/lib/rubyserial/posix.rb b/lib/rubyserial/posix.rb index b66129b..282db7b 100644 --- a/lib/rubyserial/posix.rb +++ b/lib/rubyserial/posix.rb @@ -1,46 +1,67 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch -class RubySerial::Builder - def self.build(address: , parent: IO, baud: 9600, data_bits: 8, parity: :none, stop_bits: 1, blocking: true, clear_config: true) - fd = IO::sysopen(address, File::RDWR | File::NOCTTY | File::NONBLOCK) - # enable blocking mode - if blocking - fl = ffi_call(:fcntl, fd, RubySerial::Posix::F_GETFL, :int, 0) - ffi_call(:fcntl, fd, RubySerial::Posix::F_SETFL, :int, ~RubySerial::Posix::O_NONBLOCK & fl) +## +# Low-level API. May change between minor releases. For stability, see {Serial} or {SerialPort}. +class RubySerial::Builder + ## + # Creates an instance of the given parent class to be a serial port with the given configuration + # + # @example + # RubySerial::Builder.build(SerialIO, RubySerial::Configuration.from(device: "/dev/ttyS0")) + # #=> [#, 3, #] + # + # @param [Class] parent A class that is_a? IO and has included the {RubySerial::Includes} module + # @param [RubySerial::Configuration] config The details of the serial port to open + # @return [parent, Integer, RubySerial::Configuration] A new instance of parent, the file descriptor, and the current configuration of the serial port + # @raise [Errno::ENOENT] If not a valid file + # @raise [RubySerial::Error] If not a TTY device (only on non-windows platforms), or any other general error + def self.build(parent, config) + fd = IO::sysopen(config.device, File::RDWR | File::NOCTTY | File::NONBLOCK) + + # enable blocking mode. I'm not sure why we do it this way, with disable and then re-enable. History suggests that it might be a mac thing + if config.enable_blocking + fl = ffi_call(:fcntl, fd, RubySerial::Posix::F_GETFL, :int, 0) + ffi_call(:fcntl, fd, RubySerial::Posix::F_SETFL, :int, ~RubySerial::Posix::O_NONBLOCK & fl) end # Update the terminal settings - reconfigure(fd, clear_config, min: (blocking ? 1 : 0), baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits) + out_config = reconfigure(fd, config) + out_config.device = config.device file = parent.send(:for_fd, fd, File::RDWR | File::SYNC) - file._posix_fd = fd - unless file.tty? - raise ArgumentError, "not a serial port: #{address}" - end - [file, address, fd] + file.send :_rs_posix_init, fd + + return [file, fd, out_config] end - def self.reconfigure(fd, clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) + # @api private + # @!visibility private + # Reconfigures the given (platform-specific) file handle with the provided configuration. See {RubySerial::Includes#reconfigure} for public API + def self.reconfigure(fd, req_config) # Update the terminal settings config = RubySerial::Posix::Termios.new ffi_call(:tcgetattr, fd, config) - edit_config(config, clear_config, baud_rate: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, hupcl: hupcl, min: min) + out_config = edit_config(config, req_config, min: {nil => nil, true => 1, false => 0}[req_config.enable_blocking]) ffi_call(:tcsetattr, fd, RubySerial::Posix::TCSANOW, config) + out_config end private + # Calls the given FFI target, and raises an error if it fails def self.ffi_call target, *args - res = RubySerial::Posix.send(target, *args) + res = RubySerial::Posix.send(target, *args) if res == -1 raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] end res end + # Sets the given config value (if provided), and returns the current value def self.set config, field, flag, value, map = nil - return if value.nil? + return get((config[field] & flag), map) if value.nil? trueval = if map.nil? if !!value == value # boolean values set to the flag value ? flag : 0 @@ -53,10 +74,19 @@ def self.set config, field, flag, value, map = nil raise RubySerial::Error, "Values out of range: #{value}" unless trueval.is_a? Integer # mask the whole field, and set new value config[field] = (config[field] & ~flag) | trueval + value end - def self.edit_config(config, clear, min: nil, baud_rate: nil, data_bits: nil, parity: nil, stop_bits: nil, hupcl: nil) - if clear + def self.get value, options + return value != 0 if options.nil? + options.key(value) + end + + # Updates the configuration object with the requested configuration + def self.edit_config(config, req, min: nil) + actual = RubySerial::Configuration.from(clear_config: req.clear_config) + + if req.clear_config # reset everything except for flow settings config[:c_iflag] &= (RubySerial::Posix::IXON | RubySerial::Posix::IXOFF | RubySerial::Posix::IXANY | RubySerial::Posix::CRTSCTS) config[:c_iflag] |= RubySerial::Posix::IGNPAR @@ -67,31 +97,36 @@ def self.edit_config(config, clear, min: nil, baud_rate: nil, data_bits: nil, pa config[:cc_c][RubySerial::Posix::VMIN] = min unless min.nil? - unless baud_rate.nil? + unless req.baud.nil? # Masking in baud rate on OS X would corrupt the settings. if RubySerial::ON_LINUX - set config, :c_cflag, RubySerial::Posix::CBAUD, baud_rate, RubySerial::Posix::BAUD_RATES + set config, :c_cflag, RubySerial::Posix::CBAUD, req.baud, RubySerial::Posix::BAUD_RATES end - config[:c_ospeed] = config[:c_ispeed] = RubySerial::Posix::BAUD_RATES[baud_rate] + config[:c_ospeed] = config[:c_ispeed] = RubySerial::Posix::BAUD_RATES[req.baud] end + actual.baud = get config[:c_ispeed], RubySerial::Posix::BAUD_RATES - set config, :c_cflag, RubySerial::Posix::CSIZE, data_bits, RubySerial::Posix::DATA_BITS - set config, :c_cflag, RubySerial::Posix::PARITY_FIELD, parity, RubySerial::Posix::PARITY - set config, :c_cflag, RubySerial::Posix::CSTOPB, stop_bits, RubySerial::Posix::STOPBITS - set config, :c_cflag, RubySerial::Posix::HUPCL, hupcl + actual.data_bits = set config, :c_cflag, RubySerial::Posix::CSIZE, req.data_bits, RubySerial::Posix::DATA_BITS + actual.parity = set config, :c_cflag, RubySerial::Posix::PARITY_FIELD, req.parity, RubySerial::Posix::PARITY + actual.stop_bits = set config, :c_cflag, RubySerial::Posix::CSTOPB, req.stop_bits, RubySerial::Posix::STOPBITS + actual.hupcl = set config, :c_cflag, RubySerial::Posix::HUPCL, req.hupcl - config + return actual end end -# Module that must be included in the parent class for RubySerial::Builder to work correctly +# The module that must be included in the parent class (such as {SerialIO}, {Serial}, or {SerialPort}) for {RubySerial::Builder} to work correctly. These methods are thus on all RubySerial objects. module RubySerial::Includes - def reconfigure(clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) - RubySerial::Builder.reconfigure(@_rs_fd, clear_config, hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, min: min) + # Reconfigures the serial port with the given new values, if provided. Pass nil to keep the current settings. + # @return [RubySerial::Configuration) The currently configured values for this serial port. + def reconfigure(hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil) + RubySerial::Builder.reconfigure(@_rs_posix_fd, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) end - def _posix_fd= fd - @_rs_fd = fd + # TODO: dts set on linux? + private + def _rs_posix_init(fd) + @_rs_posix_fd = fd end end diff --git a/lib/rubyserial/version.rb b/lib/rubyserial/version.rb index 5a2fd25..9d6d0e6 100644 --- a/lib/rubyserial/version.rb +++ b/lib/rubyserial/version.rb @@ -1,7 +1,9 @@ # Copyright (c) 2014-2018 The Hybrid Group and Friends + module RubySerial unless const_defined?('VERSION') + # Version of RubySerial VERSION = "1.0.0" end end diff --git a/lib/rubyserial/windows.rb b/lib/rubyserial/windows.rb index d00eed9..6f227e5 100644 --- a/lib/rubyserial/windows.rb +++ b/lib/rubyserial/windows.rb @@ -1,114 +1,59 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch + class RubySerial::Builder - def self.build(address: , parent: IO, baud: 9600, data_bits: 8, parity: :none, stop_bits: 1, blocking: true, clear_config: true, winfix: :auto) # TODO: blocking & clear_config + def self.build(parent, config) fd = IO::sysopen("\\\\.\\#{address}", File::RDWR) - # enable blocking mode TODO - hndl = RubySerial::WinC._get_osfhandle(fd) # TODO: check errno - winfix_out = [] # TODO: remove winfix # Update the terminal settings - _reconfigure(winfix_out, hndl, clear_config, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits) # TODO: min: (blocking ? 1 : 0), + out_config = reconfigure(hndl, config) # TODO: clear_config + out_config.device = config.device - ffi_call :SetupComm, hndl, 64, 64 + ffi_call :SetupComm, hndl, 64, 64 # Set the buffers to 64 bytes - win32_update_readmode :blocking, hndl + blocking_mode = config.enable_blocking ? :blocking : :nonblocking + win32_update_readmode(blocking_mode, hndl) file = parent.send(:for_fd, fd, File::RDWR) - # windows has no idea - #unless file.tty? - # raise ArgumentError, "not a serial port: #{address}" - #end - file._win32_hndl = hndl - file._winfix = winfix - #file.dtr = false - [file, address, fd] - end + file.send :_rs_win32_init, hndl, blocking_mode - WIN32_READMODES = { - :blocking => [0, 0, 0], - :partial => [2, 0, 0], - :nonblocking => [0xffff_ffff, 0, 0] - } - - def self.win32_update_readmode(mode, hwnd) - t = RubySerial::Win32::CommTimeouts.new - ffi_call :GetCommTimeouts, hwnd, t - raise "ack TODO" if WIN32_READMODES[mode].nil? - t[:read_interval_timeout], t[:read_total_timeout_multiplier], t[:read_total_timeout_constant] = *WIN32_READMODES[mode] - # do we need to set these? - #timeouts[:write_total_timeout_multiplier] #= 1 - #timeouts[:write_total_timeout_constant] #= 10 - # puts "comT: #{w32_pct t}" - ffi_call :SetCommTimeouts, hwnd, t + return [file, fd, out_config] end - - - def self._reconfigure(io, hndl, clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) - - + # @api private + # @!visibility private + # Reconfigures the given (platform-specific) file handle with the provided configuration. See {RubySerial::Includes#reconfigure} for public API + def self.reconfigure(hndl, req_config) # Update the terminal settings dcb = RubySerial::Win32::DCB.new dcb[:dcblength] = RubySerial::Win32::DCB::Sizeof ffi_call :GetCommState, hndl, dcb - dcb[:baudrate] = baud if baud - dcb[:bytesize] = data_bits if data_bits - dcb[:stopbits] = RubySerial::Win32::DCB::STOPBITS[stop_bits] if stop_bits - dcb[:parity] = RubySerial::Win32::DCB::PARITY[parity] if parity - - dcb[:flags] &= ~(0x3000) # clear - #doreset = - unless hupcl.nil? - cfl = (dcb[:flags] & 48) / 16 - dtr = hupcl - rts = hupcl ? 0 : 0 - # p cfl - dcb[:flags] &= ~(48 + 0x3000) # clear - dcb[:flags] |= 16 if dtr # set - dcb[:flags] |= 0x1000*rts # set - if cfl > 0 && !dtr - # TODO: ??? - end - end -# dcb[:flags] &= ~48 -# DTR control - #p dcb[:flags] # 4225, binary, txcontinuexonxoff, rtscontrol=1 - #p w32_dab(dcb) - #4241 = 4225 +fDtrControl=1 - ffi_call :SetCommState, hndl, dcb - + out_config = edit_config(dcb, req_config) + ffi_call :SetCommState, hndl, dcb + out_config end - def self.w32_dab(t) - [ :dcblength, - :baudrate, - :flags, - :wreserved, - :xonlim, - :xofflim, - :bytesize, - :parity, - :stopbits, - :xonchar, - :xoffchar, - :errorchar, - :eofchar, - :evtchar, - :wreserved1].map{|x|t[x]} - end + private + + # Changes the read mode timeouts to accomidate the desired blocking mode + def self.win32_update_readmode(mode, hwnd) + t = RubySerial::Win32::CommTimeouts.new + + ffi_call :GetCommTimeouts, hwnd, t + raise ArgumentError, "Invalid mode: #{mode}" if RubySerial::Win32::CommTimeouts::READ_MODES[mode].nil? - def self.w32_pct(t) - [:read_interval_timeout, - :read_total_timeout_multiplier, - :read_total_timeout_constant, - :write_total_timeout_multiplier, - :write_total_timeout_constant].map{|f| t[f]} + # Leave the write alone, just set the read timeouts + t[:read_interval_timeout], t[:read_total_timeout_multiplier], t[:read_total_timeout_constant] = *RubySerial::Win32::CommTimeouts::READ_MODES[mode] + + #puts "com details: #{t.dbg_a}" + ffi_call :SetCommTimeouts, hwnd, t end + # Calls the given FFI target, and raises an error if it fails def self.ffi_call who, *args res = RubySerial::Win32.send who, *args if res == 0 @@ -117,16 +62,48 @@ def self.ffi_call who, *args res end + # Updates the configuration object with the requested configuration + def self.edit_config(dcb, req) + actual = RubySerial::Configuration.new + + dcb[:baudrate] = req.baud if req.baud + dcb[:bytesize] = req.data_bits if req.data_bits + dcb[:stopbits] = RubySerial::Win32::DCB::STOPBITS[req.stop_bits] if req.stop_bits + dcb[:parity] = RubySerial::Win32::DCB::PARITY[req.parity] if req.parity + + dcb[:flags] &= ~RubySerial::Win32::DCB::FLAGS_RTS # Always clear the RTS bit + unless req.hupcl.nil? + dcb[:flags] &= ~RubySerial::Win32::DCB::DTR_MASK + dcb[:flags] |= ~RubySerial::Win32::DCB::DTR_ENABLED if req.hupcl + end + + actual.baud = dcb[:baudrate] + actual.data_bits = dcb[:bytesize] + actual.stop_bits = RubySerial::Win32::DCB::STOPBITS.key(dcb[:stopbits]) + actual.parity = RubySerial::Win32::DCB::PARITY.key(dcb[:parity]) + actual.hupcl = (dcb[:flags] & RubySerial::Win32::DCB::DTR_MASK) != 0 + + return actual + end end -# Copyright (c) 2014-2016 The Hybrid Group module RubySerial::Includes + def reconfigure(hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil) + RubySerial::Builder.reconfigure(self, @_rs_win32_hndl, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) + end + + # Ruby IO has issues with nonblocking from_fd on windows, so override just to change the underlying timeouts + # @api private + # @!visibility private def readpartial(*args, _bypass: false) change_win32_mode :partial unless _bypass super(*args) end - def read_nonblock(maxlen, buf=nil, exception: true) + # Ruby IO has issues with nonblocking from_fd on windows, so override just to change the underlying timeouts + # @api private + # @!visibility private + def read_nonblock(maxlen, buf=nil, exception: true) # TODO: support exception change_win32_mode :nonblocking if buf.nil? readpartial(maxlen, _bypass: true) @@ -137,42 +114,46 @@ def read_nonblock(maxlen, buf=nil, exception: true) raise IO::EAGAINWaitReadable, "Resource temporarily unavailable - read would block" end + # Ruby IO has issues with nonblocking from_fd on windows, so override just to change the underlying timeouts [:read, :pread, :readbyte, :readchar, :readline, :readlines, :sysread, :getbyte, :getc, :gets].each do |name| define_method name do |*args| - change_win32_mode :blocking + change_win32_mode @_rs_win32_blocking super(*args) end end + # Ruby IO has issuew with nonblocking from_fd on windows, so override just to change the underlying timeouts + # @api private + # @!visibility private def write_nonblock(*args) - # TODO: support write_nonblock on windows + # TODO: properly support write_nonblock on windows write(*args) end + private + + ## + # Updates the timeouts (if applicable) to emulate the requested read type def change_win32_mode type return if @_win32_curr_read_mode == type - # Ugh, have to change the mode now - RubySerial::Builder.win32_update_readmode(type, @_rs_hwnd) - @_win32_curr_read_mode = type - end - def _win32_hndl= hwnd - @_rs_hwnd = hwnd - @_win32_curr_read_mode = :blocking - end - - def reconfigure(clear_config, hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil, min: nil) - RubySerial::Builder._reconfigure(self, @_rs_hwnd, clear_config, hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits, min: min) + # have to change the mode now + RubySerial::Builder.win32_update_readmode(type, @_rs_win32_hndl) + @_win32_curr_read_mode = type end - def dtr= val - RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 5 : 6) + def _rs_win32_init(hndl, blocking_mode) + @_rs_win32_hndl = hndl + @_rs_win32_blocking = blocking_mode + @_rs_win32_curr_read_mode = blocking_mode end - def rts= val - RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 3 : 4) - end + # TODO: make cross platform... + #def dtr= val + # RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 5 : 6) + #end - def _winfix= val - end + #def rts= val + # RubySerial::Builder.ffi_call :EscapeCommFunction, @_rs_hwnd, (val ? 3 : 4) + #end end diff --git a/lib/rubyserial/windows_constants.rb b/lib/rubyserial/windows_constants.rb index 2353806..34eb11f 100644 --- a/lib/rubyserial/windows_constants.rb +++ b/lib/rubyserial/windows_constants.rb @@ -1,6 +1,10 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch + module RubySerial + # @api private + # @!visibility private module WinC extend FFI::Library ffi_lib 'msvcrt' @@ -10,6 +14,8 @@ module WinC attach_function :_get_osfhandle, [:int], :pointer, blocking: true end + # @api private + # @!visibility private module Win32 extend FFI::Library ffi_lib 'kernel32' @@ -276,6 +282,30 @@ class DCB < FFI::Struct :odd => ODDPARITY, :even => EVENPARITY } + + FLAGS_RTS = 0x3000 + + DTR_MASK = 48 + DTR_ENABLED = 16 + + # debug function to return all values as an array + def dbg_a + [ :dcblength, + :baudrate, + :flags, + :wreserved, + :xonlim, + :xofflim, + :bytesize, + :parity, + :stopbits, + :xonchar, + :xoffchar, + :errorchar, + :eofchar, + :evtchar, + :wreserved1].map{|x|self[x]} + end end class CommTimeouts < FFI::Struct @@ -284,6 +314,21 @@ class CommTimeouts < FFI::Struct :read_total_timeout_constant, :uint32, :write_total_timeout_multiplier, :uint32, :write_total_timeout_constant, :uint32 + + # debug function to return all values as an array + def dbg_a + [:read_interval_timeout, + :read_total_timeout_multiplier, + :read_total_timeout_constant, + :write_total_timeout_multiplier, + :write_total_timeout_constant].map{|f| self[f]} + end + + READ_MODES = { + :blocking => [0, 0, 0], + :partial => [2, 0, 0], + :nonblocking => [0xffff_ffff, 0, 0] + } end attach_function :SetupComm, [:pointer, :uint32, :uint32], :int32, blocking: true diff --git a/spec/rubyserial_spec.rb b/spec/rubyserial_spec.rb index e0a545c..537e0a0 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -173,11 +173,6 @@ rate = 600 @sp = Serial.new(@ports[1], rate) fd = @sp.send :fd - module RubySerial - module Posix - attach_function :tcgetattr, [ :int, RubySerial::Posix::Termios ], :int, blocking: true - end - end termios = RubySerial::Posix::Termios.new RubySerial::Posix::tcgetattr(fd, termios) expect(termios[:c_ispeed]).to eql(RubySerial::Posix::BAUD_RATES[rate]) diff --git a/spec/serial_arduino_spec.rb b/spec/serial_arduino_spec.rb index b7d50a6..868e5de 100644 --- a/spec/serial_arduino_spec.rb +++ b/spec/serial_arduino_spec.rb @@ -1,3 +1,5 @@ +# Copyright (c) 2019 Patrick Plenefisch + require 'rubyserial' require 'timeout' @@ -9,13 +11,17 @@ else @port = "/dev/ttyUSB0"# SerialPort end - - @ser = SerialPort.new(@port, 57600, 8, 1, :none) + @ser = nil + begin + @ser = SerialPort.new(@port, 57600, 8, 1, :none) + rescue Errno::ENOENT + skip "Arduino not connected or port number wrong" + end end NAR = "narwhales are cool" after do - @ser.close + @ser.close if @ser end it "should have the arduino" do diff --git a/spec/serialport_spec.rb b/spec/serialport_spec.rb new file mode 100644 index 0000000..e90d9ad --- /dev/null +++ b/spec/serialport_spec.rb @@ -0,0 +1,140 @@ +# Copyright (c) 2019 Patrick Plenefisch + +require 'rubyserial' +require 'timeout' + +describe "serialport api" do + before do + @ports = [] + if RubySerial::ON_WINDOWS + @port = "COM1" #TODO... + @not_a_port = nil + else + @port = "/dev/ttyS0"# SerialPort + @not_a_port = "/dev/null" + @not_a_file = "/dev/not_a_file" + end + end + + it "should support new" do + s = SerialPort.new(@port) + expect(s.closed?).to be false + expect(s.name).to eq @port + expect(s).to be_an IO + s.close + expect(s.closed?).to be true + end + + it "should only support serial ports" do + skip "Not supported on windows" if @not_a_port.nil? + expect { + s = SerialPort.new(@not_a_port) + s.close + }.to raise_error(ArgumentError, "not a serial port") + + expect { + s = SerialPort.new(@not_a_file) + s.close + }.to raise_error(Errno::ENOENT, /No such file or directory .*- #{@not_a_file}/) + end + + + it "should only support serial ports (serial API)" do + skip "Not supported on windows" if @not_a_port.nil? + expect { + s = Serial.new(@not_a_port) + s.close + }.to raise_error(RubySerial::Error, "ENOTTY") + + expect { + s = Serial.new(@not_a_file) + s.close + }.to raise_error(Errno::ENOENT, /No such file or directory .*- #{@not_a_file}/) + end + + it "should support named args" do + s = SerialPort.new(@port, "baud" => 9600, "data_bits" => 8) + expect(s.baud).to eq 9600 + expect(s.data_bits).to eq 8 + s.close + end + it "should support open" do + outers = nil + SerialPort.open(@port) do |s| + outers = s + expect(s.closed?).to be false + expect(s.name).to eq @port + expect(s).to be_an IO + end + expect(outers.closed?).to be true + end + it "should support open args" do + SerialPort.open(@port, 19200, 7, 2, :odd) do |s| + expect(s.baud).to eq 19200 + expect(s.data_bits).to eq 7 + expect(s.stop_bits).to eq 2 + expect(s.parity).to eq :odd + end + end + + it "should support open named args" do + SerialPort.open(@port, "baud" => 57600, "data_bits" => 6, "stop_bits" => 1, "parity" => :even) do |s| + p s + p s.to_s + expect(s.baud).to eq 57600 + expect(s.data_bits).to eq 6 + expect(s.stop_bits).to eq 1 + expect(s.parity).to eq :even + end + end + + it "should support changing values" do + SerialPort.open(@port, "baud" => 9600, "data_bits" => 7, "stop_bits" => 2, "parity" => :odd) do |s| + expect(s.baud).to eq 9600 + expect(s.data_bits).to eq 7 + expect(s.stop_bits).to eq 2 + expect(s.parity).to eq :odd + + expect(s.baud=19200).to eq 19200 + expect(s.baud).to eq 19200 + expect(s.data_bits).to eq 7 + expect(s.stop_bits).to eq 2 + expect(s.parity).to eq :odd + + expect(s.data_bits=8).to eq 8 + expect(s.baud).to eq 19200 + expect(s.data_bits).to eq 8 + expect(s.stop_bits).to eq 2 + expect(s.parity).to eq :odd + + expect(s.parity=:none).to eq :none + expect(s.baud).to eq 19200 + expect(s.data_bits).to eq 8 + expect(s.stop_bits).to eq 2 + expect(s.parity).to eq :none + + expect(s.stop_bits=1).to eq 1 + expect(s.baud).to eq 19200 + expect(s.data_bits).to eq 8 + expect(s.stop_bits).to eq 1 + expect(s.parity).to eq :none + end + end + + it "should support hupcl" do + SerialPort.open(@port) do |s| + expect(s.hupcl = true).to eq true + expect(s.hupcl).to eq true + s.baud=19200 # re-get the values + expect(s.hupcl).to eq true + expect(s.hupcl = false).to eq false + expect(s.hupcl).to eq false + s.baud=9600 # re-get the values + expect(s.hupcl).to eq false + expect(s.hupcl = true).to eq true + expect(s.hupcl).to eq true + s.baud=19200 # re-get the values + expect(s.hupcl).to eq true + end + end +end From a3ac69b97808081b5e61c2ad46958b0df713b0f2 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Sun, 21 Jul 2019 21:58:05 -0400 Subject: [PATCH 3/8] Doc updates and Readme --- README.md | 53 +++++++++++++++++++++++++++++++++-------------- lib/rubyserial.rb | 25 ++++++++++++++++++---- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 62b7f21..fffbd9a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RubySerial is a simple Ruby gem for reading from and writing to serial ports. -Unlike other Ruby serial port implementations, it supports all of the most popular Ruby implementations (MRI, JRuby, & Rubinius) on the most popular operating systems (OSX, Linux, & Windows). And it does not require any native compilation thanks to using RubyFFI [https://github.com/ffi/ffi](https://github.com/ffi/ffi). +Unlike other Ruby serial port implementations, it supports all of the most popular Ruby implementations (MRI, JRuby, & Rubinius) on the most popular operating systems (OSX, Linux, & Windows). And it does not require any native compilation thanks to using RubyFFI [https://github.com/ffi/ffi](https://github.com/ffi/ffi). Note: Windows requires JRuby >= 9.2.8.0 to fix native IO issues. The interface to RubySerial should be compatible with other Ruby serialport gems, so you should be able to drop in the new gem, change the `require` and use it as a replacement. If not, please let us know so we can address any issues. @@ -14,32 +14,55 @@ The interface to RubySerial should be compatible with other Ruby serialport gems $ gem install rubyserial -## Usage +## Basic Usage ```ruby require 'rubyserial' + +# 0.6 API (nonblocking by default) serialport = Serial.new '/dev/ttyACM0' # Defaults to 9600 baud, 8 data bits, and no parity serialport = Serial.new '/dev/ttyACM0', 57600 -serialport = Serial.new '/dev/ttyACM0', 19200, 8, :even +serialport = Serial.new '/dev/ttyACM0', 19200, :even, 8 +serialport = Serial.new '/dev/ttyACM0', 19200, :even, 8, true # to enable blocking IO + +# SerialPort gem compatible API (blocking IO by default) +serialport = SerialPort.new '/dev/ttyACM0' # Defaults to the existing system settings. +serialport = SerialPort.new '/dev/ttyACM0', 57600 +serialport = SerialPort.new '/dev/ttyACM0', 19200, 8, :even # note the order of args is different + +# open style syntax +SerialPort.open '/dev/ttyACM0', 19200, 8, :even do |serialport| + # ... +end + +# change the settings later +five = SerialPort.open '/dev/ttyACM0' do |serialport| + serialport.baud = 9600 + serialport.data_bits = 8 + serialport.stop_bits = 1 + serialport.parity = :none + serialport.hupcl = false + # ... + 5 +end ``` +Both SerialPort and Serial are an IO object, so standard methods like read and write are available, but do note that Serial has some nonstandard read behavior by default. -## Methods +## Classes -**write(data : String) -> Int** +There are 3 levels of API: -Returns the number of bytes written. -Emits a `RubySerial::Error` on error. +* High level: {SerialPort} and {Serial} +* Medium level: {SerialIO} +* Low level: {RubySerial::Builder.build} -**read(length : Int) -> String** +Most use cases will do fine with the high level API, as those, particularly {SerialPort}, are standard IO objects. {Serial} is also an IO, but with the {Serial#read} and {Serial#getbyte} methods having non-standard return conditions (`""` if no data exists, otherwise `readpartial` for the former, and nonblocking for the latter). For this reason, new code is suggested to use {SerialPort} as `read`/`readpartial`/`read_nonblocking` work as expected in all other IO objects. -Returns a string up to `length` long. It is not guaranteed to return the entire -length specified, and will return an empty string if no data is -available. Emits a `RubySerial::Error` on error. +The medium level API with {SerialIO} also returns an IO object, but allows you to provide your own SerialIO child class to instantiate instead. -**getbyte -> Fixnum or nil** +The low level API is not considered stable, and may change in minor releases. -Returns an 8 bit byte or nil if no data is available. -Emits a `RubySerial::Error` on error. +See the documentation ! TODO link!!! for more details **RubySerial::Error** @@ -47,7 +70,7 @@ A wrapper error type that returns the underlying system error code and inherits ## Running the tests -The test suite is written using rspec, just use the `rspec` command. +The test suite is written using rspec, just use the `rspec` command. There are 3 test files: SerialPort API comatibility `serialport_spec`, Serial API compatability `rubyserial_spec`, and the DTR & timeout correctness test suite `serial_arduino_spec`. The latter requires this !TODO! program flashed to an arduino, or a compatible program that the test can talk to. ### Test dependencies diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index a8811f8..010d4cb 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -88,6 +88,10 @@ class SerialPort < IO # # @overload new(device, hash) # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash + # @option hash [Integer] "baud" The baud. Optional + # @option hash [Integer] "data_bits" The number of data bits. Optional + # @option hash [Integer] "stop" The number of stop bits. Optional + # @option hash [Symbol] "parity" The parity. Optional def self.new(device, *params) raise ArgumentError, "Not Implemented. Please use a full path #{device}" if device.is_a? Integer baud, *listargs = *params @@ -139,6 +143,10 @@ def self.new(device, *params) # Creates a new {SerialPort} and returns it. # @return [SerialPort] An opened serial port # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash + # @option hash [Integer] "baud" The baud. Optional + # @option hash [Integer] "data_bits" The number of data bits. Optional + # @option hash [Integer] "stop" The number of stop bits. Optional + # @option hash [Symbol] "parity" The parity. Optional # @overload open(device, baud=nil, data_bits=nil, stop_bits=nil, parity=nil) # Creates a new {SerialPort} and pass it to the provided block, closing automatically. # @return [Object] What the block returns @@ -152,6 +160,10 @@ def self.new(device, *params) # @return [Object] What the block returns # @yieldparam io [SerialPort] An opened serial port # @param [Hash] hash The given parameters, but as stringly-keyed values in a hash + # @option hash [Integer] "baud" The baud. Optional + # @option hash [Integer] "data_bits" The number of data bits. Optional + # @option hash [Integer] "stop" The number of stop bits. Optional + # @option hash [Symbol] "parity" The parity. Optional def self.open(device, *params) arg = SerialPort.new(device, *params) return arg unless block_given? @@ -256,15 +268,20 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en clear_config: true), Serial) end - # @!visibility private - # Don't doc IO methods + # Returns a string up to `length` long. It is not guaranteed to return the entire + # length specified, and will return an empty string if no data is + # available. If enable_blocking=true, it is identical to standard ruby IO#read + # except for the fact that empty reads still return empty strings instead of nil + # @note nonstandard IO behavior + # @return String def read(*args) res = super res.nil? ? '' : res end - # @!visibility private - # Don't doc IO methods + # Returns an 8 bit byte or nil if no data is available. + # @note nonstandard IO signature and behavior + # @yieldparam line [String] def gets(sep=$/, limit=nil) if block_given? loop do From 4029b38861cc94b2bbd8d5dfb6b819bf37bacdba Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Mon, 29 Jul 2019 00:39:51 -0400 Subject: [PATCH 4/8] Fix OSX. Add hupcl FD clone to keep hupcl working --- README.md | 9 ++- lib/rubyserial.rb | 23 ++++++- lib/rubyserial/linux_constants.rb | 5 ++ lib/rubyserial/osx_constants.rb | 4 ++ lib/rubyserial/posix.rb | 35 +++++++---- lib/rubyserial/windows.rb | 2 +- lib/rubyserial/windows_constants.rb | 3 + spec/rubyserial_spec.rb | 93 ++++++++++++++++++----------- spec/serial_arduino_spec.rb | 3 +- spec/serialport_spec.rb | 5 +- 10 files changed, 125 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index fffbd9a..d02abd1 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,22 @@ The interface to RubySerial should be compatible with other Ruby serialport gems ```ruby require 'rubyserial' -# 0.6 API (nonblocking by default) +# 0.6 API (nonblocking IO by default) serialport = Serial.new '/dev/ttyACM0' # Defaults to 9600 baud, 8 data bits, and no parity serialport = Serial.new '/dev/ttyACM0', 57600 serialport = Serial.new '/dev/ttyACM0', 19200, :even, 8 serialport = Serial.new '/dev/ttyACM0', 19200, :even, 8, true # to enable blocking IO -# SerialPort gem compatible API (blocking IO by default) +# SerialPort gem compatible API (blocking & nonblocking IO) serialport = SerialPort.new '/dev/ttyACM0' # Defaults to the existing system settings. serialport = SerialPort.new '/dev/ttyACM0', 57600 serialport = SerialPort.new '/dev/ttyACM0', 19200, 8, :even # note the order of args is different # open style syntax SerialPort.open '/dev/ttyACM0', 19200, 8, :even do |serialport| + serialport.read(3) # blocking + serialport.read_nonblock(3) # nonblocking + serialport.readpartial(3) # nonblocking after blocking for first character # ... end @@ -46,7 +49,7 @@ five = SerialPort.open '/dev/ttyACM0' do |serialport| 5 end ``` -Both SerialPort and Serial are an IO object, so standard methods like read and write are available, but do note that Serial has some nonstandard read behavior by default. +Both SerialPort and Serial are an IO object, so standard methods like read and write are available, but do note that Serial has some nonstandard read behavior by default. See the documentation for details ## Classes diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index 010d4cb..fc782a9 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -106,7 +106,7 @@ def self.new(device, *params) serial.instance_variable_set :@config, config serial rescue RubySerial::Error => e - if e.message == "ENOTTY" + if e.message == RubySerial::ENOTTY_MAP raise ArgumentError, "not a serial port" else raise @@ -265,7 +265,9 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en parity: parity, stop_bits: stop_bits, enable_blocking: enable_blocking, - clear_config: true), Serial) + clear_config: true), Serial).tap do |this| + this.instance_variable_set :@blocking, enable_blocking + end end # Returns a string up to `length` long. It is not guaranteed to return the entire @@ -275,8 +277,23 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en # @note nonstandard IO behavior # @return String def read(*args) - res = super + res = @blocking ? super : read_nonblock(*args) res.nil? ? '' : res + rescue IO::EAGAINWaitReadable + '' + end + + # Stanard IO#getbyte when enable_blocking=true, nonblocking otherwise + def getbyte + if !@blocking + begin + return read_nonblock(1).ord + rescue IO::EAGAINWaitReadable + nil + end + else + super + end end # Returns an 8 bit byte or nil if no data is available. diff --git a/lib/rubyserial/linux_constants.rb b/lib/rubyserial/linux_constants.rb index 837e333..0423de3 100644 --- a/lib/rubyserial/linux_constants.rb +++ b/lib/rubyserial/linux_constants.rb @@ -2,6 +2,9 @@ # Copyright (c) 2019 Patrick Plenefisch module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENOTTY" # @api private # @!visibility private module Posix @@ -32,6 +35,7 @@ module Posix CSIZE = 0000060 CBAUD = 0010017 HUPCL = 0002000 + HUPCL_HACK=false DATA_BITS = { 5 => 0000000, @@ -85,6 +89,7 @@ module Posix 2 => CSTOPB } + ERROR_CODES = { 1 => "EPERM", 2 => "ENOENT", diff --git a/lib/rubyserial/osx_constants.rb b/lib/rubyserial/osx_constants.rb index 8f82134..9f8cdfc 100644 --- a/lib/rubyserial/osx_constants.rb +++ b/lib/rubyserial/osx_constants.rb @@ -3,6 +3,9 @@ module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENODEV" # @api private # @!visibility private module Posix @@ -33,6 +36,7 @@ module Posix CRTSCTS = CCTS_OFLOW | CRTS_IFLOW CSIZE = 0x00000300 HUPCL = 0x00004000 + HUPCL_HACK=true DATA_BITS = { 5 => 0x00000000, diff --git a/lib/rubyserial/posix.rb b/lib/rubyserial/posix.rb index 282db7b..d19a856 100644 --- a/lib/rubyserial/posix.rb +++ b/lib/rubyserial/posix.rb @@ -26,24 +26,24 @@ def self.build(parent, config) ffi_call(:fcntl, fd, RubySerial::Posix::F_SETFL, :int, ~RubySerial::Posix::O_NONBLOCK & fl) end + file = parent.send(:for_fd, fd, File::RDWR | File::SYNC) + file.send :_rs_posix_init, fd, config.device + # Update the terminal settings - out_config = reconfigure(fd, config) + out_config = reconfigure(file, fd, config) out_config.device = config.device - file = parent.send(:for_fd, fd, File::RDWR | File::SYNC) - file.send :_rs_posix_init, fd - return [file, fd, out_config] end # @api private # @!visibility private # Reconfigures the given (platform-specific) file handle with the provided configuration. See {RubySerial::Includes#reconfigure} for public API - def self.reconfigure(fd, req_config) + def self.reconfigure(file, fd, req_config) # Update the terminal settings config = RubySerial::Posix::Termios.new ffi_call(:tcgetattr, fd, config) - out_config = edit_config(config, req_config, min: {nil => nil, true => 1, false => 0}[req_config.enable_blocking]) + out_config = edit_config(file, fd, config, req_config, min: {nil => nil, true => 1, false => 0}[req_config.enable_blocking]) ffi_call(:tcsetattr, fd, RubySerial::Posix::TCSANOW, config) out_config end @@ -83,8 +83,8 @@ def self.get value, options end # Updates the configuration object with the requested configuration - def self.edit_config(config, req, min: nil) - actual = RubySerial::Configuration.from(clear_config: req.clear_config) + def self.edit_config(fio, fd, config, req, min: nil) + actual = RubySerial::Configuration.from(clear_config: req.clear_config, device: req.device) if req.clear_config # reset everything except for flow settings @@ -110,6 +110,20 @@ def self.edit_config(config, req, min: nil) actual.data_bits = set config, :c_cflag, RubySerial::Posix::CSIZE, req.data_bits, RubySerial::Posix::DATA_BITS actual.parity = set config, :c_cflag, RubySerial::Posix::PARITY_FIELD, req.parity, RubySerial::Posix::PARITY actual.stop_bits = set config, :c_cflag, RubySerial::Posix::CSTOPB, req.stop_bits, RubySerial::Posix::STOPBITS + + # Some systems (osx) don't keep settings between reconnects, thus negating the usefulness of hupcl. + # On those systems, we add an open pipe when you set it, and close it when turned off again + hupcl_hack = RubySerial::Posix::HUPCL_HACK + if hupcl_hack && req.hupcl != nil + @hupcl_map ||= {} + devname = req.device + if !req.hupcl + @hupcl_map[devname] = fio.dup if @hupcl_map[devname].nil? + elsif @hupcl_map[devname] != nil + @hupcl_map[devname].close + @hupcl_map[devname] = nil + end + end actual.hupcl = set config, :c_cflag, RubySerial::Posix::HUPCL, req.hupcl return actual @@ -121,12 +135,13 @@ module RubySerial::Includes # Reconfigures the serial port with the given new values, if provided. Pass nil to keep the current settings. # @return [RubySerial::Configuration) The currently configured values for this serial port. def reconfigure(hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil) - RubySerial::Builder.reconfigure(@_rs_posix_fd, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) + RubySerial::Builder.reconfigure(self, @_rs_posix_fd, RubySerial::Configuration.from(device: @_rs_posix_devname, hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) end # TODO: dts set on linux? private - def _rs_posix_init(fd) + def _rs_posix_init(fd, name) @_rs_posix_fd = fd + @_rs_posix_devname = name end end diff --git a/lib/rubyserial/windows.rb b/lib/rubyserial/windows.rb index 6f227e5..e8fab24 100644 --- a/lib/rubyserial/windows.rb +++ b/lib/rubyserial/windows.rb @@ -64,7 +64,7 @@ def self.ffi_call who, *args # Updates the configuration object with the requested configuration def self.edit_config(dcb, req) - actual = RubySerial::Configuration.new + actual = RubySerial::Configuration.from(device: req.device) dcb[:baudrate] = req.baud if req.baud dcb[:bytesize] = req.data_bits if req.data_bits diff --git a/lib/rubyserial/windows_constants.rb b/lib/rubyserial/windows_constants.rb index 34eb11f..244dab6 100644 --- a/lib/rubyserial/windows_constants.rb +++ b/lib/rubyserial/windows_constants.rb @@ -3,6 +3,9 @@ module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENOTTY" # @api private # @!visibility private module WinC diff --git a/spec/rubyserial_spec.rb b/spec/rubyserial_spec.rb index 537e0a0..b152492 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -1,4 +1,5 @@ require 'rubyserial' +require 'timeout' describe "rubyserial" do before do @@ -62,71 +63,91 @@ end it "reading nothing should be blank" do - expect(@sp.read(5)).to eql('') + Timeout::timeout(3) do + expect(@sp.read(5)).to eql('') + end end it "should give me nil on getbyte" do - expect(@sp.getbyte).to be_nil + Timeout::timeout(3) do + expect(@sp.getbyte).to be_nil + end end it 'should give me a zero byte from getbyte' do - @sp2.write("\x00") - sleep 0.1 - expect(@sp.getbyte).to eql(0) + Timeout::timeout(3) do + @sp2.write("\x00") + sleep 0.1 + expect(@sp.getbyte).to eql(0) + end end it "should give me bytes" do - @sp2.write('hello') - # small delay so it can write to the other port. - sleep 0.1 - check = @sp.getbyte - expect([check].pack('C')).to eql('h') + Timeout::timeout(3) do + @sp2.write('hello') + # small delay so it can write to the other port. + sleep 0.1 + check = @sp.getbyte + expect([check].pack('C')).to eql('h') + end end describe "giving me lines" do it "should give me a line" do - @sp.write("no yes \n hello") - sleep 0.1 - expect(@sp2.gets).to eql("no yes \n") + Timeout::timeout(3) do + @sp.write("no yes \n hello") + sleep 0.1 + expect(@sp2.gets).to eql("no yes \n") + end end it "should give me a line with block" do - @sp.write("no yes \n hello") - sleep 0.1 - result = "" - @sp2.gets do |line| - result = line - break if !result.empty? + Timeout::timeout(3) do + @sp.write("no yes \n hello") + sleep 0.1 + result = "" + @sp2.gets do |line| + result = line + break if !result.empty? + end + expect(result).to eql("no yes \n") end - expect(result).to eql("no yes \n") end it "should accept a sep param" do - @sp.write('no yes END bleh') - sleep 0.1 - expect(@sp2.gets('END')).to eql("no yes END") + Timeout::timeout(3) do + @sp.write('no yes END bleh') + sleep 0.1 + expect(@sp2.gets('END')).to eql("no yes END") + end end it "should accept a limit param" do - @sp.write("no yes \n hello") - sleep 0.1 - expect(@sp2.gets(4)).to eql("no y") + Timeout::timeout(3) do + @sp.write("no yes \n hello") + sleep 0.1 + expect(@sp2.gets(4)).to eql("no y") + end end it "should accept limit and sep params" do - @sp.write("no yes END hello") - sleep 0.1 - expect(@sp2.gets('END', 20)).to eql("no yes END") - @sp2.read(1000) - @sp.write("no yes END hello") - sleep 0.1 - expect(@sp2.gets('END', 4)).to eql('no y') + Timeout::timeout(3) do + @sp.write("no yes END hello") + sleep 0.1 + expect(@sp2.gets('END', 20)).to eql("no yes END") + @sp2.read(1000) + @sp.write("no yes END hello") + sleep 0.1 + expect(@sp2.gets('END', 4)).to eql('no y') + end end it "should read a paragraph at a time" do - @sp.write("Something \n Something else \n\n and other stuff") - sleep 0.1 - expect(@sp2.gets('')).to eql("Something \n Something else \n\n") + Timeout::timeout(3) do + @sp.write("Something \n Something else \n\n and other stuff") + sleep 0.1 + expect(@sp2.gets('')).to eql("Something \n Something else \n\n") + end end end diff --git a/spec/serial_arduino_spec.rb b/spec/serial_arduino_spec.rb index 868e5de..762f351 100644 --- a/spec/serial_arduino_spec.rb +++ b/spec/serial_arduino_spec.rb @@ -9,6 +9,7 @@ if RubySerial::ON_WINDOWS @port = "COM3" else + # cu.usbserial-???? on a mac (varies) @port = "/dev/ttyUSB0"# SerialPort end @ser = nil @@ -88,7 +89,7 @@ @ser.close # stil resets @ser = SerialPort.new(@port, 57600, 8, 1, :none) # reopen with hupcl set - sleep 0.25 + sleep 0.75 # time for the arduino to reset @ser.readpartial(1024) # remove the z @ser.close # should NOT reset diff --git a/spec/serialport_spec.rb b/spec/serialport_spec.rb index e90d9ad..6e3fb6c 100644 --- a/spec/serialport_spec.rb +++ b/spec/serialport_spec.rb @@ -10,6 +10,7 @@ @port = "COM1" #TODO... @not_a_port = nil else + # Use ttys0 on a mac @port = "/dev/ttyS0"# SerialPort @not_a_port = "/dev/null" @not_a_file = "/dev/not_a_file" @@ -44,7 +45,7 @@ expect { s = Serial.new(@not_a_port) s.close - }.to raise_error(RubySerial::Error, "ENOTTY") + }.to raise_error(RubySerial::Error, /^ENO/) expect { s = Serial.new(@not_a_file) @@ -79,8 +80,6 @@ it "should support open named args" do SerialPort.open(@port, "baud" => 57600, "data_bits" => 6, "stop_bits" => 1, "parity" => :even) do |s| - p s - p s.to_s expect(s.baud).to eq 57600 expect(s.data_bits).to eq 6 expect(s.stop_bits).to eq 1 From 8f05a1dee6546252a5384b26f80167463f1277a8 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Mon, 29 Jul 2019 01:17:59 -0400 Subject: [PATCH 5/8] Only do fake blocking on Mac OS X & the BSD's, it works fine on linux. Also kill socat when we are done with it --- lib/rubyserial.rb | 8 +++++--- spec/rubyserial_spec.rb | 29 ++++++++++++++++++----------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index fc782a9..b3222df 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -12,6 +12,8 @@ module RubySerial ON_WINDOWS = RbConfig::CONFIG['host_os'] =~ /mswin|windows|mingw/i # @!visibility private ON_LINUX = RbConfig::CONFIG['host_os'] =~ /linux/i + # @!visibility private + ON_MAC = RbConfig::CONFIG['host_os'] =~ /darwin|bsd/i # Error thrown for most RubySerial-specific operations. Originates # from ffi errors. @@ -266,7 +268,7 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en stop_bits: stop_bits, enable_blocking: enable_blocking, clear_config: true), Serial).tap do |this| - this.instance_variable_set :@blocking, enable_blocking + this.instance_variable_set :@nonblock_mac, (RubySerial::ON_MAC && !enable_blocking) end end @@ -277,7 +279,7 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en # @note nonstandard IO behavior # @return String def read(*args) - res = @blocking ? super : read_nonblock(*args) + res = !@nonblock_mac ? super : read_nonblock(*args) res.nil? ? '' : res rescue IO::EAGAINWaitReadable '' @@ -285,7 +287,7 @@ def read(*args) # Stanard IO#getbyte when enable_blocking=true, nonblocking otherwise def getbyte - if !@blocking + if @nonblock_mac begin return read_nonblock(1).ord rescue IO::EAGAINWaitReadable diff --git a/spec/rubyserial_spec.rb b/spec/rubyserial_spec.rb index b152492..c2c214c 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -15,7 +15,7 @@ raise 'socat not found' unless (`socat -h` && $? == 0) Thread.new do - system('socat -lf socat.log -d -d pty,raw,echo=0 pty,raw,echo=0') + @pid = spawn('socat -lf socat.log -d -d pty,raw,echo=0 pty,raw,echo=0') end @ptys = nil @@ -42,28 +42,35 @@ after do @sp2.close @sp.close + Process.kill "KILL", @pid end it "should read and write" do - @sp2.write('hello') - # small delay so it can write to the other port. - sleep 0.1 - check = @sp.read(5) - expect(check).to eql('hello') + Timeout::timeout(3) do + @sp2.write('hello') + # small delay so it can write to the other port. + sleep 0.1 + check = @sp.read(5) + expect(check).to eql('hello') + end end it "should convert ints to strings" do - expect(@sp2.write(123)).to eql(3) - sleep 0.1 - expect(@sp.read(3)).to eql('123') + Timeout::timeout(3) do + expect(@sp2.write(123)).to eql(3) + sleep 0.1 + expect(@sp.read(3)).to eql('123') + end end it "write should return bytes written" do - expect(@sp2.write('hello')).to eql(5) + Timeout::timeout(3) do + expect(@sp2.write('hello')).to eql(5) + end end it "reading nothing should be blank" do - Timeout::timeout(3) do + Timeout::timeout(3) do expect(@sp.read(5)).to eql('') end end From 2016b8ddcf195691b5d514584faded08b880f3f3 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Mon, 29 Jul 2019 02:06:03 -0400 Subject: [PATCH 6/8] Fix windows refactoring bugs & timing for new arduino image --- lib/rubyserial/windows.rb | 11 ++++++----- spec/serial_arduino_spec.rb | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/rubyserial/windows.rb b/lib/rubyserial/windows.rb index e8fab24..bc6a4ba 100644 --- a/lib/rubyserial/windows.rb +++ b/lib/rubyserial/windows.rb @@ -4,7 +4,8 @@ class RubySerial::Builder def self.build(parent, config) - fd = IO::sysopen("\\\\.\\#{address}", File::RDWR) + dev = config.device.start_with?("\\\\.\\") ? config.device : "\\\\.\\#{config.device}" + fd = IO::sysopen(dev, File::RDWR) hndl = RubySerial::WinC._get_osfhandle(fd) # TODO: check errno @@ -74,7 +75,7 @@ def self.edit_config(dcb, req) dcb[:flags] &= ~RubySerial::Win32::DCB::FLAGS_RTS # Always clear the RTS bit unless req.hupcl.nil? dcb[:flags] &= ~RubySerial::Win32::DCB::DTR_MASK - dcb[:flags] |= ~RubySerial::Win32::DCB::DTR_ENABLED if req.hupcl + dcb[:flags] |= RubySerial::Win32::DCB::DTR_ENABLED if req.hupcl end actual.baud = dcb[:baudrate] @@ -89,7 +90,7 @@ def self.edit_config(dcb, req) module RubySerial::Includes def reconfigure(hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil) - RubySerial::Builder.reconfigure(self, @_rs_win32_hndl, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) + RubySerial::Builder.reconfigure(@_rs_win32_hndl, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) end # Ruby IO has issues with nonblocking from_fd on windows, so override just to change the underlying timeouts @@ -135,11 +136,11 @@ def write_nonblock(*args) ## # Updates the timeouts (if applicable) to emulate the requested read type def change_win32_mode type - return if @_win32_curr_read_mode == type + return if @_rs_win32_curr_read_mode == type # have to change the mode now RubySerial::Builder.win32_update_readmode(type, @_rs_win32_hndl) - @_win32_curr_read_mode = type + @_rs_win32_curr_read_mode = type end def _rs_win32_init(hndl, blocking_mode) diff --git a/spec/serial_arduino_spec.rb b/spec/serial_arduino_spec.rb index 762f351..e75c129 100644 --- a/spec/serial_arduino_spec.rb +++ b/spec/serial_arduino_spec.rb @@ -229,6 +229,7 @@ it "should support changing settings" do Timeout::timeout(12) do + sleep 0.1 expect(@ser.read(1)).to eq("z") @ser.puts "ye" @ser.hupcl = false From de7acda08e5e4e18c2271ebfd8e95227786a6040 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Thu, 1 Aug 2019 18:22:00 -0400 Subject: [PATCH 7/8] Fix previous change for windows again --- lib/rubyserial.rb | 2 +- spec/rubyserial_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyserial.rb b/lib/rubyserial.rb index b3222df..f2fa345 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -268,7 +268,7 @@ def self.new(address, baud_rate=9600, data_bits=8, parity=:none, stop_bits=1, en stop_bits: stop_bits, enable_blocking: enable_blocking, clear_config: true), Serial).tap do |this| - this.instance_variable_set :@nonblock_mac, (RubySerial::ON_MAC && !enable_blocking) + this.instance_variable_set :@nonblock_mac, (!RubySerial::ON_LINUX && !enable_blocking) end end diff --git a/spec/rubyserial_spec.rb b/spec/rubyserial_spec.rb index c2c214c..59b4111 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -9,6 +9,7 @@ # https://github.com/hybridgroup/rubyserial/raw/appveyor_deps/setup_com0com_W7_x64_signed.exe @ports[0] = "\\\\.\\CNCA0" @ports[1] = "\\\\.\\CNCB0" + @pid = nil else File.delete('socat.log') if File.file?('socat.log') @@ -42,7 +43,7 @@ after do @sp2.close @sp.close - Process.kill "KILL", @pid + Process.kill "KILL", @pid unless @pid.nil? end it "should read and write" do @@ -98,7 +99,6 @@ expect([check].pack('C')).to eql('h') end end - describe "giving me lines" do it "should give me a line" do Timeout::timeout(3) do From 04a1e885cc2284942cf27f3fbd786ed70af29212 Mon Sep 17 00:00:00 2001 From: Patrick Plenefisch Date: Sat, 7 Sep 2019 23:33:01 -0400 Subject: [PATCH 8/8] Add arduino program and allow jenkins to not fail --- README.md | 9 +++- spec/arduino.ino | 91 +++++++++++++++++++++++++++++++++++++ spec/serial_arduino_spec.rb | 39 ++-------------- spec/serialport_spec.rb | 4 +- 4 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 spec/arduino.ino diff --git a/README.md b/README.md index d02abd1..5798895 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The medium level API with {SerialIO} also returns an IO object, but allows you t The low level API is not considered stable, and may change in minor releases. -See the documentation ! TODO link!!! for more details +See the yard documentation for more details **RubySerial::Error** @@ -73,7 +73,12 @@ A wrapper error type that returns the underlying system error code and inherits ## Running the tests -The test suite is written using rspec, just use the `rspec` command. There are 3 test files: SerialPort API comatibility `serialport_spec`, Serial API compatability `rubyserial_spec`, and the DTR & timeout correctness test suite `serial_arduino_spec`. The latter requires this !TODO! program flashed to an arduino, or a compatible program that the test can talk to. +The test suite is written using rspec, just use the `rspec` command. There are 3 test files: SerialPort API compatibility `serialport_spec`, Serial API compatability `rubyserial_spec`, and the DTR & timeout correctness test suite `serial_arduino_spec`. The latter requires the `spec/arduino.ino` program flashed to an arduino, or a compatible program that the test can talk to. If you wish to test either of the latter, define the enviroment variable `RS_ARDUINO_PORT` to be the arduino serial port. If you wish to run `serialport_spec`, either the `RS_ARDUINO_PORT` or `RS_TEST_PORT` must be defined. + +```txt +$ export RS_ARDUINO_PORT=/dev/ttyUSB0 +$ rspec +``` ### Test dependencies diff --git a/spec/arduino.ino b/spec/arduino.ino new file mode 100644 index 0000000..2bf9e9b --- /dev/null +++ b/spec/arduino.ino @@ -0,0 +1,91 @@ +// This is the Arduino script for testing. Please compile and upload to any arduino with *hardware* serial. SoftSerial doesn't reset on HUPCL, such as with the pro Mini. +// Uno, Nano, or other board with ftdi or similar usb chips are required. + +int i = 0; +int loopv = 0; +void setup() { + // put your setup code here, to run once: + Serial.begin(57600); + Serial.write('z'); + i = 0; + loopv = 0; +} +// w - wait 250ms +// e immediate "echo!" +// n immediate "narwhales are cool" +// i immediate "A" + i++ +// a[5 bytes] - immediate 5 byte echo +// [1s wait] - "y/w" +// x/y - enable/disable pings +// r - reset i + +bool ping = true; + +void loop() +{ + if (Serial.available()) + { + char chr = (char)Serial.read(); + switch (chr) + { + case 'a': + while (Serial.available() < 5) + delay(3); + byte buf[5]; + Serial.readBytes(buf, 5); + Serial.write(buf, 5); + break; + case 'b': + { + while (Serial.available() < 5) + delay(3); + int buf2; + Serial.readBytes((char*)&buf2, 4); + uint8_t r = Serial.read(); + Serial.end(); + delay(100); + Serial.begin(buf2, r); + delay(100); + Serial.write('B'); + break; + } + case 'e': + Serial.print("echo"); + break; + case 'w': + delay(250); + break; + case 'i': + Serial.write(i++ + 'A'); + break; + case 'n': + Serial.print("narwhales are cool"); + break; + case 'y': + ping = false; + break; + case 'x': + ping = true; + break; + case 'r': + i = 0; + break; + default: + Serial.write('!'); + break; + } + loopv = 0; + } + loopv++; + if (loopv == 1000 && ping) + { + Serial.write('w'); + } + else if (loopv == 2000 && ping) + { + Serial.write('y'); + loopv = 0; + } + delay(1); +} + diff --git a/spec/serial_arduino_spec.rb b/spec/serial_arduino_spec.rb index e75c129..0959f4b 100644 --- a/spec/serial_arduino_spec.rb +++ b/spec/serial_arduino_spec.rb @@ -6,12 +6,8 @@ describe "serialport" do before do @ports = [] - if RubySerial::ON_WINDOWS - @port = "COM3" - else - # cu.usbserial-???? on a mac (varies) - @port = "/dev/ttyUSB0"# SerialPort - end + @port = ENV['RS_ARDUINO_PORT'] + skip "RS_ARDUINO_PORT is undefined. Please define it to be the port that the arduino program is attached if you wish to test hupcl behavior." if @port.nil? @ser = nil begin @ser = SerialPort.new(@port, 57600, 8, 1, :none) @@ -43,33 +39,7 @@ expect(dat.length).to be <= 512 end end -=begin - it "should test" do - puts "start" - #@ser.dtr = true - p @ser.read(1) - p @ser.read(1) - p "dtr = false" - #@ser.dtr = false - p @ser.write("e") - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - p "dtr = true" - #@ser.dtr = true - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - p "dtr = false" - #@ser.dtr = false - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - p @ser.read(1) - end -=end + it "should reset" do @ser.hupcl = true @ser.read_nonblock(1024) rescue nil # remove the old garbage if it exists @@ -81,8 +51,7 @@ expect(dat).to eq("z") end end - - + it "should not reset" do Timeout::timeout(4) do @ser.hupcl = false diff --git a/spec/serialport_spec.rb b/spec/serialport_spec.rb index 6e3fb6c..af71ec9 100644 --- a/spec/serialport_spec.rb +++ b/spec/serialport_spec.rb @@ -6,12 +6,12 @@ describe "serialport api" do before do @ports = [] + @port = ENV['RS_TEST_PORT'] || ENV['RS_ARDUINO_PORT'] + skip "RS_TEST_PORT is undefined. Please define it (or RS_ARDUINO_PORT) to any valid serial port" if @port.nil? if RubySerial::ON_WINDOWS - @port = "COM1" #TODO... @not_a_port = nil else # Use ttys0 on a mac - @port = "/dev/ttyS0"# SerialPort @not_a_port = "/dev/null" @not_a_file = "/dev/not_a_file" end