"""
Provides the main AlarmDecoder class.

.. _AlarmDecoder: http://www.alarmdecoder.com

.. moduleauthor:: Scott Petersen <scott@nutech.com>
"""

import time
import re

from builtins import chr

from .event import event
from .util import InvalidMessageError
from .messages import Message, ExpanderMessage, RFMessage, LRRMessage
from .zonetracking import Zonetracker
from .panels import PANEL_TYPES, ADEMCO, DSC

class AlarmDecoder(object):
    """
    High-level wrapper around `AlarmDecoder`_ (AD2) devices.
    """

    # High-level Events
    on_arm = event.Event("This event is called when the panel is armed.\n\n**Callback definition:** *def callback(device)*")
    on_disarm = event.Event("This event is called when the panel is disarmed.\n\n**Callback definition:** *def callback(device)*")
    on_power_changed = event.Event("This event is called when panel power switches between AC and DC.\n\n**Callback definition:** *def callback(device, status)*")
    on_alarm = event.Event("This event is called when the alarm is triggered.\n\n**Callback definition:** *def callback(device, zone)*")
    on_alarm_restored = event.Event("This event is called when the alarm stops sounding.\n\n**Callback definition:** *def callback(device, zone)*")
    on_fire = event.Event("This event is called when a fire is detected.\n\n**Callback definition:** *def callback(device, status)*")
    on_bypass = event.Event("This event is called when a zone is bypassed.  \n\n\n\n**Callback definition:** *def callback(device, status)*")
    on_boot = event.Event("This event is called when the device finishes booting.\n\n**Callback definition:** *def callback(device)*")
    on_config_received = event.Event("This event is called when the device receives its configuration. \n\n**Callback definition:** *def callback(device)*")
    on_zone_fault = event.Event("This event is called when :py:class:`~alarmdecoder.zonetracking.Zonetracker` detects a zone fault.\n\n**Callback definition:** *def callback(device, zone)*")
    on_zone_restore = event.Event("This event is called when :py:class:`~alarmdecoder.zonetracking.Zonetracker` detects that a fault is restored.\n\n**Callback definition:** *def callback(device, zone)*")
    on_low_battery = event.Event("This event is called when the device detects a low battery.\n\n**Callback definition:** *def callback(device, status)*")
    on_panic = event.Event("This event is called when the device detects a panic.\n\n**Callback definition:** *def callback(device, status)*")
    on_relay_changed = event.Event("This event is called when a relay is opened or closed on an expander board.\n\n**Callback definition:** *def callback(device, message)*")

    # Mid-level Events
    on_message = event.Event("This event is called when standard panel :py:class:`~alarmdecoder.messages.Message` is received.\n\n**Callback definition:** *def callback(device, message)*")
    on_expander_message = event.Event("This event is called when an :py:class:`~alarmdecoder.messages.ExpanderMessage` is received.\n\n**Callback definition:** *def callback(device, message)*")
    on_lrr_message = event.Event("This event is called when an :py:class:`~alarmdecoder.messages.LRRMessage` is received.\n\n**Callback definition:** *def callback(device, message)*")
    on_rfx_message = event.Event("This event is called when an :py:class:`~alarmdecoder.messages.RFMessage` is received.\n\n**Callback definition:** *def callback(device, message)*")
    on_sending_received = event.Event("This event is called when a !Sending.done message is received from the AlarmDecoder.\n\n**Callback definition:** *def callback(device, status, message)*")

    # Low-level 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)*")

    # Constants
    KEY_F1 = chr(1) + chr(1) + chr(1)
    """Represents panel function key #1"""
    KEY_F2 = chr(2) + chr(2) + chr(2)
    """Represents panel function key #2"""
    KEY_F3 = chr(3) + chr(3) + chr(3)
    """Represents panel function key #3"""
    KEY_F4 = chr(4) + chr(4) + chr(4)
    """Represents panel function key #4"""
    KEY_PANIC = chr(5) + chr(5) + chr(5)
    """Represents a panic keypress"""

    BATTERY_TIMEOUT = 30
    """Default timeout (in seconds) before the battery status reverts."""
    FIRE_TIMEOUT = 30
    """Default tTimeout (in seconds) before the fire status reverts."""

    # Attributes
    address = 18
    """The keypad address in use by the device."""
    configbits = 0xFF00
    """The configuration bits set on the device."""
    address_mask = 0xFFFFFFFF
    """The address mask configured on the device."""
    emulate_zone = [False for _ in list(range(5))]
    """List containing the devices zone emulation status."""
    emulate_relay = [False for _ in list(range(4))]
    """List containing the devices relay emulation status."""
    emulate_lrr = False
    """The status of the devices LRR emulation."""
    deduplicate = False
    """The status of message deduplication as configured on the device."""
    mode = ADEMCO
    """The panel mode that the AlarmDecoder is in.  Currently supports ADEMCO and DSC."""

    #Version Information
    serial_number = 0xFFFFFFFF
    """The device serial number"""
    version_number = 'Unknown'
    """The device firmware version"""
    version_flags = ""
    """Device flags enabled"""

    def __init__(self, device):
        """
        Constructor

        :param device: The low-level device used for this `AlarmDecoder`_
                       interface.
        :type device: Device
        """
        self._device = device
        self._zonetracker = Zonetracker(self)

        self._battery_timeout = AlarmDecoder.BATTERY_TIMEOUT
        self._fire_timeout = AlarmDecoder.FIRE_TIMEOUT
        self._power_status = None
        self._alarm_status = None
        self._bypass_status = None
        self._armed_status = None
        self._fire_status = (False, 0)
        self._battery_status = (False, 0)
        self._panic_status = False
        self._relay_status = {}
        self._internal_address_mask = 0xFFFFFFFF

        self.address = 18
        self.configbits = 0xFF00
        self.address_mask = 0xFFFFFFFF
        self.emulate_zone = [False for x in list(range(5))]
        self.emulate_relay = [False for x in list(range(4))]
        self.emulate_lrr = False
        self.deduplicate = False
        self.mode = ADEMCO

        self.serial_number = 0xFFFFFFFF
        self.version_number = 'Unknown'
        self.version_flags = ""

    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):
        """
        The ID of the `AlarmDecoder`_ device.

        :returns: identification string for the device
        """
        return self._device.id

    @property
    def battery_timeout(self):
        """
        Retrieves the timeout for restoring the battery status, in seconds.

        :returns: battery status timeout
        """
        return self._battery_timeout

    @battery_timeout.setter
    def battery_timeout(self, value):
        """
        Sets the timeout for restoring the battery status, in seconds.

        :param value: timeout in seconds
        :type value: int
        """
        self._battery_timeout = value

    @property
    def fire_timeout(self):
        """
        Retrieves the timeout for restoring the fire status, in seconds.

        :returns: fire status timeout
        """
        return self._fire_timeout

    @fire_timeout.setter
    def fire_timeout(self, value):
        """
        Sets the timeout for restoring the fire status, in seconds.

        """
        self._fire_timeout = value

    @property
    def internal_address_mask(self):
        """
        Retrieves the address mask used for updating internal status.

        :returns: address mask
        """
        return self._internal_address_mask

    @internal_address_mask.setter
    def internal_address_mask(self, value):
        """
        Sets the address mask used internally for updating status.

        :param value: address mask
        """
        self._internal_address_mask = value

    def open(self, baudrate=None, no_reader_thread=False):
        """
        Opens the device.

        :param baudrate: baudrate used for the device.  Defaults to the lower-level device default.
        :type baudrate: int
        :param no_reader_thread: Specifies whether or not the automatic reader
                                 thread should be started.
        :type no_reader_thread: bool
        """
        self._wire_events()
        self._device.open(baudrate=baudrate, no_reader_thread=no_reader_thread)

    def close(self):
        """
        Closes the device.
        """
        if self._device:
            self._device.close()

        del self._device
        self._device = None

    def send(self, data):
        """
        Sends data to the `AlarmDecoder`_ device.

        :param data: data to send
        :type data: string
        """

            self._device.write(str.encode(data))

    def get_config(self):
        """
        Retrieves the configuration from the device.  Called automatically by :py:meth:`_on_open`.
        """
        self.send("C\r")

    def save_config(self):
        """
        Sets configuration entries on the device.
        """
        self.send("C{0}\r".format(self.get_config_string()))

    def get_config_string(self):
        config_entries = []

        # HACK: This is ugly.. but I can't think of an elegant way of doing it.
        config_entries.append(('ADDRESS', '{0}'.format(self.address)))
        config_entries.append(('CONFIGBITS', '{0:x}'.format(self.configbits)))
        config_entries.append(('MASK', '{0:x}'.format(self.address_mask)))
        config_entries.append(('EXP',
                               ''.join(['Y' if z else 'N' for z in self.emulate_zone])))
        config_entries.append(('REL',
                               ''.join(['Y' if r else 'N' for r in self.emulate_relay])))
        config_entries.append(('LRR', 'Y' if self.emulate_lrr else 'N'))
        config_entries.append(('DEDUPLICATE', 'Y' if self.deduplicate else 'N'))
        config_entries.append(('MODE', list(PANEL_TYPES)[list(PANEL_TYPES.values()).index(self.mode)]))

        config_string = '&'.join(['='.join(t) for t in config_entries])

        return '&'.join(['='.join(t) for t in config_entries])

    def get_version(self):
        """
        Retrieves the version string from the device.  Called automatically by :py:meth:`_on_open`.
        """
        self.send("V\r")

    def reboot(self):
        """
        Reboots the device.
        """
        self.send('=')

    def fault_zone(self, zone, simulate_wire_problem=False):
        """
        Faults a zone if we are emulating a zone expander.

        :param zone: zone to fault
        :type zone: int
        :param simulate_wire_problem: Whether or not to simulate a wire fault
        :type simulate_wire_problem: bool
        """

        # Allow ourselves to also be passed an address/channel combination
        # for zone expanders.
        #
        # Format (expander index, channel)
        if isinstance(zone, tuple):
            expander_idx, channel = zone

            zone = self._zonetracker.expander_to_zone(expander_idx, channel)

        status = 2 if simulate_wire_problem else 1

        self.send("L{0:02}{1}\r".format(zone, status))

    def clear_zone(self, zone):
        """
        Clears a zone if we are emulating a zone expander.

        :param zone: zone to clear
        """
        self.send("L{0:02}0\r".format(zone))

    def _wire_events(self):
        """
        Wires up the internal device events.
        """
        self._device.on_open += self._on_open
        self._device.on_close += self._on_close
        self._device.on_read += self._on_read
        self._device.on_write += self._on_write
        self._zonetracker.on_fault += self._on_zone_fault
        self._zonetracker.on_restore += self._on_zone_restore

    def _handle_message(self, data):
        """
        Parses keypad messages from the panel.

        :param data: keypad data to parse

        :returns: :py:class:`~alarmdecoder.messages.Message`
        """

        data = data.decode('utf-8')

        if data is not None:
            data = data.lstrip('\0')

        if data is None or data == '':
            raise InvalidMessageError()

        msg = None
        header = data[0:4]

        if header[0] != '!' or header == '!KPM':
            msg = self._handle_keypad_message(data)

        elif header == '!EXP' or header == '!REL':
            msg = self._handle_expander_message(data)

        elif header == '!RFX':
            msg = self._handle_rfx(data)

        elif header == '!LRR':
            msg = self._handle_lrr(data)

        elif data.startswith('!Ready'):
            self.on_boot()

        elif data.startswith('!CONFIG'):
            self._handle_config(data)

        elif data.startswith('!VER'):
            self._handle_version(data)

        elif data.startswith('!Sending'):
            self._handle_sending(data)

        return msg

    def _handle_keypad_message(self, data):
        """
        Handle keypad messages.

        :param data: keypad message to parse

        """
        msg = Message(data)

        if self._internal_address_mask & msg.mask > 0:
            self._update_internal_states(msg)

            self.on_message(message=msg)

        return msg

    def _handle_expander_message(self, data):
        """
        Handle expander messages.

        :param data: expander message to parse

        :returns: :py:class:`~alarmdecoder.messages.ExpanderMessage`
        """
        msg = ExpanderMessage(data)

        self._update_internal_states(msg)
        self.on_expander_message(message=msg)

        return msg

    def _handle_rfx(self, data):
        """
        Handle RF messages.

        :param data: RF message to parse

        :returns: :py:class:`~alarmdecoder.messages.RFMessage`
        """
        msg = RFMessage(data)

        self.on_rfx_message(message=msg)

        return msg

    def _handle_lrr(self, data):
        """
        Handle Long Range Radio messages.

        :param data: LRR message to parse

        :returns: :py:class:`~alarmdecoder.messages.LRRMessage`
        """
        msg = LRRMessage(data)

        if msg.event_type == 'ALARM_PANIC':
            self._panic_status = True
            self.on_panic(status=True)

        elif msg.event_type == 'CANCEL':
            if self._panic_status is True:
                self._panic_status = False
                self.on_panic(status=False)

        self.on_lrr_message(message=msg)

        return msg

    def _handle_version(self, data):
        """
        Handles received version data.
        
        :param data: Version string to parse
        """

        _, version_string = data.split(':')
        version_parts = version_string.split(',')

        self.serial_number = version_parts[0]
        self.version_number = version_parts[1]
        self.version_flags = version_parts[2]

    def _handle_config(self, data):
        """
        Handles received configuration data.

        :param data: Configuration string to parse
        """
        _, config_string = data.split('>')
        for setting in config_string.split('&'):
            key, val = setting.split('=')

            if key == 'ADDRESS':
                self.address = int(val)
            elif key == 'CONFIGBITS':
                self.configbits = int(val, 16)
            elif key == 'MASK':
                self.address_mask = int(val, 16)
            elif key == 'EXP':
                self.emulate_zone = [val[z] == 'Y' for z in list(range(5))]
            elif key == 'REL':
                self.emulate_relay = [val[r] == 'Y' for r in list(range(4))]
            elif key == 'LRR':
                self.emulate_lrr = (val == 'Y')
            elif key == 'DEDUPLICATE':
                self.deduplicate = (val == 'Y')
            elif key == 'MODE':
                self.mode = PANEL_TYPES[val]

        self.on_config_received()

    def _handle_sending(self, data):
        """
        Handles results of a keypress send.

        :param data: Sending string to parse
        """

        matches = re.match('^!Sending(\.{1,5})done.*', data)
        if matches is not None:
            good_send = False
            if len(matches.group(1)) < 5:
                good_send = True

            self.on_sending_received(status=good_send, message=data)

    def _update_internal_states(self, message):
        """
        Updates internal device states.

        :param message: :py:class:`~alarmdecoder.messages.Message` to update internal states with
        :type message: :py:class:`~alarmdecoder.messages.Message`, :py:class:`~alarmdecoder.messages.ExpanderMessage`, :py:class:`~alarmdecoder.messages.LRRMessage`, or :py:class:`~alarmdecoder.messages.RFMessage`
        """
        if isinstance(message, Message):
            self._update_power_status(message)
            self._update_alarm_status(message)
            self._update_zone_bypass_status(message)
            self._update_armed_status(message)
            self._update_battery_status(message)
            self._update_fire_status(message)

        elif isinstance(message, ExpanderMessage):
            self._update_expander_status(message)

        self._update_zone_tracker(message)

    def _update_power_status(self, message):
        """
        Uses the provided message to update the AC power state.

        :param message: message to use to update
        :type message: :py:class:`~alarmdecoder.messages.Message`

        :returns: bool indicating the new status
        """
        if message.ac_power != self._power_status:
            self._power_status, old_status = message.ac_power, self._power_status

            if old_status is not None:
                self.on_power_changed(status=self._power_status)

        return self._power_status

    def _update_alarm_status(self, message):
        """
        Uses the provided message to update the alarm state.

        """

        if message.alarm_sounding != self._alarm_status:
            self._alarm_status, old_status = message.alarm_sounding, self._alarm_status

                if self._alarm_status:
                    self.on_alarm(zone=message.numeric_code)
                else:
                    self.on_alarm_restored(zone=message.numeric_code)

        return self._alarm_status

    def _update_zone_bypass_status(self, message):
        """
        Uses the provided message to update the zone bypass state.

        """

        if message.zone_bypassed != self._bypass_status:
            self._bypass_status, old_status = message.zone_bypassed, self._bypass_status

                self.on_bypass(status=self._bypass_status)

        return self._bypass_status

    def _update_armed_status(self, message):
        """
        Uses the provided message to update the armed state.

        """

        message_status = message.armed_away | message.armed_home
        if message_status != self._armed_status:
            self._armed_status, old_status = message_status, self._armed_status

                if self._armed_status:
                    self.on_arm()
                else:
                    self.on_disarm()

        return self._armed_status

    def _update_battery_status(self, message):
        """
        Uses the provided message to update the battery state.

        :returns: boolean indicating the new status
        """

        last_status, last_update = self._battery_status
        if message.battery_low == last_status:
            self._battery_status = (last_status, time.time())
        else:
            if message.battery_low is True or time.time() > last_update + self._battery_timeout:
                self._battery_status = (message.battery_low, time.time())
                self.on_low_battery(status=message.battery_low)

        return self._battery_status[0]

    def _update_fire_status(self, message):
        """
        Uses the provided message to update the fire alarm state.

        """

        last_status, last_update = self._fire_status
        if message.fire_alarm == last_status:
            self._fire_status = (last_status, time.time())
        else:
            if message.fire_alarm is True or time.time() > last_update + self._fire_timeout:
                self._fire_status = (message.fire_alarm, time.time())
                self.on_fire(status=message.fire_alarm)

        return self._fire_status[0]

    def _update_expander_status(self, message):
        """
        Uses the provided message to update the expander states.

        :type message: :py:class:`~alarmdecoder.messages.ExpanderMessage`

        """

        if message.type == ExpanderMessage.RELAY:
            self._relay_status[(message.address, message.channel)] = message.value

            self.on_relay_changed(message=message)

            return self._relay_status[(message.address, message.channel)]

    def _update_zone_tracker(self, message):
        """
        Trigger an update of the :py:class:`~alarmdecoder.messages.Zonetracker`.

        :param message: message to update the zonetracker with
        """

        # Retrieve a list of faults.
        # NOTE: This only happens on first boot or after exiting programming mode.
            if not message.ready and "Hit * for faults" in message.text:
                self.send('*')
                return

        self._zonetracker.update(message)

    def _on_open(self, sender, *args, **kwargs):
        """
        Internal handler for opening the device.
        """
        self.get_config()

        self.get_version()

        self.on_open()

    def _on_close(self, sender, *args, **kwargs):
        """
        Internal handler for closing the device.
        """
        self.on_close()

    def _on_read(self, sender, *args, **kwargs):
        """
        Internal handler for reading from the device.
        """
        data = kwargs.get('data', None)
        self.on_read(data=data)

        self._handle_message(data)

    def _on_write(self, sender, *args, **kwargs):
        """
        Internal handler for writing to the device.
        """
        self.on_write(data=kwargs.get('data', None))

    def _on_zone_fault(self, sender, *args, **kwargs):
        """
        Internal handler for zone faults.
        """
        self.on_zone_fault(*args, **kwargs)

    def _on_zone_restore(self, sender, *args, **kwargs):
        """
        Internal handler for zone restoration.
        """
        self.on_zone_restore(*args, **kwargs)

# event.py (improved)
#

# Based on pyevent originally found at http://www.emptypage.jp/notes/pyevent.en.html
#
# License: https://creativecommons.org/licenses/by/2.1/jp/deed.en
#
# Changes:
#   * Added type check in fire()
#   * Removed earg from fire() and added support for args/kwargs.

class Event(object):

    def __init__(self, doc=None):
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return EventHandler(self, obj)

    def __set__(self, obj, value):
        pass

class EventHandler(object):

    def __init__(self, event, obj):

        self.event = event
        self.obj = obj

    def __iter__(self):
        return iter(self._getfunctionlist())

    def _getfunctionlist(self):

        """(internal use) """

        try:
            eventhandler = self.obj.__eventhandler__
        except AttributeError:
            eventhandler = self.obj.__eventhandler__ = {}
        return eventhandler.setdefault(self.event, [])

    def add(self, func):

        """Add new event handler function.

        Event handler function must be defined like func(sender, earg).
        You can add handler also by using '+=' operator.
        """

        self._getfunctionlist().append(func)

    def remove(self, func):

        """Remove existing event handler function.

        You can remove handler also by using '-=' operator.
        """

        self._getfunctionlist().remove(func)

    def fire(self, *args, **kwargs):

        """Fire event and call all handler functions

        You can call EventHandler object itself like e(*args, **kwargs) instead of
        e.fire(*args, **kwargs).
        """

        for func in self._getfunctionlist():
            if type(func) == EventHandler:
                func.fire(*args, **kwargs)
            else:
                func(self.obj, *args, **kwargs)

    __iadd__ = add
    __isub__ = remove
    __call__ = fire

"""
Provides utility classes for the `AlarmDecoder`_ (AD2) devices.

"""

import threading
from io import open

class NoDeviceError(Exception):
    """
    No devices found.
    """
    pass

class CommError(Exception):
    """
    There was an error communicating with the device.
    """
    pass

class TimeoutError(Exception):
    """
    There was a timeout while trying to communicate with the device.
    """
    pass

class InvalidMessageError(Exception):
    """
    The format of the panel message was invalid.
    """
    pass

class UploadError(Exception):
    """
    Generic firmware upload error.
    """
    pass

class UploadChecksumError(UploadError):
    """
    The firmware upload failed due to a checksum error.
    """
    pass

class Firmware(object):
    """
    Represents firmware for the `AlarmDecoder`_ devices.
    """

    STAGE_START = 0
    STAGE_WAITING = 1
    STAGE_BOOT = 2
    STAGE_LOAD = 3
    STAGE_UPLOADING = 4
    STAGE_DONE = 5
    STAGE_ERROR = 98
    STAGE_DEBUG = 99

    # FIXME: Rewrite this monstrosity.
    @staticmethod
    def upload(dev, filename, progress_callback=None, debug=False):
        """
        Uploads firmware to an `AlarmDecoder`_ device.

        :param filename: firmware filename
        :type filename: string
        :param progress_callback: callback function used to report progress
        :type progress_callback: function

        :raises: :py:class:`~alarmdecoder.util.NoDeviceError`, :py:class:`~alarmdecoder.util.TimeoutError`
        """

        def do_upload():
            """
            Perform the actual firmware upload to the device.
            """
            with open(filename) as upload_file:
                line_cnt = 0
                for line in upload_file:
                    line_cnt += 1
                    line = line.rstrip()

                    if line[0] == ':':
                        dev.write(line + "\r")
                        response = dev.read_line(timeout=5.0, purge_buffer=True)
                        if debug:
                            stage_callback(Firmware.STAGE_DEBUG, data="line={0} - line={1} response={2}".format(line_cnt, line, response));

                        if '!ce' in response:
                            raise UploadChecksumError("Checksum error on line " + str(line_cnt) + " of " + filename);

                        elif '!no' in response:
                            raise UploadError("Incorrect data sent to bootloader.")

                        elif '!ok' in response:
                            break

                        else:
                            if progress_callback is not None:
                                progress_callback(Firmware.STAGE_UPLOADING)

                        time.sleep(0.0)

        def read_until(pattern, timeout=0.0):
            """
            Read characters until a specific pattern is found or the timeout is
            hit.
            """
            def timeout_event():
                """Handles the read timeout event."""
                timeout_event.reading = False

            timeout_event.reading = True

            timer = None
            if timeout > 0:
                timer = threading.Timer(timeout, timeout_event)
                timer.start()

            position = 0

            dev.purge()

            while timeout_event.reading:
                try:
                    char = dev.read()

                    if char is not None and char != '':
                        if char == pattern[position]:
                            position = position + 1
                            if position == len(pattern):
                                break
                        else:
                            position = 0

                except Exception as err:
                    pass

            if timer:
                if timer.is_alive():
                    timer.cancel()
                else:
                    raise TimeoutError('Timeout while waiting for line terminator.')

        def stage_callback(stage, **kwargs):
            """Callback to update progress for the specified stage."""
            if progress_callback is not None:
                progress_callback(stage, **kwargs)

        if dev is None:
            raise NoDeviceError('No device specified for firmware upload.')

        stage_callback(Firmware.STAGE_START)

        if dev.is_reader_alive():
            # Close the reader thread and wait for it to die, otherwise
            # it interferes with our reading.
            dev.stop_reader()
            while dev._read_thread.is_alive():
                stage_callback(Firmware.STAGE_WAITING)
                time.sleep(0.5)

        # Reboot the device and wait for the boot loader.
        retry = 3
        found_loader = False
        while retry > 0:
            try:
                stage_callback(Firmware.STAGE_BOOT)
                dev.write("=")
                read_until('!boot', timeout=15.0)

                # Get ourselves into the boot loader and wait for indication
                # that it's ready for the firmware upload.
                stage_callback(Firmware.STAGE_LOAD)
                read_until('!load', timeout=15.0)

            except TimeoutError as err:
                retry -= 1
            else:
                retry = 0
                found_loader = True

        # And finally do the upload.
        if found_loader:
            try:
                do_upload()
            except UploadError as err:
                stage_callback(Firmware.STAGE_ERROR, error=str(err))
            else:
                stage_callback(Firmware.STAGE_DONE)
        else:
            stage_callback(Firmware.STAGE_ERROR, error="Error entering bootloader.")

"""
Provides zone tracking functionality for the `AlarmDecoder`_ (AD2) device family.

"""

import re

from .messages import ExpanderMessage
from .panels import ADEMCO, DSC

class Zone(object):
    """
    Representation of a panel zone.
    """

    CLEAR = 0
    """Status indicating that the zone is cleared."""
    FAULT = 1
    """Status indicating that the zone is faulted."""
    CHECK = 2   # Wire fault
    """Status indicating that there is a wiring issue with the zone."""

    STATUS = {CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK'}

    zone = 0
    """Zone ID"""
    name = ''
    """Zone name"""
    status = CLEAR
    """Zone status"""
    timestamp = None
    """Timestamp of last update"""
    expander = False
    """Does this zone exist on an expander?"""

    def __init__(self, zone=0, name='', status=CLEAR, expander=False):
        """

        :param zone: zone number
        :param name: Human readable zone name
        :type name: string
        :param status: Initial zone state
        :type status: int
        """
        self.zone = zone
        self.name = name
        self.status = status
        self.timestamp = time.time()
        self.expander = expander

    def __str__(self):
        """
        String conversion operator.
        """
        return 'Zone {0} {1}'.format(self.zone, self.name)

    def __repr__(self):
        """
        Human readable representation operator.
        """
        return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)

class Zonetracker(object):
    """
    Handles tracking of zones and their statuses.
    """

    on_fault = event.Event("This event is called when the device detects a zone fault.\n\n**Callback definition:** *def callback(device, zone)*")
    on_restore = event.Event("This event is called when the device detects that a fault is restored.\n\n**Callback definition:** *def callback(device, zone)*")

    EXPIRE = 30
    """Zone expiration timeout."""

    @property
    def zones(self):
        """
        Returns the current list of zones being tracked.

        :returns: dictionary of :py:class:`Zone` being tracked
        """
        return self._zones

    @zones.setter
    def zones(self, value):
        """
        Sets the current list of zones being tracked.

        :param value: new list of zones being tracked
        :type value: dictionary of :py:class:`Zone` being tracked
        """
        self._zones = value

    @property
    def faulted(self):
        """
        Retrieves the current list of faulted zones.

        :returns: list of faulted zones
        """
        return self._zones_faulted

    @faulted.setter
    def faulted(self, value):
        """
        Sets the current list of faulted zones.

        :param value: new list of faulted zones
        :type value: list of integers
        """
        self._zones_faulted = value

    def __init__(self, alarmdecoder_object):
        """
        """
        self._zones = {}
        self._zones_faulted = []
        self._last_zone_fault = 0

        self.alarmdecoder_object = alarmdecoder_object

    def update(self, message):
        """
        Update zone statuses based on the current message.

        :param message: message to use to update the zone tracking
        :type message: :py:class:`~alarmdecoder.messages.Message` or :py:class:`~alarmdecoder.messages.ExpanderMessage`
        """
        if isinstance(message, ExpanderMessage):
            zone = -1

            if message.type == ExpanderMessage.ZONE:
                zone = self.expander_to_zone(message.address, message.channel, self.alarmdecoder_object.mode)

            if zone != -1:
                status = Zone.CLEAR
                if message.value == 1:
                    status = Zone.FAULT
                elif message.value == 2:
                    status = Zone.CHECK

                # NOTE: Expander zone faults are handled differently than
                #       regular messages.  We don't include them in
                #       self._zones_faulted because they are not reported
                #       by the panel in it's rolling list of faults.
                try:
                    self._update_zone(zone, status=status)

                except IndexError:
                    self._add_zone(zone, status=status, expander=True)

        else:
            # Panel is ready, restore all zones.
            #
            # NOTE: This will need to be updated to support panels with
            #       multiple partitions.  In it's current state a ready on
            #       partition #1 will end up clearing all zones, even if they
            #       exist elsewhere and it shouldn't.
            #
            # NOTE: SYSTEM messages provide inconsistent ready statuses.  This
            #       may need to be extended later for other panels.
            if message.ready and not message.text.startswith("SYSTEM"):
                for zone in self._zones_faulted:
                    self._update_zone(zone, Zone.CLEAR)

                self._last_zone_fault = 0

            # Process fault
            elif message.check_zone or message.text.startswith("FAULT") or message.text.startswith("ALARM"):
                # Apparently this representation can be both base 10
                # or base 16, depending on where the message came
                # from.
                try:
                    zone = int(message.numeric_code)
                except ValueError:
                    zone = int(message.numeric_code, 16)

                # NOTE: Odd case for ECP failures.  Apparently they report as
                #       zone 191 (0xBF) regardless of whether or not the
                #       3-digit mode is enabled... so we have to pull it out
                #       of the alpha message.
                if zone == 191:
                    zone_regex = re.compile('^CHECK (\d+).*$')

                    match = zone_regex.match(message.text)
                    if match is None:
                        return

                    zone = match.group(1)

                # Add new zones and clear expired ones.
                if zone in self._zones_faulted:
                    self._update_zone(zone)
                    self._clear_zones(zone)

                else:
                    if message.check_zone:
                        status = Zone.CHECK

                    self._add_zone(zone, status=status)
                    self._zones_faulted.append(zone)
                    self._zones_faulted.sort()

                # Save our spot for the next message.
                self._last_zone_fault = zone

            self._clear_expired_zones()

    def expander_to_zone(self, address, channel, panel_type=ADEMCO):
        """
        Convert an address and channel into a zone number.

        :param address: expander address
        :type address: int
        :param channel: channel
        :type channel: int

        :returns: zone number associated with an address and channel
        """

        zone = -1

        if panel_type == ADEMCO:
            # TODO: This is going to need to be reworked to support the larger
            #       panels without fixed addressing on the expanders.

            idx = address - 7   # Expanders start at address 7.
            zone = address + channel + (idx * 7) + 1

        elif panel_type == DSC:
            zone = (address * 8) + channel

        return zone

    def _clear_zones(self, zone):
        """
        Clear all expired zones from our status list.

        :param zone: current zone being processed
        """
        cleared_zones = []
        found_last_faulted = found_current = at_end = False

        # First pass: Find our start spot.
        it = iter(self._zones_faulted)
        try:
            while not found_last_faulted:
                z = next(it)

                if z == self._last_zone_fault:
                    found_last_faulted = True
                    break

        except StopIteration:
            at_end = True

        # Continue until we find our end point and add zones in
        # between to our clear list.
        try:
            while not at_end and not found_current:

                if z == zone:
                    found_current = True
                    break
                else:
                    cleared_zones += [z]

            pass

        # Second pass: roll through the list again if we didn't find
        # our end point and remove everything until we do.
        if not found_current:
            it = iter(self._zones_faulted)

            try:
                while not found_current:
                    z = next(it)

                    if z == zone:
                        found_current = True
                        break
                    else:
                        cleared_zones += [z]

            except StopIteration:
                pass

        # Actually remove the zones and trigger the restores.
        for z in cleared_zones:
            self._update_zone(z, Zone.CLEAR)

    def _clear_expired_zones(self):
        """
        Update zone status for all expired zones.
        """
        zones = []

        for z in list(self._zones.keys()):
            zones += [z]

        for z in zones:
            if self._zones[z].status != Zone.CLEAR and self._zone_expired(z):
                self._update_zone(z, Zone.CLEAR)

    def _add_zone(self, zone, name='', status=Zone.CLEAR, expander=False):
        """
        Adds a zone to the internal zone list.

        :param name: human readable zone name
        :param status: zone status
        """
        if not zone in self._zones:
            self._zones[zone] = Zone(zone=zone, name=name, status=None, expander=expander)

        self._update_zone(zone, status=status)

    def _update_zone(self, zone, status=None):
        """
        Updates a zones status.

        :raises: IndexError
        """
            raise IndexError('Zone does not exist and cannot be updated: %d', zone)

        old_status = self._zones[zone].status
        if status is None:
            status = old_status

        self._zones[zone].status = status
        self._zones[zone].timestamp = time.time()

        if status == Zone.CLEAR:
            if zone in self._zones_faulted:
                self._zones_faulted.remove(zone)

            self.on_restore(zone=zone)
        else:
            if old_status != status and status is not None:
                self.on_fault(zone=zone)

    def _zone_expired(self, zone):
        """
        Determine if a zone is expired or not.

        :returns: whether or not the zone is expired
        """
        return (time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE) and self._zones[zone].expander is False

"""
Message representations received from the panel through the `AlarmDecoder`_ (AD2)
devices.

* :py:class:`Message`: The standard and most common message received from a panel.
* :py:class:`ExpanderMessage`: Messages received from Relay or Zone expander modules.
* :py:class:`RFMessage`: Message received from an RF receiver module.
* :py:class:`LRRMessage`: Message received from a long-range radio module.

"""

import re
import datetime

from reprlib import repr

class BaseMessage(object):
    """
    Base class for messages.
    """

    raw = None
    """The raw message text"""

    """The timestamp of the message"""

    def __init__(self):
        """
        """
        self.timestamp = datetime.datetime.now()

        """
        """
        return self.raw

    def dict(self, **kwargs):
        """
        Dictionary representation.
        """
        return dict(
            time=self.timestamp,
            mesg=self.raw,
            **kwargs
        )

        """
        String representation.
        """
        return repr(self.dict())

class Message(BaseMessage):
    """
    Represents a message from the alarm panel.
    """

    ready = False
    """Indicates whether or not the panel is in a ready state."""
    armed_away = False
    """Indicates whether or not the panel is armed away."""
    armed_home = False
    """Indicates whether or not the panel is armed home."""
    backlight_on = False
    """Indicates whether or not the keypad backlight is on."""
    programming_mode = False
    """Indicates whether or not we're in programming mode."""
    beeps = -1
    """Number of beeps associated with a message."""
    zone_bypassed = False
    """Indicates whether or not a zone is bypassed."""
    ac_power = False
    """Indicates whether or not the panel is on AC power."""
    chime_on = False
    """Indicates whether or not the chime is enabled."""
    alarm_event_occurred = False
    """Indicates whether or not an alarm event has occurred."""
    alarm_sounding = False
    """Indicates whether or not an alarm is sounding."""
    battery_low = False
    """Indicates whether or not there is a low battery."""
    entry_delay_off = False
    """Indicates whether or not the entry delay is enabled."""
    fire_alarm = False
    """Indicates whether or not a fire alarm is sounding."""
    check_zone = False
    """Indicates whether or not there are zones that require attention."""
    perimeter_only = False
    """Indicates whether or not the perimeter is armed."""
    system_fault = False
    """Indicates whether a system fault has occurred."""
    panel_type = ADEMCO
    """Indicates which panel type was the source of this message."""
    numeric_code = None
    """The numeric code associated with the message."""
    text = None
    """The human-readable text to be displayed on the panel LCD."""
    cursor_location = -1
    """Current cursor location on the keypad."""
    mask = 0xFFFFFFFF
    """Address mask this message is intended for."""
    bitfield = None
    """The bitfield associated with this message."""
    panel_data = None
    """The panel data field associated with this message."""

    def __init__(self, data=None):
        """

        :param data: message data to parse
        """
        BaseMessage.__init__(self)

        self._regex = re.compile('^(!KPM:){0,1}(\[[a-fA-F0-9\-]+\]),([a-fA-F0-9]+),(\[[a-fA-F0-9]+\]),(".+")$')

            self._parse_message(data)

    def _parse_message(self, data):
        """
        Parse the message from the device.

        :param data: message data

        :raises: :py:class:`~alarmdecoder.util.InvalidMessageError`
        """
        match = self._regex.match(str(data))

        if match is None:
            raise InvalidMessageError('Received invalid message: {0}'.format(data))

        header, self.bitfield, self.numeric_code, self.panel_data, alpha = match.group(1, 2, 3, 4, 5)

        is_bit_set = lambda bit: not self.bitfield[bit] == "0"

        self.raw = data
        self.ready = is_bit_set(1)
        self.armed_away = is_bit_set(2)
        self.armed_home = is_bit_set(3)
        self.backlight_on = is_bit_set(4)
        self.programming_mode = is_bit_set(5)
        self.beeps = int(self.bitfield[6], 16)
        self.zone_bypassed = is_bit_set(7)
        self.ac_power = is_bit_set(8)
        self.chime_on = is_bit_set(9)
        self.alarm_event_occurred = is_bit_set(10)
        self.alarm_sounding = is_bit_set(11)
        self.battery_low = is_bit_set(12)
        self.entry_delay_off = is_bit_set(13)
        self.fire_alarm = is_bit_set(14)
        self.check_zone = is_bit_set(15)
        self.perimeter_only = is_bit_set(16)
        self.system_fault = is_bit_set(17)
        if self.bitfield[18] in list(PANEL_TYPES):
            self.panel_type = PANEL_TYPES[self.bitfield[18]]
        # pos 20-21 - Unused.
        self.text = alpha.strip('"')
        self.mask = int(self.panel_data[3:3+8], 16)

        if self.panel_type in (ADEMCO, DSC):
            if int(self.panel_data[19:21], 16) & 0x01 > 0:
                # Current cursor location on the alpha display.
                self.cursor_location = int(self.panel_data[21:23], 16)

        """
        """
            time                  = self.timestamp,
            bitfield              = self.bitfield,
            numeric_code          = self.numeric_code,
            panel_data            = self.panel_data,
            mask                  = self.mask,
            ready                 = self.ready,
            armed_away            = self.armed_away,
            armed_home            = self.armed_home,
            backlight_on          = self.backlight_on,
            programming_mode      = self.programming_mode,
            beeps                 = self.beeps,
            zone_bypassed         = self.zone_bypassed,
            ac_power              = self.ac_power,
            chime_on              = self.chime_on,
            alarm_event_occurred  = self.alarm_event_occurred,
            alarm_sounding        = self.alarm_sounding,
            battery_low           = self.battery_low,
            entry_delay_off       = self.entry_delay_off,
            fire_alarm            = self.fire_alarm,
            check_zone            = self.check_zone,
            perimeter_only        = self.perimeter_only,
            text                  = self.text,
            cursor_location       = self.cursor_location,
            **kwargs
        )

class ExpanderMessage(BaseMessage):
    """
    Represents a message from a zone or relay expansion module.
    """

    ZONE = 0
    """Flag indicating that the expander message relates to a Zone Expander."""
    RELAY = 1
    """Flag indicating that the expander message relates to a Relay Expander."""

    type = None
    """Expander message type: ExpanderMessage.ZONE or ExpanderMessage.RELAY"""
    address = -1
    """Address of expander"""
    channel = -1
    """Channel on the expander"""
    value = -1
    """Value associated with the message"""

        """

        """

        """
        Parse the raw message from the device.

        """
        try:
            header, values = data.split(':')
            address, channel, value = values.split(',')

            self.raw = data
            self.address = int(address)
            self.channel = int(channel)
            self.value = int(value)

        except ValueError:

        if header == '!EXP':
            self.type = ExpanderMessage.ZONE
        elif header == '!REL':
            self.type = ExpanderMessage.RELAY
        else:
            raise InvalidMessageError('Unknown expander message header: {0}'.format(data))

        """
        """
            address               = self.address,
            channel               = self.channel,
            value                 = self.value,
            **kwargs
        )

class RFMessage(BaseMessage):
    """
    Represents a message from an RF receiver.
    """

    serial_number = None
    """Serial number of the RF device."""
    value = -1
    """Value associated with this message."""
    battery = False
    """Low battery indication"""
    supervision = False
    """Supervision required indication"""
    loop = [False for _ in list(range(4))]
    """Loop indicators"""

        """

        """

        """
        Parses the raw message from the device.

        """
        try:

            _, values = data.split(':')
            self.serial_number, self.value = values.split(',')
            self.value = int(self.value, 16)

            is_bit_set = lambda b: self.value & (1 << (b - 1)) > 0

            # Bit 1 = unknown
            self.battery = is_bit_set(2)
            self.supervision = is_bit_set(3)
            # Bit 4 = unknown
            self.loop[2] = is_bit_set(5)
            self.loop[1] = is_bit_set(6)
            self.loop[3] = is_bit_set(7)
            self.loop[0] = is_bit_set(8)

        """
        """
            serial_number         = self.serial_number,
            battery               = self.battery,
            supervision           = self.supervision,
            **kwargs
        )

class LRRMessage(BaseMessage):
    """
    Represent a message from a Long Range Radio.
    """

    event_data = None
    """Data associated with the LRR message.  Usually user ID or zone."""
    partition = -1
    """The partition that this message applies to."""
    event_type = None
    """The type of the event that occurred."""

        """

        """

        """

        """
        try:

            self.event_data, self.partition, self.event_type = values.split(',')

        """
        """
            event_data            = self.event_data,
            event_type            = self.event_type,
            partition             = self.partition,
            **kwargs
        )

"""
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
.. _AD2USB: http://www.alarmdecoder.com
.. _AD2SERIAL: http://www.alarmdecoder.com
.. _AD2PI: http://www.alarmdecoder.com

"""

import serial
import serial.tools.list_ports
import socket
import select
from builtins import bytes

from .util import CommError, TimeoutError, NoDeviceError, InvalidMessageError

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

    class SSL:
        class Error(BaseException):
            pass

        class WantReadError(BaseException):
            pass

        class SysCallError(BaseException):
            pass

    have_openssl = False

class Device(object):
    """
    Base class for all `AlarmDecoder`_ (AD2) device types.
    """

    # Generic device events
    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)*")

        """
        """
        self._id = ''
        self._buffer = b''
        self._running = False
        self._read_thread = None

        """
        """

        """
        """

    @property
        """
        Retrieve the device ID.

        """
        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()

        """
        """
        try:
            self._running = False
            self._read_thread.stop()

        except Exception:
            pass

    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

        def stop(self):
            """
            Stops the running thread.
            """

        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()

                    self._running = False
                    raise

class USBDevice(Device):
    """
    `AD2USB`_ device utilizing PyFTDI's interface.
    """

    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

    def devices(cls):
        """
        Returns a cached list of `AD2USB`_ devices located on the system.

        :returns: cached list of devices found
        """

    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`
        """

        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)

    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
        """

        cls.__detect_thread = USBDevice.DetectThread(on_attached, on_detached)

        try:
            cls.find_all()
        except CommError:
            pass

        cls.__detect_thread.start()

    def stop_detection(cls):
        """
        Stops the device detection thread.
        """

        try:
            cls.__detect_thread.stop()

            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
        """
        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
        """
        self._description = value

    def __init__(self, interface=0, vid=None, pid=None):
        """

        :param interface: May specify either the serial number or the device
                          index.
        :type interface: string or int
        """

        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):
        """

        :param baudrate: baudrate to use
        :param no_reader_thread: whether or not to automatically start the
                                 reader thread.

        """
        # 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

            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.on_open()

            if not no_reader_thread:
                self._read_thread.start()

        """
        """
        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)

            pass

    def fileno(self):
        raise NotImplementedError('USB devices do not support fileno()')

    def write(self, data):
        """
        Writes data to the device.

        :param data: data to write

        """
        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
        """
        ret = None

        try:
            ret = self._device.read_data(1)

            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:
                buf = self._device.read_data(1)

                if buf != b'':
                    self._buffer += buf

                    if buf == b"\n":
                        self._buffer = self._buffer.rstrip(b"\r\n")

                        if len(self._buffer) > 0:
                            got_line = True
                            break
                else:
                    time.sleep(0.01)

        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):
            """

            :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
            """

            if on_attached:
                self.on_attached += on_attached

            if on_detached:
                self.on_detached += on_detached

            """
            Stops the thread.
            """

            """
            The actual detection process.
            """

            last_devices = set()

                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.
    """

    BAUDRATE = 19200
    """Default baudrate for Serial devices."""

    def find_all(pattern=None):
        """
        Returns all serial ports present.

        :param pattern: pattern to search for when retrieving serial ports
        :type pattern: string

        """
        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
        """

        :returns: interface used to connect to the device
        """
        return self._port

        """

        :param value: name of the serial device
        """
        self._port = value

    def __init__(self, interface=None):
        """

        :param interface: device to open
        :type interface: string
        """

        self._port = interface
        self._id = interface
        # Timeout = non-blocking to match pyftdi.
        self._device = serial.Serial(timeout=0, writeTimeout=0)

        """

        :param baudrate: baudrate to use with the device

        """
        # Set up the defaults
            baudrate = SerialDevice.BAUDRATE

        if self._port is None:
            raise NoDeviceError('No device interface specified.')

        # 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:

        """
        """
        try:

            pass

        return self._device.fileno()

        """

        :raises: py:class:`~alarmdecoder.util.CommError`
        """
        try:
            self._device.write(data)

        except serial.SerialTimeoutException:
            pass

            raise CommError('Error writing to device.', err)

        else:

        """

        """
        ret = None

        try:
            ret = self._device.read(1)

        return ret

        """

                             reading.

        """

        try:
                buf = self._device.read(1)

                # NOTE: AD2SERIAL apparently sends down \xFF on boot.
                if buf != b'' and buf != b"\xff":

                            break
                else:

        except (OSError, serial.SerialException) as err:

        else:

            else:

        finally:

        return ret

        """
        """
        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
        """

        """
        return (self._host, self._port)

        """

        :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`
        """
        self._ssl_ca = value

    def __init__(self, interface=("localhost", 10000)):
        """

        :param interface: Tuple containing the hostname and port of our target
        :type interface: tuple
        """

        self._host, self._port = interface
        self._use_ssl = False
        self._ssl_certificate = None
        self._ssl_key = None
        self._ssl_ca = None

        """

        :param no_reader_thread: whether or not to automatically open the reader
                                 thread.

        :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))

                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:

        """
        """
        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)

            pass

        Device.close(self)

        """

        :returns: number of bytes sent
        """
        data_sent = None

        try:
            data_sent = self._device.send(data)

            if data_sent == 0:
                raise CommError('Error writing to device.')

        except (SSL.Error, socket.error) as err:

        return data_sent

        """

        """
        data = None

        try:
            read_ready, _, _ = select.select([self._device], [], [], 0)

            if (len(read_ready) != 0):
                data = self._device.recv(1)

            raise CommError('Error while reading from device: {0}'.format(str(err)), err)

        return data

        """

                             reading.

        """

        try:
                read_ready, _, _ = select.select([self._device], [], [], 0)

                if (len(read_ready) == 0):
                    continue

                buf = self._device.recv(1)

                            break

                else:

        except SSL.SysCallError as err:
            errno, msg = err
            raise CommError('SSL error while reading from device: {0} ({1})'.format(msg, errno))

            raise

        else:

            else:

        finally:

        return ret

        """
        """
        try:
            self._device.setblocking(0)
            while(self._device.recv(1)):
                pass
            pass
        finally:
            self._device.setblocking(1)

    def _init_ssl(self):
        """
        Initializes our device as an SSL connection.

        """

        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

on_config_received (alarmdecoder.decoder.AlarmDecoder attribute)

on_expander_message (alarmdecoder.decoder.AlarmDecoder attribute)

on_sending_received (alarmdecoder.decoder.AlarmDecoder attribute)

internal_address_mask (alarmdecoder.decoder.AlarmDecoder attribute)

on_attached (alarmdecoder.devices.USBDevice.DetectThread attribute)

on_detached (alarmdecoder.devices.USBDevice.DetectThread attribute)

expander_to_zone() (alarmdecoder.zonetracking.Zonetracker method)