diff --git a/README.md b/README.md index 6c5dd5a..5798895 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ 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 (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) @@ -14,32 +14,58 @@ The interface to RubySerial should be (mostly) compatible with other Ruby serial $ gem install rubyserial -## Usage +## Basic Usage ```ruby require 'rubyserial' + +# 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, 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 & 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 + +# 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. See the documentation for details -## 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 yard documentation for more details **RubySerial::Error** @@ -47,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. +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/lib/rubyserial.rb b/lib/rubyserial.rb index a98bc88..f2fa345 100644 --- a/lib/rubyserial.rb +++ b/lib/rubyserial.rb @@ -1,15 +1,29 @@ # 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 + # @!visibility private + ON_MAC = RbConfig::CONFIG['host_os'] =~ /darwin|bsd/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' require 'rubyserial/windows' @@ -21,3 +35,295 @@ class Error < IOError end require 'rubyserial/posix' end + +# Mid-level API. A simple no-fluff serial port interface. Is an IO object. +class SerialIO < IO + include RubySerial::Includes + ## + # 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 + + # @!visibility private + # Don't doc Object methods + def inspect + "#<#{self.class.name}:#{name}>" # TODO: closed + end + + # @return [String] The name of the serial port + attr_reader :name + + private + attr_writer :name, :fd + attr_reader :fd +end + +# 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 + # @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 + 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 == RubySerial::ENOTTY_MAP + raise ArgumentError, "not a serial port" + else + raise + end + end + end + + ## + # 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 + # @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 + # @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 + # @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? + begin + yield arg + ensure + arg.close + end + end + + NONE = :none + 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 + @config = reconfigure(hupcl: value) + value + end + + def hupcl + @config.hupcl + end + + # @!attribute baud + # @return [Integer] the baud of this serial port + def 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 + @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 + @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 + @config = reconfigure(stop_bits: value) + value + end + + def stop_bits + @config.stop_bits + end + + private + attr_writer :name +end + +# Custom rubyserial Serial interface. High-level API, and stable. +class Serial < SerialIO + + # 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).tap do |this| + this.instance_variable_set :@nonblock_mac, (!RubySerial::ON_LINUX && !enable_blocking) + end + end + + # 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 = !@nonblock_mac ? super : read_nonblock(*args) + res.nil? ? '' : res + rescue IO::EAGAINWaitReadable + '' + end + + # Stanard IO#getbyte when enable_blocking=true, nonblocking otherwise + def getbyte + if @nonblock_mac + 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. + # @note nonstandard IO signature and behavior + # @yieldparam line [String] + 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/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 3dfeac9..0423de3 100644 --- a/lib/rubyserial/linux_constants.rb +++ b/lib/rubyserial/linux_constants.rb @@ -1,8 +1,14 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENOTTY" + # @api private + # @!visibility private module Posix - extend FFI::Library + extend FFI::Library ffi_lib FFI::Library::LIBC O_NONBLOCK = 00004000 @@ -16,11 +22,20 @@ 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 + HUPCL_HACK=false DATA_BITS = { 5 => 0000000, @@ -29,7 +44,7 @@ module Posix 8 => 0000060 } - BAUDE_RATES = { + BAUD_RATES = { 0 => 0000000, 50 => 0000001, 75 => 0000002, @@ -74,6 +89,7 @@ module Posix 2 => CSTOPB } + ERROR_CODES = { 1 => "EPERM", 2 => "ENOENT", @@ -217,12 +233,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..9f8cdfc 100644 --- a/lib/rubyserial/osx_constants.rb +++ b/lib/rubyserial/osx_constants.rb @@ -1,6 +1,13 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch + module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENODEV" + # @api private + # @!visibility private module Posix extend FFI::Library ffi_lib FFI::Library::LIBC @@ -13,14 +20,23 @@ 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 + HUPCL_HACK=true DATA_BITS = { 5 => 0x00000000, @@ -29,7 +45,7 @@ module Posix 8 => 0x00000300 } - BAUDE_RATES = { + BAUD_RATES = { 0 => 0, 50 => 50, 75 => 75, @@ -187,10 +203,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..d19a856 100644 --- a/lib/rubyserial/posix.rb +++ b/lib/rubyserial/posix.rb @@ -1,132 +1,147 @@ # 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] +# Copyright (c) 2019 Patrick Plenefisch + + +## +# 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 - 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 + file = parent.send(:for_fd, fd, File::RDWR | File::SYNC) + file.send :_rs_posix_init, fd, config.device - @config = build_config(baude_rate, data_bits, parity, stop_bits) + # Update the terminal settings + out_config = reconfigure(file, fd, config) + out_config.device = config.device - err = RubySerial::Posix.tcsetattr(@fd, RubySerial::Posix::TCSANOW, @config) - if err == -1 - raise RubySerial::Error, RubySerial::Posix::ERROR_CODES[FFI.errno] - end + return [file, fd, out_config] end - def closed? - !@open + # @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(file, fd, req_config) + # Update the terminal settings + config = RubySerial::Posix::Termios.new + ffi_call(:tcgetattr, fd, config) + 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 - def close - err = RubySerial::Posix.close(@fd) - if err == -1 + 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) + 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] + # Sets the given config value (if provided), and returns the current value + def self.set config, field, flag, value, map = 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 else - n = n+i + value end + else + map[value] end - - # return number of bytes written - n + 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 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) + def self.get value, options + return value != 0 if options.nil? + options.key(value) 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] + # Updates the configuration object with the requested configuration + 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 + 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 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, req.baud, 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[req.baud] end + actual.baud = get config[:c_ispeed], RubySerial::Posix::BAUD_RATES + + 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 - bytes.map { |e| e.chr }.join + return actual end +end - def build_config(baude_rate, data_bits, parity, stop_bits) - config = RubySerial::Posix::Termios.new - - 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[:cc_c][RubySerial::Posix::VMIN] = 0 +# 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 + # 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(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 - config + # TODO: dts set on linux? + private + def _rs_posix_init(fd, name) + @_rs_posix_fd = fd + @_rs_posix_devname = name end end diff --git a/lib/rubyserial/version.rb b/lib/rubyserial/version.rb index 71a0b46..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 = "0.6.0" + # Version of RubySerial + VERSION = "1.0.0" end end diff --git a/lib/rubyserial/windows.rb b/lib/rubyserial/windows.rb index 04f35ba..bc6a4ba 100644 --- a/lib/rubyserial/windows.rb +++ b/lib/rubyserial/windows.rb @@ -1,116 +1,160 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch -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 - 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] - 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 +class RubySerial::Builder + def self.build(parent, config) + dev = config.device.start_with?("\\\\.\\") ? config.device : "\\\\.\\#{config.device}" + fd = IO::sysopen(dev, File::RDWR) - 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 + hndl = RubySerial::WinC._get_osfhandle(fd) + # TODO: check errno + + # Update the terminal settings + out_config = reconfigure(hndl, config) # TODO: clear_config + out_config.device = config.device + + ffi_call :SetupComm, hndl, 64, 64 # Set the buffers to 64 bytes + + blocking_mode = config.enable_blocking ? :blocking : :nonblocking + win32_update_readmode(blocking_mode, hndl) + + file = parent.send(:for_fd, fd, File::RDWR) + file.send :_rs_win32_init, hndl, blocking_mode + + return [file, fd, out_config] 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) + # @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 + out_config = edit_config(dcb, req_config) + ffi_call :SetCommState, hndl, dcb + out_config 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 + private - if count.read_int == 0 - nil - else - buff.get_bytes(0, 1).bytes.first - end + # 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? + + # 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 - 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] + # 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 + raise RubySerial::Error, RubySerial::Win32::ERROR_CODES[FFI.errno] + end + res + end + + # Updates the configuration object with the requested configuration + def self.edit_config(dcb, req) + actual = RubySerial::Configuration.from(device: req.device) + + 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 - count.read_int + + 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 + +module RubySerial::Includes + def reconfigure(hupcl: nil, baud: nil, data_bits: nil, parity: nil, stop_bits: nil) + RubySerial::Builder.reconfigure(@_rs_win32_hndl, RubySerial::Configuration.from(hupcl: hupcl, baud: baud, data_bits: data_bits, parity: parity, stop_bits: stop_bits)) end - def gets(sep=$/, limit=nil) - if block_given? - loop do - yield(get_until_sep(sep, limit)) - 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 + + # 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) 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 + # 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 @_rs_win32_blocking + super(*args) end end - def closed? - !@open + # 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: properly support write_nonblock on windows + write(*args) 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 + ## + # Updates the timeouts (if applicable) to emulate the requested read type + def change_win32_mode type + return if @_rs_win32_curr_read_mode == type - bytes.map { |e| e.chr }.join + # have to change the mode now + RubySerial::Builder.win32_update_readmode(type, @_rs_win32_hndl) + @_rs_win32_curr_read_mode = type end + + def _rs_win32_init(hndl, blocking_mode) + @_rs_win32_hndl = hndl + @_rs_win32_blocking = blocking_mode + @_rs_win32_curr_read_mode = blocking_mode + end + + # TODO: make cross platform... + #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 end diff --git a/lib/rubyserial/windows_constants.rb b/lib/rubyserial/windows_constants.rb index 514cc69..244dab6 100644 --- a/lib/rubyserial/windows_constants.rb +++ b/lib/rubyserial/windows_constants.rb @@ -1,6 +1,24 @@ # Copyright (c) 2014-2016 The Hybrid Group +# Copyright (c) 2019 Patrick Plenefisch + module RubySerial + # @api private + # @!visibility private + ENOTTY_MAP="ENOTTY" + # @api private + # @!visibility private + 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 + + # @api private + # @!visibility private module Win32 extend FFI::Library ffi_lib 'kernel32' @@ -267,6 +285,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 @@ -275,15 +317,29 @@ 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 :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/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/rubyserial_spec.rb b/spec/rubyserial_spec.rb index 7380284..59b4111 100644 --- a/spec/rubyserial_spec.rb +++ b/spec/rubyserial_spec.rb @@ -1,4 +1,5 @@ require 'rubyserial' +require 'timeout' describe "rubyserial" do before do @@ -8,13 +9,14 @@ # 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') 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 @@ -41,92 +43,118 @@ after do @sp2.close @sp.close + Process.kill "KILL", @pid unless @pid.nil? 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 - 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 @@ -172,15 +200,10 @@ @sp.close rate = 600 @sp = Serial.new(@ports[1], rate) - fd = @sp.instance_variable_get(:@fd) - module RubySerial - module Posix - attach_function :tcgetattr, [ :int, RubySerial::Posix::Termios ], :int, blocking: true - end - end + fd = @sp.send :fd 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..0959f4b --- /dev/null +++ b/spec/serial_arduino_spec.rb @@ -0,0 +1,251 @@ +# Copyright (c) 2019 Patrick Plenefisch + +require 'rubyserial' +require 'timeout' + +describe "serialport" do + before do + @ports = [] + @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) + rescue Errno::ENOENT + skip "Arduino not connected or port number wrong" + end + end + NAR = "narwhales are cool" + + after do + @ser.close if @ser + 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 + + 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.75 # time for the arduino to reset + @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 + sleep 0.1 + 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 "# 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| + 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