From f23403849476c61ffd790571d124a08a537b9399 Mon Sep 17 00:00:00 2001 From: Scott Petersen Date: Thu, 20 Jul 2017 13:11:14 -0700 Subject: [PATCH] Broke out the devices into their own files for easier management. --- alarmdecoder/devices.py | 1297 ------------------------- alarmdecoder/devices/__init__.py | 6 + alarmdecoder/devices/base_device.py | 139 +++ alarmdecoder/devices/serial_device.py | 268 +++++ alarmdecoder/devices/socket_device.py | 388 ++++++++ alarmdecoder/devices/usb_device.py | 482 +++++++++ alarmdecoder/util.py | 14 + 7 files changed, 1297 insertions(+), 1297 deletions(-) delete mode 100644 alarmdecoder/devices.py create mode 100644 alarmdecoder/devices/__init__.py create mode 100644 alarmdecoder/devices/base_device.py create mode 100644 alarmdecoder/devices/serial_device.py create mode 100644 alarmdecoder/devices/socket_device.py create mode 100644 alarmdecoder/devices/usb_device.py diff --git a/alarmdecoder/devices.py b/alarmdecoder/devices.py deleted file mode 100644 index 6fd7f2b..0000000 --- a/alarmdecoder/devices.py +++ /dev/null @@ -1,1297 +0,0 @@ -""" -This module contains different types of devices belonging to the `AlarmDecoder`_ (AD2) family. - -* :py:class:`USBDevice`: Interfaces with the `AD2USB`_ device. -* :py:class:`SerialDevice`: Interfaces with the `AD2USB`_, `AD2SERIAL`_ or `AD2PI`_. -* :py:class:`SocketDevice`: Interfaces with devices exposed through `ser2sock`_ or another IP to Serial solution. - Also supports SSL if using `ser2sock`_. - -.. _ser2sock: http://github.com/nutechsoftware/ser2sock -.. _AlarmDecoder: http://www.alarmdecoder.com -.. _AD2USB: http://www.alarmdecoder.com -.. _AD2SERIAL: http://www.alarmdecoder.com -.. _AD2PI: http://www.alarmdecoder.com - -.. moduleauthor:: Scott Petersen -""" - -import time -import threading -import serial -import serial.tools.list_ports -import socket -import select -import sys - -from .util import CommError, TimeoutError, NoDeviceError, InvalidMessageError -from .event import event - -have_pyftdi = False -try: - from pyftdi.pyftdi.ftdi import Ftdi, FtdiError - import usb.core - import usb.util - - have_pyftdi = True - -except ImportError: - try: - from pyftdi.ftdi import Ftdi, FtdiError - import usb.core - import usb.util - - have_pyftdi = True - - except ImportError: - have_pyftdi = False - -try: - from OpenSSL import SSL, crypto - - have_openssl = True - -except ImportError: - class SSL: - class Error(BaseException): - pass - - class WantReadError(BaseException): - pass - - class SysCallError(BaseException): - pass - - have_openssl = False - - -def bytes_hack(buf): - """ - Hacky workaround for old installs of the library on systems without python-future that were - keeping the 2to3 update from working after auto-update. - """ - ub = None - if sys.version_info > (3,): - ub = buf - else: - ub = bytes(buf) - - return ub - - -class Device(object): - """ - Base class for all `AlarmDecoder`_ (AD2) device types. - """ - - # Generic device events - on_open = event.Event("This event is called when the device has been opened.\n\n**Callback definition:** *def callback(device)*") - on_close = event.Event("This event is called when the device has been closed.\n\n**Callback definition:** def callback(device)*") - on_read = event.Event("This event is called when a line has been read from the device.\n\n**Callback definition:** def callback(device, data)*") - on_write = event.Event("This event is called when data has been written to the device.\n\n**Callback definition:** def callback(device, data)*") - - def __init__(self): - """ - Constructor - """ - self._id = '' - self._buffer = b'' - self._device = None - self._running = False - self._read_thread = None - - def __enter__(self): - """ - Support for context manager __enter__. - """ - return self - - def __exit__(self, exc_type, exc_value, traceback): - """ - Support for context manager __exit__. - """ - self.close() - - return False - - @property - def id(self): - """ - Retrieve the device ID. - - :returns: identification string for the device - """ - return self._id - - @id.setter - def id(self, value): - """ - Sets the device ID. - - :param value: device identification string - :type value: string - """ - self._id = value - - def is_reader_alive(self): - """ - Indicates whether or not the reader thread is alive. - - :returns: whether or not the reader thread is alive - """ - return self._read_thread.is_alive() - - def stop_reader(self): - """ - Stops the reader thread. - """ - self._read_thread.stop() - - def close(self): - """ - Closes the device. - """ - try: - self._running = False - self._read_thread.stop() - self._device.close() - - except Exception: - pass - - self.on_close() - - class ReadThread(threading.Thread): - """ - Reader thread which processes messages from the device. - """ - - READ_TIMEOUT = 10 - """Timeout for the reader thread.""" - - def __init__(self, device): - """ - Constructor - - :param device: device used by the reader thread - :type device: :py:class:`~alarmdecoder.devices.Device` - """ - threading.Thread.__init__(self) - self._device = device - self._running = False - - def stop(self): - """ - Stops the running thread. - """ - self._running = False - - def run(self): - """ - The actual read process. - """ - self._running = True - - while self._running: - try: - self._device.read_line(timeout=self.READ_TIMEOUT) - - except TimeoutError: - pass - - except InvalidMessageError: - pass - - except SSL.WantReadError: - pass - - except CommError as err: - self._device.close() - - except Exception as err: - self._device.close() - self._running = False - raise - - -class USBDevice(Device): - """ - `AD2USB`_ device utilizing PyFTDI's interface. - """ - - # Constants - PRODUCT_IDS = ((0x0403, 0x6001), (0x0403, 0x6015)) - """List of Vendor and Product IDs used to recognize `AD2USB`_ devices.""" - DEFAULT_VENDOR_ID = PRODUCT_IDS[0][0] - """Default Vendor ID used to recognize `AD2USB`_ devices.""" - DEFAULT_PRODUCT_ID = PRODUCT_IDS[0][1] - """Default Product ID used to recognize `AD2USB`_ devices.""" - - # Deprecated constants - FTDI_VENDOR_ID = DEFAULT_VENDOR_ID - """DEPRECATED: Vendor ID used to recognize `AD2USB`_ devices.""" - FTDI_PRODUCT_ID = DEFAULT_PRODUCT_ID - """DEPRECATED: Product ID used to recognize `AD2USB`_ devices.""" - - - BAUDRATE = 115200 - """Default baudrate for `AD2USB`_ devices.""" - - __devices = [] - __detect_thread = None - - @classmethod - def find_all(cls, vid=None, pid=None): - """ - Returns all FTDI devices matching our vendor and product IDs. - - :returns: list of devices - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - if not have_pyftdi: - raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') - - cls.__devices = [] - - query = cls.PRODUCT_IDS - if vid and pid: - query = [(vid, pid)] - - try: - cls.__devices = Ftdi.find_all(query, nocache=True) - - except (usb.core.USBError, FtdiError) as err: - raise CommError('Error enumerating AD2USB devices: {0}'.format(str(err)), err) - - return cls.__devices - - @classmethod - def devices(cls): - """ - Returns a cached list of `AD2USB`_ devices located on the system. - - :returns: cached list of devices found - """ - return cls.__devices - - @classmethod - def find(cls, device=None): - """ - Factory method that returns the requested :py:class:`USBDevice` device, or the - first device. - - :param device: Tuple describing the USB device to open, as returned - by find_all(). - :type device: tuple - - :returns: :py:class:`USBDevice` object utilizing the specified device - :raises: :py:class:`~alarmdecoder.util.NoDeviceError` - """ - if not have_pyftdi: - raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') - - cls.find_all() - - if len(cls.__devices) == 0: - raise NoDeviceError('No AD2USB devices present.') - - if device is None: - device = cls.__devices[0] - - vendor, product, sernum, ifcount, description = device - - return USBDevice(interface=sernum, vid=vendor, pid=product) - - @classmethod - def start_detection(cls, on_attached=None, on_detached=None): - """ - Starts the device detection thread. - - :param on_attached: function to be called when a device is attached **Callback definition:** *def callback(thread, device)* - :type on_attached: function - :param on_detached: function to be called when a device is detached **Callback definition:** *def callback(thread, device)* - - :type on_detached: function - """ - if not have_pyftdi: - raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') - - cls.__detect_thread = USBDevice.DetectThread(on_attached, on_detached) - - try: - cls.find_all() - except CommError: - pass - - cls.__detect_thread.start() - - @classmethod - def stop_detection(cls): - """ - Stops the device detection thread. - """ - if not have_pyftdi: - raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') - - try: - cls.__detect_thread.stop() - - except Exception: - pass - - @property - def interface(self): - """ - Retrieves the interface used to connect to the device. - - :returns: the interface used to connect to the device - """ - return self._interface - - @interface.setter - def interface(self, value): - """ - Sets the interface used to connect to the device. - - :param value: may specify either the serial number or the device index - :type value: string or int - """ - self._interface = value - if isinstance(value, int): - self._device_number = value - else: - self._serial_number = value - - @property - def serial_number(self): - """ - Retrieves the serial number of the device. - - :returns: serial number of the device - """ - - return self._serial_number - - @serial_number.setter - def serial_number(self, value): - """ - Sets the serial number of the device. - - :param value: serial number of the device - :type value: string - """ - self._serial_number = value - - @property - def description(self): - """ - Retrieves the description of the device. - - :returns: description of the device - """ - return self._description - - @description.setter - def description(self, value): - """ - Sets the description of the device. - - :param value: description of the device - :type value: string - """ - self._description = value - - def __init__(self, interface=0, vid=None, pid=None): - """ - Constructor - - :param interface: May specify either the serial number or the device - index. - :type interface: string or int - """ - if not have_pyftdi: - raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') - - Device.__init__(self) - - self._device = Ftdi() - - self._interface = 0 - self._device_number = 0 - self._serial_number = None - - self._vendor_id = USBDevice.DEFAULT_VENDOR_ID - if vid: - self._vendor_id = vid - - self._product_id = USBDevice.DEFAULT_PRODUCT_ID - if pid: - self._product_id = pid - - self._endpoint = 0 - self._description = None - - self.interface = interface - - def open(self, baudrate=BAUDRATE, no_reader_thread=False): - """ - Opens the device. - - :param baudrate: baudrate to use - :type baudrate: int - :param no_reader_thread: whether or not to automatically start the - reader thread. - :type no_reader_thread: bool - - :raises: :py:class:`~alarmdecoder.util.NoDeviceError` - """ - # Set up defaults - if baudrate is None: - baudrate = USBDevice.BAUDRATE - - self._read_thread = Device.ReadThread(self) - - # Open the device and start up the thread. - try: - self._device.open(self._vendor_id, - self._product_id, - self._endpoint, - self._device_number, - self._serial_number, - self._description) - - self._device.set_baudrate(baudrate) - - if not self._serial_number: - self._serial_number = self._get_serial_number() - - self._id = self._serial_number - - except (usb.core.USBError, FtdiError) as err: - raise NoDeviceError('Error opening device: {0}'.format(str(err)), err) - - except KeyError as err: - raise NoDeviceError('Unsupported device. ({0:04x}:{1:04x}) You probably need a newer version of pyftdi.'.format(err[0][0], err[0][1])) - - else: - self._running = True - self.on_open() - - if not no_reader_thread: - self._read_thread.start() - - return self - - def close(self): - """ - Closes the device. - """ - try: - Device.close(self) - - # HACK: Probably should fork pyftdi and make this call in .close() - self._device.usb_dev.attach_kernel_driver(self._device_number) - - except Exception: - pass - - def fileno(self): - """ - File number not supported for USB devices. - - :raises: NotImplementedError - """ - raise NotImplementedError('USB devices do not support fileno()') - - def write(self, data): - """ - Writes data to the device. - - :param data: data to write - :type data: string - - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - try: - self._device.write_data(data) - - self.on_write(data=data) - - except FtdiError as err: - raise CommError('Error writing to device: {0}'.format(str(err)), err) - - def read(self): - """ - Reads a single character from the device. - - :returns: character read from the device - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - ret = None - - try: - ret = self._device.read_data(1) - - except (usb.core.USBError, FtdiError) as err: - raise CommError('Error reading from device: {0}'.format(str(err)), err) - - return ret - - def read_line(self, timeout=0.0, purge_buffer=False): - """ - Reads a line from the device. - - :param timeout: read timeout - :type timeout: float - :param purge_buffer: Indicates whether to purge the buffer prior to - reading. - :type purge_buffer: bool - - :returns: line that was read - :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` - """ - - def timeout_event(): - """Handles read timeout event""" - timeout_event.reading = False - timeout_event.reading = True - - if purge_buffer: - self._buffer = b'' - - got_line, ret = False, None - - timer = threading.Timer(timeout, timeout_event) - if timeout > 0: - timer.start() - - try: - while timeout_event.reading: - buf = self._device.read_data(1) - - if buf != b'': - ub = bytes_hack(buf) - - self._buffer += ub - - if ub == b"\n": - self._buffer = self._buffer.rstrip(b"\r\n") - - if len(self._buffer) > 0: - got_line = True - break - else: - time.sleep(0.01) - - except (usb.core.USBError, FtdiError) as err: - raise CommError('Error reading from device: {0}'.format(str(err)), err) - - else: - if got_line: - ret, self._buffer = self._buffer, b'' - - self.on_read(data=ret) - - else: - raise TimeoutError('Timeout while waiting for line terminator.') - - finally: - timer.cancel() - - return ret - - def purge(self): - """ - Purges read/write buffers. - """ - self._device.purge_buffers() - - def _get_serial_number(self): - """ - Retrieves the FTDI device serial number. - - :returns: string containing the device serial number - """ - return usb.util.get_string(self._device.usb_dev, 64, self._device.usb_dev.iSerialNumber) - - class DetectThread(threading.Thread): - """ - Thread that handles detection of added/removed devices. - """ - on_attached = event.Event("This event is called when an `AD2USB`_ device has been detected.\n\n**Callback definition:** def callback(thread, device*") - on_detached = event.Event("This event is called when an `AD2USB`_ device has been removed.\n\n**Callback definition:** def callback(thread, device*") - - def __init__(self, on_attached=None, on_detached=None): - """ - Constructor - - :param on_attached: Function to call when a device is attached **Callback definition:** *def callback(thread, device)* - :type on_attached: function - :param on_detached: Function to call when a device is detached **Callback definition:** *def callback(thread, device)* - :type on_detached: function - """ - threading.Thread.__init__(self) - - if on_attached: - self.on_attached += on_attached - - if on_detached: - self.on_detached += on_detached - - self._running = False - - def stop(self): - """ - Stops the thread. - """ - self._running = False - - def run(self): - """ - The actual detection process. - """ - self._running = True - - last_devices = set() - - while self._running: - try: - current_devices = set(USBDevice.find_all()) - - for dev in current_devices.difference(last_devices): - self.on_attached(device=dev) - - for dev in last_devices.difference(current_devices): - self.on_detached(device=dev) - - last_devices = current_devices - - except CommError: - pass - - time.sleep(0.25) - - -class SerialDevice(Device): - """ - `AD2USB`_, `AD2SERIAL`_ or `AD2PI`_ device utilizing the PySerial interface. - """ - - # Constants - BAUDRATE = 19200 - """Default baudrate for Serial devices.""" - - @staticmethod - def find_all(pattern=None): - """ - Returns all serial ports present. - - :param pattern: pattern to search for when retrieving serial ports - :type pattern: string - - :returns: list of devices - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - devices = [] - - try: - if pattern: - devices = serial.tools.list_ports.grep(pattern) - else: - devices = serial.tools.list_ports.comports() - - except serial.SerialException as err: - raise CommError('Error enumerating serial devices: {0}'.format(str(err)), err) - - return devices - - @property - def interface(self): - """ - Retrieves the interface used to connect to the device. - - :returns: interface used to connect to the device - """ - return self._port - - @interface.setter - def interface(self, value): - """ - Sets the interface used to connect to the device. - - :param value: name of the serial device - :type value: string - """ - self._port = value - - def __init__(self, interface=None): - """ - Constructor - - :param interface: device to open - :type interface: string - """ - Device.__init__(self) - - self._port = interface - self._id = interface - # Timeout = non-blocking to match pyftdi. - self._device = serial.Serial(timeout=0, writeTimeout=0) - - def open(self, baudrate=BAUDRATE, no_reader_thread=False): - """ - Opens the device. - - :param baudrate: baudrate to use with the device - :type baudrate: int - :param no_reader_thread: whether or not to automatically start the - reader thread. - :type no_reader_thread: bool - - :raises: :py:class:`~alarmdecoder.util.NoDeviceError` - """ - # Set up the defaults - if baudrate is None: - baudrate = SerialDevice.BAUDRATE - - if self._port is None: - raise NoDeviceError('No device interface specified.') - - self._read_thread = Device.ReadThread(self) - - # Open the device and start up the reader thread. - try: - self._device.port = self._port - self._device.open() - # NOTE: Setting the baudrate before opening the - # port caused issues with Moschip 7840/7820 - # USB Serial Driver converter. (mos7840) - # - # Moving it to this point seems to resolve - # all issues with it. - self._device.baudrate = baudrate - - except (serial.SerialException, ValueError, OSError) as err: - raise NoDeviceError('Error opening device on {0}.'.format(self._port), err) - - else: - self._running = True - self.on_open() - - if not no_reader_thread: - self._read_thread.start() - - return self - - def close(self): - """ - Closes the device. - """ - try: - Device.close(self) - - except Exception: - pass - - def fileno(self): - """ - Returns the file number associated with the device - - :returns: int - """ - return self._device.fileno() - - def write(self, data): - """ - Writes data to the device. - - :param data: data to write - :type data: string - - :raises: py:class:`~alarmdecoder.util.CommError` - """ - try: - # Hack to support unicode under Python 2.x - if isinstance(data, str) or (sys.version_info < (3,) and isinstance(data, unicode)): - data = data.encode('utf-8') - - self._device.write(data) - - except serial.SerialTimeoutException: - pass - - except serial.SerialException as err: - raise CommError('Error writing to device.', err) - - else: - self.on_write(data=data) - - def read(self): - """ - Reads a single character from the device. - - :returns: character read from the device - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - data = '' - - try: - read_ready, _, _ = select.select([self._device.fileno()], [], [], 0.5) - - if len(read_ready) != 0: - data = self._device.read(1) - - except serial.SerialException as err: - raise CommError('Error reading from device: {0}'.format(str(err)), err) - - return data.decode('utf-8') - - def read_line(self, timeout=0.0, purge_buffer=False): - """ - Reads a line from the device. - - :param timeout: read timeout - :type timeout: float - :param purge_buffer: Indicates whether to purge the buffer prior to - reading. - :type purge_buffer: bool - - :returns: line that was read - :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` - """ - - def timeout_event(): - """Handles read timeout event""" - timeout_event.reading = False - timeout_event.reading = True - - if purge_buffer: - self._buffer = b'' - - got_line, data = False, '' - - timer = threading.Timer(timeout, timeout_event) - if timeout > 0: - timer.start() - - leftovers = b'' - try: - while timeout_event.reading and not got_line: - read_ready, _, _ = select.select([self._device.fileno()], [], [], 0.5) - if len(read_ready) == 0: - continue - - bytes_avail = 0 - if hasattr(self._device, "in_waiting"): - bytes_avail = self._device.in_waiting - else: - bytes_avail = self._device.inWaiting() - - buf = self._device.read(bytes_avail) - - for idx in range(len(buf)): - c = buf[idx] - - ub = bytes_hack(c) - if sys.version_info > (3,): - ub = bytes([ub]) - - # NOTE: AD2SERIAL and AD2PI apparently sends down \xFF on boot. - if ub != b'' and ub != b"\xff": - self._buffer += ub - - if ub == b"\n": - self._buffer = self._buffer.strip(b"\r\n") - - if len(self._buffer) > 0: - got_line = True - leftovers = buf[idx:] - break - - except (OSError, serial.SerialException) as err: - raise CommError('Error reading from device: {0}'.format(str(err)), err) - - else: - if got_line: - data, self._buffer = self._buffer, leftovers - - self.on_read(data=data) - - else: - raise TimeoutError('Timeout while waiting for line terminator.') - - finally: - timer.cancel() - - return data.decode('utf-8') - - def purge(self): - """ - Purges read/write buffers. - """ - self._device.flushInput() - self._device.flushOutput() - - -class SocketDevice(Device): - """ - Device that supports communication with an `AlarmDecoder`_ (AD2) that is - exposed via `ser2sock`_ or another Serial to IP interface. - """ - - @property - def interface(self): - """ - Retrieves the interface used to connect to the device. - - :returns: interface used to connect to the device - """ - return (self._host, self._port) - - @interface.setter - def interface(self, value): - """ - Sets the interface used to connect to the device. - - :param value: Tuple containing the host and port to use - :type value: tuple - """ - self._host, self._port = value - - @property - def ssl(self): - """ - Retrieves whether or not the device is using SSL. - - :returns: whether or not the device is using SSL - """ - return self._use_ssl - - @ssl.setter - def ssl(self, value): - """ - Sets whether or not SSL communication is in use. - - :param value: Whether or not SSL communication is in use - :type value: bool - """ - self._use_ssl = value - - @property - def ssl_certificate(self): - """ - Retrieves the SSL client certificate path used for authentication. - - :returns: path to the certificate path or :py:class:`OpenSSL.crypto.X509` - """ - return self._ssl_certificate - - @ssl_certificate.setter - def ssl_certificate(self, value): - """ - Sets the SSL client certificate to use for authentication. - - :param value: path to the SSL certificate or :py:class:`OpenSSL.crypto.X509` - :type value: string or :py:class:`OpenSSL.crypto.X509` - """ - self._ssl_certificate = value - - @property - def ssl_key(self): - """ - Retrieves the SSL client certificate key used for authentication. - - :returns: jpath to the SSL key or :py:class:`OpenSSL.crypto.PKey` - """ - return self._ssl_key - - @ssl_key.setter - def ssl_key(self, value): - """ - Sets the SSL client certificate key to use for authentication. - - :param value: path to the SSL key or :py:class:`OpenSSL.crypto.PKey` - :type value: string or :py:class:`OpenSSL.crypto.PKey` - """ - self._ssl_key = value - - @property - def ssl_ca(self): - """ - Retrieves the SSL Certificate Authority certificate used for - authentication. - - :returns: path to the CA certificate or :py:class:`OpenSSL.crypto.X509` - """ - return self._ssl_ca - - @ssl_ca.setter - def ssl_ca(self, value): - """ - Sets the SSL Certificate Authority certificate used for authentication. - - :param value: path to the SSL CA certificate or :py:class:`OpenSSL.crypto.X509` - :type value: string or :py:class:`OpenSSL.crypto.X509` - """ - self._ssl_ca = value - - def __init__(self, interface=("localhost", 10000)): - """ - Constructor - - :param interface: Tuple containing the hostname and port of our target - :type interface: tuple - """ - Device.__init__(self) - - self._host, self._port = interface - self._use_ssl = False - self._ssl_certificate = None - self._ssl_key = None - self._ssl_ca = None - - def open(self, baudrate=None, no_reader_thread=False): - """ - Opens the device. - - :param baudrate: baudrate to use - :type baudrate: int - :param no_reader_thread: whether or not to automatically open the reader - thread. - :type no_reader_thread: bool - - :raises: :py:class:`~alarmdecoder.util.NoDeviceError`, :py:class:`~alarmdecoder.util.CommError` - """ - - try: - self._read_thread = Device.ReadThread(self) - - self._device = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - if self._use_ssl: - self._init_ssl() - - self._device.connect((self._host, self._port)) - - if self._use_ssl: - while True: - try: - self._device.do_handshake() - break - except SSL.WantReadError: - pass - - self._id = '{0}:{1}'.format(self._host, self._port) - - except socket.error as err: - raise NoDeviceError('Error opening device at {0}:{1}'.format(self._host, self._port), err) - - else: - self._running = True - self.on_open() - - if not no_reader_thread: - self._read_thread.start() - - return self - - def close(self): - """ - Closes the device. - """ - try: - # TODO: Find a way to speed up this shutdown. - if self.ssl: - self._device.shutdown() - - else: - # Make sure that it closes immediately. - self._device.shutdown(socket.SHUT_RDWR) - - except Exception: - pass - - Device.close(self) - - def fileno(self): - """ - Returns the file number associated with the device - - :returns: int - """ - return self._device.fileno() - - def write(self, data): - """ - Writes data to the device. - - :param data: data to write - :type data: string - - :returns: number of bytes sent - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - data_sent = None - - try: - if isinstance(data, str): - data = data.encode('utf-8') - - data_sent = self._device.send(data) - - if data_sent == 0: - raise CommError('Error writing to device.') - - self.on_write(data=data) - - except (SSL.Error, socket.error) as err: - raise CommError('Error writing to device.', err) - - return data_sent - - def read(self): - """ - Reads a single character from the device. - - :returns: character read from the device - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - data = '' - - try: - read_ready, _, _ = select.select([self._device], [], [], 0.5) - - if len(read_ready) != 0: - data = self._device.recv(1) - - except socket.error as err: - raise CommError('Error while reading from device: {0}'.format(str(err)), err) - - return data.decode('utf-8') - - def read_line(self, timeout=0.0, purge_buffer=False): - """ - Reads a line from the device. - - :param timeout: read timeout - :type timeout: float - :param purge_buffer: Indicates whether to purge the buffer prior to - reading. - :type purge_buffer: bool - - :returns: line that was read - :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` - """ - - def timeout_event(): - """Handles read timeout event""" - timeout_event.reading = False - timeout_event.reading = True - - if purge_buffer: - self._buffer = b'' - - got_line, ret = False, None - - timer = threading.Timer(timeout, timeout_event) - if timeout > 0: - timer.start() - - try: - while timeout_event.reading: - read_ready, _, _ = select.select([self._device], [], [], 0.5) - - if len(read_ready) == 0: - continue - - buf = self._device.recv(1) - - if buf != b'' and buf != b"\xff": - ub = bytes_hack(buf) - - self._buffer += ub - - if ub == b"\n": - self._buffer = self._buffer.rstrip(b"\r\n") - - if len(self._buffer) > 0: - got_line = True - break - - except socket.error as err: - raise CommError('Error reading from device: {0}'.format(str(err)), err) - - except SSL.SysCallError as err: - errno, msg = err - raise CommError('SSL error while reading from device: {0} ({1})'.format(msg, errno)) - - except Exception: - raise - - else: - if got_line: - ret, self._buffer = self._buffer, b'' - - self.on_read(data=ret) - - else: - raise TimeoutError('Timeout while waiting for line terminator.') - - finally: - timer.cancel() - - return ret.decode('utf-8') - - def purge(self): - """ - Purges read/write buffers. - """ - try: - self._device.setblocking(0) - while(self._device.recv(1)): - pass - except socket.error as err: - pass - finally: - self._device.setblocking(1) - - def _init_ssl(self): - """ - Initializes our device as an SSL connection. - - :raises: :py:class:`~alarmdecoder.util.CommError` - """ - - if not have_openssl: - raise ImportError('SSL sockets have been disabled due to missing requirement: pyopenssl.') - - try: - ctx = SSL.Context(SSL.TLSv1_METHOD) - - if isinstance(self.ssl_key, crypto.PKey): - ctx.use_privatekey(self.ssl_key) - else: - ctx.use_privatekey_file(self.ssl_key) - - if isinstance(self.ssl_certificate, crypto.X509): - ctx.use_certificate(self.ssl_certificate) - else: - ctx.use_certificate_file(self.ssl_certificate) - - if isinstance(self.ssl_ca, crypto.X509): - store = ctx.get_cert_store() - store.add_cert(self.ssl_ca) - else: - ctx.load_verify_locations(self.ssl_ca, None) - - ctx.set_verify(SSL.VERIFY_PEER, self._verify_ssl_callback) - - self._device = SSL.Connection(ctx, self._device) - - except SSL.Error as err: - raise CommError('Error setting up SSL connection.', err) - - def _verify_ssl_callback(self, connection, x509, errnum, errdepth, ok): - """ - SSL verification callback. - """ - return ok diff --git a/alarmdecoder/devices/__init__.py b/alarmdecoder/devices/__init__.py new file mode 100644 index 0000000..196f120 --- /dev/null +++ b/alarmdecoder/devices/__init__.py @@ -0,0 +1,6 @@ +from .base_device import Device +from .serial_device import SerialDevice +from .socket_device import SocketDevice +from .usb_device import USBDevice + +__all__ = ['Device', 'SerialDevice', 'SocketDevice', 'USBDevice'] \ No newline at end of file diff --git a/alarmdecoder/devices/base_device.py b/alarmdecoder/devices/base_device.py new file mode 100644 index 0000000..857563c --- /dev/null +++ b/alarmdecoder/devices/base_device.py @@ -0,0 +1,139 @@ +import threading + +from ..util import CommError, TimeoutError, InvalidMessageError +from ..event import event + + +class Device(object): + """ + Base class for all `AlarmDecoder`_ (AD2) device types. + """ + + # Generic device events + on_open = event.Event("This event is called when the device has been opened.\n\n**Callback definition:** *def callback(device)*") + on_close = event.Event("This event is called when the device has been closed.\n\n**Callback definition:** def callback(device)*") + on_read = event.Event("This event is called when a line has been read from the device.\n\n**Callback definition:** def callback(device, data)*") + on_write = event.Event("This event is called when data has been written to the device.\n\n**Callback definition:** def callback(device, data)*") + + def __init__(self): + """ + Constructor + """ + self._id = '' + self._buffer = b'' + self._device = None + self._running = False + self._read_thread = None + + def __enter__(self): + """ + Support for context manager __enter__. + """ + return self + + def __exit__(self, exc_type, exc_value, traceback): + """ + Support for context manager __exit__. + """ + self.close() + + return False + + @property + def id(self): + """ + Retrieve the device ID. + + :returns: identification string for the device + """ + return self._id + + @id.setter + def id(self, value): + """ + Sets the device ID. + + :param value: device identification string + :type value: string + """ + self._id = value + + def is_reader_alive(self): + """ + Indicates whether or not the reader thread is alive. + + :returns: whether or not the reader thread is alive + """ + return self._read_thread.is_alive() + + def stop_reader(self): + """ + Stops the reader thread. + """ + self._read_thread.stop() + + def close(self): + """ + Closes the device. + """ + try: + self._running = False + self._read_thread.stop() + self._device.close() + + except Exception: + pass + + self.on_close() + + class ReadThread(threading.Thread): + """ + Reader thread which processes messages from the device. + """ + + READ_TIMEOUT = 10 + """Timeout for the reader thread.""" + + def __init__(self, device): + """ + Constructor + + :param device: device used by the reader thread + :type device: :py:class:`~alarmdecoder.devices.Device` + """ + threading.Thread.__init__(self) + self._device = device + self._running = False + + def stop(self): + """ + Stops the running thread. + """ + self._running = False + + def run(self): + """ + The actual read process. + """ + self._running = True + + while self._running: + try: + self._device.read_line(timeout=self.READ_TIMEOUT) + + except TimeoutError: + pass + + except InvalidMessageError: + pass + + except SSL.WantReadError: + pass + + except CommError as err: + self._device.close() + + except Exception as err: + self._device.close() + self._running = False + raise \ No newline at end of file diff --git a/alarmdecoder/devices/serial_device.py b/alarmdecoder/devices/serial_device.py new file mode 100644 index 0000000..a372d3f --- /dev/null +++ b/alarmdecoder/devices/serial_device.py @@ -0,0 +1,268 @@ +import threading +import serial +import serial.tools.list_ports +import select +import sys +from .base_device import Device +from ..util import CommError, TimeoutError, NoDeviceError, bytes_hack + + +class SerialDevice(Device): + """ + `AD2USB`_, `AD2SERIAL`_ or `AD2PI`_ device utilizing the PySerial interface. + """ + + # Constants + BAUDRATE = 19200 + """Default baudrate for Serial devices.""" + + @staticmethod + def find_all(pattern=None): + """ + Returns all serial ports present. + + :param pattern: pattern to search for when retrieving serial ports + :type pattern: string + + :returns: list of devices + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + devices = [] + + try: + if pattern: + devices = serial.tools.list_ports.grep(pattern) + else: + devices = serial.tools.list_ports.comports() + + except serial.SerialException as err: + raise CommError('Error enumerating serial devices: {0}'.format(str(err)), err) + + return devices + + @property + def interface(self): + """ + Retrieves the interface used to connect to the device. + + :returns: interface used to connect to the device + """ + return self._port + + @interface.setter + def interface(self, value): + """ + Sets the interface used to connect to the device. + + :param value: name of the serial device + :type value: string + """ + self._port = value + + def __init__(self, interface=None): + """ + Constructor + + :param interface: device to open + :type interface: string + """ + Device.__init__(self) + + self._port = interface + self._id = interface + # Timeout = non-blocking to match pyftdi. + self._device = serial.Serial(timeout=0, writeTimeout=0) + + def open(self, baudrate=BAUDRATE, no_reader_thread=False): + """ + Opens the device. + + :param baudrate: baudrate to use with the device + :type baudrate: int + :param no_reader_thread: whether or not to automatically start the + reader thread. + :type no_reader_thread: bool + + :raises: :py:class:`~alarmdecoder.util.NoDeviceError` + """ + # Set up the defaults + if baudrate is None: + baudrate = SerialDevice.BAUDRATE + + if self._port is None: + raise NoDeviceError('No device interface specified.') + + self._read_thread = Device.ReadThread(self) + + # Open the device and start up the reader thread. + try: + self._device.port = self._port + self._device.open() + # NOTE: Setting the baudrate before opening the + # port caused issues with Moschip 7840/7820 + # USB Serial Driver converter. (mos7840) + # + # Moving it to this point seems to resolve + # all issues with it. + self._device.baudrate = baudrate + + except (serial.SerialException, ValueError, OSError) as err: + raise NoDeviceError('Error opening device on {0}.'.format(self._port), err) + + else: + self._running = True + self.on_open() + + if not no_reader_thread: + self._read_thread.start() + + return self + + def close(self): + """ + Closes the device. + """ + try: + Device.close(self) + + except Exception: + pass + + def fileno(self): + """ + Returns the file number associated with the device + + :returns: int + """ + return self._device.fileno() + + def write(self, data): + """ + Writes data to the device. + + :param data: data to write + :type data: string + + :raises: py:class:`~alarmdecoder.util.CommError` + """ + try: + # Hack to support unicode under Python 2.x + if isinstance(data, str) or (sys.version_info < (3,) and isinstance(data, unicode)): + data = data.encode('utf-8') + + self._device.write(data) + + except serial.SerialTimeoutException: + pass + + except serial.SerialException as err: + raise CommError('Error writing to device.', err) + + else: + self.on_write(data=data) + + def read(self): + """ + Reads a single character from the device. + + :returns: character read from the device + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + data = '' + + try: + read_ready, _, _ = select.select([self._device.fileno()], [], [], 0.5) + + if len(read_ready) != 0: + data = self._device.read(1) + + except serial.SerialException as err: + raise CommError('Error reading from device: {0}'.format(str(err)), err) + + return data.decode('utf-8') + + def read_line(self, timeout=0.0, purge_buffer=False): + """ + Reads a line from the device. + + :param timeout: read timeout + :type timeout: float + :param purge_buffer: Indicates whether to purge the buffer prior to + reading. + :type purge_buffer: bool + + :returns: line that was read + :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` + """ + + def timeout_event(): + """Handles read timeout event""" + timeout_event.reading = False + timeout_event.reading = True + + if purge_buffer: + self._buffer = b'' + + got_line, data = False, '' + + timer = threading.Timer(timeout, timeout_event) + if timeout > 0: + timer.start() + + leftovers = b'' + try: + while timeout_event.reading and not got_line: + read_ready, _, _ = select.select([self._device.fileno()], [], [], 0.5) + if len(read_ready) == 0: + continue + + bytes_avail = 0 + if hasattr(self._device, "in_waiting"): + bytes_avail = self._device.in_waiting + else: + bytes_avail = self._device.inWaiting() + + buf = self._device.read(bytes_avail) + + for idx in range(len(buf)): + c = buf[idx] + + ub = bytes_hack(c) + if sys.version_info > (3,): + ub = bytes([ub]) + + # NOTE: AD2SERIAL and AD2PI apparently sends down \xFF on boot. + if ub != b'' and ub != b"\xff": + self._buffer += ub + + if ub == b"\n": + self._buffer = self._buffer.strip(b"\r\n") + + if len(self._buffer) > 0: + got_line = True + leftovers = buf[idx:] + break + + except (OSError, serial.SerialException) as err: + raise CommError('Error reading from device: {0}'.format(str(err)), err) + + else: + if got_line: + data, self._buffer = self._buffer, leftovers + + self.on_read(data=data) + + else: + raise TimeoutError('Timeout while waiting for line terminator.') + + finally: + timer.cancel() + + return data.decode('utf-8') + + def purge(self): + """ + Purges read/write buffers. + """ + self._device.flushInput() + self._device.flushOutput() diff --git a/alarmdecoder/devices/socket_device.py b/alarmdecoder/devices/socket_device.py new file mode 100644 index 0000000..301cbe0 --- /dev/null +++ b/alarmdecoder/devices/socket_device.py @@ -0,0 +1,388 @@ +import threading +import socket +import select +from .base_device import Device +from ..util import CommError, TimeoutError, NoDeviceError, bytes_hack + +try: + from OpenSSL import SSL, crypto + + have_openssl = True + +except ImportError: + class SSL: + class Error(BaseException): + pass + + class WantReadError(BaseException): + pass + + class SysCallError(BaseException): + pass + + have_openssl = False + + +class SocketDevice(Device): + """ + Device that supports communication with an `AlarmDecoder`_ (AD2) that is + exposed via `ser2sock`_ or another Serial to IP interface. + """ + + @property + def interface(self): + """ + Retrieves the interface used to connect to the device. + + :returns: interface used to connect to the device + """ + return (self._host, self._port) + + @interface.setter + def interface(self, value): + """ + Sets the interface used to connect to the device. + + :param value: Tuple containing the host and port to use + :type value: tuple + """ + self._host, self._port = value + + @property + def ssl(self): + """ + Retrieves whether or not the device is using SSL. + + :returns: whether or not the device is using SSL + """ + return self._use_ssl + + @ssl.setter + def ssl(self, value): + """ + Sets whether or not SSL communication is in use. + + :param value: Whether or not SSL communication is in use + :type value: bool + """ + self._use_ssl = value + + @property + def ssl_certificate(self): + """ + Retrieves the SSL client certificate path used for authentication. + + :returns: path to the certificate path or :py:class:`OpenSSL.crypto.X509` + """ + return self._ssl_certificate + + @ssl_certificate.setter + def ssl_certificate(self, value): + """ + Sets the SSL client certificate to use for authentication. + + :param value: path to the SSL certificate or :py:class:`OpenSSL.crypto.X509` + :type value: string or :py:class:`OpenSSL.crypto.X509` + """ + self._ssl_certificate = value + + @property + def ssl_key(self): + """ + Retrieves the SSL client certificate key used for authentication. + + :returns: jpath to the SSL key or :py:class:`OpenSSL.crypto.PKey` + """ + return self._ssl_key + + @ssl_key.setter + def ssl_key(self, value): + """ + Sets the SSL client certificate key to use for authentication. + + :param value: path to the SSL key or :py:class:`OpenSSL.crypto.PKey` + :type value: string or :py:class:`OpenSSL.crypto.PKey` + """ + self._ssl_key = value + + @property + def ssl_ca(self): + """ + Retrieves the SSL Certificate Authority certificate used for + authentication. + + :returns: path to the CA certificate or :py:class:`OpenSSL.crypto.X509` + """ + return self._ssl_ca + + @ssl_ca.setter + def ssl_ca(self, value): + """ + Sets the SSL Certificate Authority certificate used for authentication. + + :param value: path to the SSL CA certificate or :py:class:`OpenSSL.crypto.X509` + :type value: string or :py:class:`OpenSSL.crypto.X509` + """ + self._ssl_ca = value + + def __init__(self, interface=("localhost", 10000)): + """ + Constructor + + :param interface: Tuple containing the hostname and port of our target + :type interface: tuple + """ + Device.__init__(self) + + self._host, self._port = interface + self._use_ssl = False + self._ssl_certificate = None + self._ssl_key = None + self._ssl_ca = None + + def open(self, baudrate=None, no_reader_thread=False): + """ + Opens the device. + + :param baudrate: baudrate to use + :type baudrate: int + :param no_reader_thread: whether or not to automatically open the reader + thread. + :type no_reader_thread: bool + + :raises: :py:class:`~alarmdecoder.util.NoDeviceError`, :py:class:`~alarmdecoder.util.CommError` + """ + + try: + self._read_thread = Device.ReadThread(self) + + self._device = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + if self._use_ssl: + self._init_ssl() + + self._device.connect((self._host, self._port)) + + if self._use_ssl: + while True: + try: + self._device.do_handshake() + break + except SSL.WantReadError: + pass + + self._id = '{0}:{1}'.format(self._host, self._port) + + except socket.error as err: + raise NoDeviceError('Error opening device at {0}:{1}'.format(self._host, self._port), err) + + else: + self._running = True + self.on_open() + + if not no_reader_thread: + self._read_thread.start() + + return self + + def close(self): + """ + Closes the device. + """ + try: + # TODO: Find a way to speed up this shutdown. + if self.ssl: + self._device.shutdown() + + else: + # Make sure that it closes immediately. + self._device.shutdown(socket.SHUT_RDWR) + + except Exception: + pass + + Device.close(self) + + def fileno(self): + """ + Returns the file number associated with the device + + :returns: int + """ + return self._device.fileno() + + def write(self, data): + """ + Writes data to the device. + + :param data: data to write + :type data: string + + :returns: number of bytes sent + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + data_sent = None + + try: + if isinstance(data, str): + data = data.encode('utf-8') + + data_sent = self._device.send(data) + + if data_sent == 0: + raise CommError('Error writing to device.') + + self.on_write(data=data) + + except (SSL.Error, socket.error) as err: + raise CommError('Error writing to device.', err) + + return data_sent + + def read(self): + """ + Reads a single character from the device. + + :returns: character read from the device + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + data = '' + + try: + read_ready, _, _ = select.select([self._device], [], [], 0.5) + + if len(read_ready) != 0: + data = self._device.recv(1) + + except socket.error as err: + raise CommError('Error while reading from device: {0}'.format(str(err)), err) + + return data.decode('utf-8') + + def read_line(self, timeout=0.0, purge_buffer=False): + """ + Reads a line from the device. + + :param timeout: read timeout + :type timeout: float + :param purge_buffer: Indicates whether to purge the buffer prior to + reading. + :type purge_buffer: bool + + :returns: line that was read + :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` + """ + + def timeout_event(): + """Handles read timeout event""" + timeout_event.reading = False + timeout_event.reading = True + + if purge_buffer: + self._buffer = b'' + + got_line, ret = False, None + + timer = threading.Timer(timeout, timeout_event) + if timeout > 0: + timer.start() + + try: + while timeout_event.reading: + read_ready, _, _ = select.select([self._device], [], [], 0.5) + + if len(read_ready) == 0: + continue + + buf = self._device.recv(1) + + if buf != b'' and buf != b"\xff": + ub = bytes_hack(buf) + + self._buffer += ub + + if ub == b"\n": + self._buffer = self._buffer.rstrip(b"\r\n") + + if len(self._buffer) > 0: + got_line = True + break + + except socket.error as err: + raise CommError('Error reading from device: {0}'.format(str(err)), err) + + except SSL.SysCallError as err: + errno, msg = err + raise CommError('SSL error while reading from device: {0} ({1})'.format(msg, errno)) + + except Exception: + raise + + else: + if got_line: + ret, self._buffer = self._buffer, b'' + + self.on_read(data=ret) + + else: + raise TimeoutError('Timeout while waiting for line terminator.') + + finally: + timer.cancel() + + return ret.decode('utf-8') + + def purge(self): + """ + Purges read/write buffers. + """ + try: + self._device.setblocking(0) + while(self._device.recv(1)): + pass + except socket.error as err: + pass + finally: + self._device.setblocking(1) + + def _init_ssl(self): + """ + Initializes our device as an SSL connection. + + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + + if not have_openssl: + raise ImportError('SSL sockets have been disabled due to missing requirement: pyopenssl.') + + try: + ctx = SSL.Context(SSL.TLSv1_METHOD) + + if isinstance(self.ssl_key, crypto.PKey): + ctx.use_privatekey(self.ssl_key) + else: + ctx.use_privatekey_file(self.ssl_key) + + if isinstance(self.ssl_certificate, crypto.X509): + ctx.use_certificate(self.ssl_certificate) + else: + ctx.use_certificate_file(self.ssl_certificate) + + if isinstance(self.ssl_ca, crypto.X509): + store = ctx.get_cert_store() + store.add_cert(self.ssl_ca) + else: + ctx.load_verify_locations(self.ssl_ca, None) + + ctx.set_verify(SSL.VERIFY_PEER, self._verify_ssl_callback) + + self._device = SSL.Connection(ctx, self._device) + + except SSL.Error as err: + raise CommError('Error setting up SSL connection.', err) + + def _verify_ssl_callback(self, connection, x509, errnum, errdepth, ok): + """ + SSL verification callback. + """ + return ok diff --git a/alarmdecoder/devices/usb_device.py b/alarmdecoder/devices/usb_device.py new file mode 100644 index 0000000..8d3e617 --- /dev/null +++ b/alarmdecoder/devices/usb_device.py @@ -0,0 +1,482 @@ +import time +import threading +from .base_device import Device +from ..util import CommError, TimeoutError, NoDeviceError, bytes_hack +from ..event import event + +have_pyftdi = False +try: + from pyftdi.pyftdi.ftdi import Ftdi, FtdiError + import usb.core + import usb.util + + have_pyftdi = True + +except ImportError: + try: + from pyftdi.ftdi import Ftdi, FtdiError + import usb.core + import usb.util + + have_pyftdi = True + + except ImportError: + have_pyftdi = False + + +class USBDevice(Device): + """ + `AD2USB`_ device utilizing PyFTDI's interface. + """ + + # Constants + PRODUCT_IDS = ((0x0403, 0x6001), (0x0403, 0x6015)) + """List of Vendor and Product IDs used to recognize `AD2USB`_ devices.""" + DEFAULT_VENDOR_ID = PRODUCT_IDS[0][0] + """Default Vendor ID used to recognize `AD2USB`_ devices.""" + DEFAULT_PRODUCT_ID = PRODUCT_IDS[0][1] + """Default Product ID used to recognize `AD2USB`_ devices.""" + + # Deprecated constants + FTDI_VENDOR_ID = DEFAULT_VENDOR_ID + """DEPRECATED: Vendor ID used to recognize `AD2USB`_ devices.""" + FTDI_PRODUCT_ID = DEFAULT_PRODUCT_ID + """DEPRECATED: Product ID used to recognize `AD2USB`_ devices.""" + + + BAUDRATE = 115200 + """Default baudrate for `AD2USB`_ devices.""" + + __devices = [] + __detect_thread = None + + @classmethod + def find_all(cls, vid=None, pid=None): + """ + Returns all FTDI devices matching our vendor and product IDs. + + :returns: list of devices + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + if not have_pyftdi: + raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') + + cls.__devices = [] + + query = cls.PRODUCT_IDS + if vid and pid: + query = [(vid, pid)] + + try: + cls.__devices = Ftdi.find_all(query, nocache=True) + + except (usb.core.USBError, FtdiError) as err: + raise CommError('Error enumerating AD2USB devices: {0}'.format(str(err)), err) + + return cls.__devices + + @classmethod + def devices(cls): + """ + Returns a cached list of `AD2USB`_ devices located on the system. + + :returns: cached list of devices found + """ + return cls.__devices + + @classmethod + def find(cls, device=None): + """ + Factory method that returns the requested :py:class:`USBDevice` device, or the + first device. + + :param device: Tuple describing the USB device to open, as returned + by find_all(). + :type device: tuple + + :returns: :py:class:`USBDevice` object utilizing the specified device + :raises: :py:class:`~alarmdecoder.util.NoDeviceError` + """ + if not have_pyftdi: + raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') + + cls.find_all() + + if len(cls.__devices) == 0: + raise NoDeviceError('No AD2USB devices present.') + + if device is None: + device = cls.__devices[0] + + vendor, product, sernum, ifcount, description = device + + return USBDevice(interface=sernum, vid=vendor, pid=product) + + @classmethod + def start_detection(cls, on_attached=None, on_detached=None): + """ + Starts the device detection thread. + + :param on_attached: function to be called when a device is attached **Callback definition:** *def callback(thread, device)* + :type on_attached: function + :param on_detached: function to be called when a device is detached **Callback definition:** *def callback(thread, device)* + + :type on_detached: function + """ + if not have_pyftdi: + raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') + + cls.__detect_thread = USBDevice.DetectThread(on_attached, on_detached) + + try: + cls.find_all() + except CommError: + pass + + cls.__detect_thread.start() + + @classmethod + def stop_detection(cls): + """ + Stops the device detection thread. + """ + if not have_pyftdi: + raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') + + try: + cls.__detect_thread.stop() + + except Exception: + pass + + @property + def interface(self): + """ + Retrieves the interface used to connect to the device. + + :returns: the interface used to connect to the device + """ + return self._interface + + @interface.setter + def interface(self, value): + """ + Sets the interface used to connect to the device. + + :param value: may specify either the serial number or the device index + :type value: string or int + """ + self._interface = value + if isinstance(value, int): + self._device_number = value + else: + self._serial_number = value + + @property + def serial_number(self): + """ + Retrieves the serial number of the device. + + :returns: serial number of the device + """ + + return self._serial_number + + @serial_number.setter + def serial_number(self, value): + """ + Sets the serial number of the device. + + :param value: serial number of the device + :type value: string + """ + self._serial_number = value + + @property + def description(self): + """ + Retrieves the description of the device. + + :returns: description of the device + """ + return self._description + + @description.setter + def description(self, value): + """ + Sets the description of the device. + + :param value: description of the device + :type value: string + """ + self._description = value + + def __init__(self, interface=0, vid=None, pid=None): + """ + Constructor + + :param interface: May specify either the serial number or the device + index. + :type interface: string or int + """ + if not have_pyftdi: + raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') + + Device.__init__(self) + + self._device = Ftdi() + + self._interface = 0 + self._device_number = 0 + self._serial_number = None + + self._vendor_id = USBDevice.DEFAULT_VENDOR_ID + if vid: + self._vendor_id = vid + + self._product_id = USBDevice.DEFAULT_PRODUCT_ID + if pid: + self._product_id = pid + + self._endpoint = 0 + self._description = None + + self.interface = interface + + def open(self, baudrate=BAUDRATE, no_reader_thread=False): + """ + Opens the device. + + :param baudrate: baudrate to use + :type baudrate: int + :param no_reader_thread: whether or not to automatically start the + reader thread. + :type no_reader_thread: bool + + :raises: :py:class:`~alarmdecoder.util.NoDeviceError` + """ + # Set up defaults + if baudrate is None: + baudrate = USBDevice.BAUDRATE + + self._read_thread = Device.ReadThread(self) + + # Open the device and start up the thread. + try: + self._device.open(self._vendor_id, + self._product_id, + self._endpoint, + self._device_number, + self._serial_number, + self._description) + + self._device.set_baudrate(baudrate) + + if not self._serial_number: + self._serial_number = self._get_serial_number() + + self._id = self._serial_number + + except (usb.core.USBError, FtdiError) as err: + raise NoDeviceError('Error opening device: {0}'.format(str(err)), err) + + except KeyError as err: + raise NoDeviceError('Unsupported device. ({0:04x}:{1:04x}) You probably need a newer version of pyftdi.'.format(err[0][0], err[0][1])) + + else: + self._running = True + self.on_open() + + if not no_reader_thread: + self._read_thread.start() + + return self + + def close(self): + """ + Closes the device. + """ + try: + Device.close(self) + + # HACK: Probably should fork pyftdi and make this call in .close() + self._device.usb_dev.attach_kernel_driver(self._device_number) + + except Exception: + pass + + def fileno(self): + """ + File number not supported for USB devices. + + :raises: NotImplementedError + """ + raise NotImplementedError('USB devices do not support fileno()') + + def write(self, data): + """ + Writes data to the device. + + :param data: data to write + :type data: string + + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + try: + self._device.write_data(data) + + self.on_write(data=data) + + except FtdiError as err: + raise CommError('Error writing to device: {0}'.format(str(err)), err) + + def read(self): + """ + Reads a single character from the device. + + :returns: character read from the device + :raises: :py:class:`~alarmdecoder.util.CommError` + """ + ret = None + + try: + ret = self._device.read_data(1) + + except (usb.core.USBError, FtdiError) as err: + raise CommError('Error reading from device: {0}'.format(str(err)), err) + + return ret + + def read_line(self, timeout=0.0, purge_buffer=False): + """ + Reads a line from the device. + + :param timeout: read timeout + :type timeout: float + :param purge_buffer: Indicates whether to purge the buffer prior to + reading. + :type purge_buffer: bool + + :returns: line that was read + :raises: :py:class:`~alarmdecoder.util.CommError`, :py:class:`~alarmdecoder.util.TimeoutError` + """ + + def timeout_event(): + """Handles read timeout event""" + timeout_event.reading = False + timeout_event.reading = True + + if purge_buffer: + self._buffer = b'' + + got_line, ret = False, None + + timer = threading.Timer(timeout, timeout_event) + if timeout > 0: + timer.start() + + try: + while timeout_event.reading: + buf = self._device.read_data(1) + + if buf != b'': + ub = bytes_hack(buf) + + self._buffer += ub + + if ub == b"\n": + self._buffer = self._buffer.rstrip(b"\r\n") + + if len(self._buffer) > 0: + got_line = True + break + else: + time.sleep(0.01) + + except (usb.core.USBError, FtdiError) as err: + raise CommError('Error reading from device: {0}'.format(str(err)), err) + + else: + if got_line: + ret, self._buffer = self._buffer, b'' + + self.on_read(data=ret) + + else: + raise TimeoutError('Timeout while waiting for line terminator.') + + finally: + timer.cancel() + + return ret + + def purge(self): + """ + Purges read/write buffers. + """ + self._device.purge_buffers() + + def _get_serial_number(self): + """ + Retrieves the FTDI device serial number. + + :returns: string containing the device serial number + """ + return usb.util.get_string(self._device.usb_dev, 64, self._device.usb_dev.iSerialNumber) + + class DetectThread(threading.Thread): + """ + Thread that handles detection of added/removed devices. + """ + on_attached = event.Event("This event is called when an `AD2USB`_ device has been detected.\n\n**Callback definition:** def callback(thread, device*") + on_detached = event.Event("This event is called when an `AD2USB`_ device has been removed.\n\n**Callback definition:** def callback(thread, device*") + + def __init__(self, on_attached=None, on_detached=None): + """ + Constructor + + :param on_attached: Function to call when a device is attached **Callback definition:** *def callback(thread, device)* + :type on_attached: function + :param on_detached: Function to call when a device is detached **Callback definition:** *def callback(thread, device)* + :type on_detached: function + """ + threading.Thread.__init__(self) + + if on_attached: + self.on_attached += on_attached + + if on_detached: + self.on_detached += on_detached + + self._running = False + + def stop(self): + """ + Stops the thread. + """ + self._running = False + + def run(self): + """ + The actual detection process. + """ + self._running = True + + last_devices = set() + + while self._running: + try: + current_devices = set(USBDevice.find_all()) + + for dev in current_devices.difference(last_devices): + self.on_attached(device=dev) + + for dev in last_devices.difference(current_devices): + self.on_detached(device=dev) + + last_devices = current_devices + + except CommError: + pass + + time.sleep(0.25) \ No newline at end of file diff --git a/alarmdecoder/util.py b/alarmdecoder/util.py index f7c00d9..ad6291b 100644 --- a/alarmdecoder/util.py +++ b/alarmdecoder/util.py @@ -9,6 +9,7 @@ Provides utility classes for the `AlarmDecoder`_ (AD2) devices. import time import threading import select +import sys import alarmdecoder from io import open @@ -79,6 +80,19 @@ def bytes_available(device): return bytes_avail +def bytes_hack(buf): + """ + Hacky workaround for old installs of the library on systems without python-future that were + keeping the 2to3 update from working after auto-update. + """ + ub = None + if sys.version_info > (3,): + ub = buf + else: + ub = bytes(buf) + + return ub + def read_firmware_file(file_path): """ Reads a firmware file into a dequeue for processing.