"""
Message representations received from the panel through the AD2 devices.

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

import re

from .util import InvalidMessageError

class BaseMessage(object):
    """
    Base class for messages.
    """
    def __init__(self):
        """
        Constructor
        """
        self.raw = None

    def __str__(self):
        """
        String conversion operator.
        """
        return self.raw

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

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

        :param data: Message data to parse.
        :type data: str
        """
        self.ready = False
        self.armed_away = False
        self.armed_home = False
        self.backlight_on = False
        self.programming_mode = False
        self.beeps = -1
        self.zone_bypassed = False
        self.ac_power = False
        self.chime_on = False
        self.alarm_event_occurred = False
        self.alarm_sounding = False
        self.battery_low = False
        self.entry_delay_off = False
        self.fire_alarm = False
        self.check_zone = False
        self.perimeter_only = False
        self.numeric_code = ""
        self.text = ""
        self.cursor_location = -1
        self.data = ""
        self.mask = ""
        self.bitfield = ""
        self.panel_data = ""

        self._regex = re.compile('("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*)')

        if data is not None:
            self._parse_message(data)

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

        :param data: The message data.

        :raises: InvalidMessageError
        """
        m = self._regex.match(data)

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

        self.bitfield, self.numeric_code, self.panel_data, alpha = m.group(1, 2, 3, 4)
        self.mask = int(self.panel_data[3:3+8], 16)

        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)
        # bits 17-20 unused.
        self.text = alpha.strip('"')

        if int(self.panel_data[19:21], 16) & 0x01 > 0:
            self.cursor_location = int(self.bitfield[21:23], 16)    # Alpha character index that the cursor is on.

        """
        """

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

        """

        :param data: The message data to parse.
        """
        self.type = None
        self.address = None
        self.channel = None
        self.value = None

        """
        """

        """
        Parse the raw message from the device.

        :param data: The message data
        """
        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))

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

        """

        :param data: The message data to parse
        """
        self.serial_number = None
        self.battery = None
        self.supervision = None
        self.loop = {}

        """
        """

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

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

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

        """

        """
        self.event_data = None
        self.partition = None
        self.event_type = None

        """
        """

        """

        """
        try:

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

"""
Provides utility classes for the AD2 devices.

"""

import ad2
import time
import threading

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 Firmware(object):
    """
    Represents firmware for the AD2 devices.
    """

    # Constants
    STAGE_START = 0
    STAGE_WAITING = 1
    STAGE_BOOT = 2
    STAGE_LOAD = 3
    STAGE_UPLOADING = 4
    STAGE_DONE = 5

    @staticmethod
    def upload(dev, filename, progress_callback=None):
        """
        Uploads firmware to an AD2 device.

        :param filename: The firmware filename
        :type filename: str
        :param progress_callback: Callback function used to report progress.
        :type progress_callback: function

        :raises: util.NoDeviceError, util.TimeoutError
        """

        def do_upload():
            """
            Perform the actual firmware upload to the device.
            """
            with open(filename) as f:
                for line in f:
                    line = line.rstrip()

                    if line[0] == ':':
                        dev.write(line + "\r")
                        res = dev.read_line(timeout=10.0)

                        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():
                timeout_event.reading = False

            timeout_event.reading = True

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

            buf = ''
            position = 0

            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, err:
                    pass

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

        def stage_callback(stage):
            if progress_callback is not None:
                progress_callback(stage)

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

        time.sleep(2)

        # Reboot the device and wait for the boot loader.
        stage_callback(Firmware.STAGE_BOOT)
        dev.write("=")
        read_until('......', 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)

        # And finally do the upload.
        do_upload()
        stage_callback(Firmware.STAGE_DONE)

"""
Provides zone tracking functionality for the AD2 device family.

"""

import re

from .event import event
from .messages import ExpanderMessage

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' }

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

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

        """
        """
        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 zone and their statuses.
    """

    on_fault = event.Event('Called when the device detects a zone fault.')
    on_restore = event.Event('Called when the device detects that a fault is restored.')

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

        """
        """
        self._zones = {}
        self._zones_faulted = []
        self._last_zone_fault = 0

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

        :param message: Message to use to update the zone tracking.
        :type message: Message or ExpanderMessage
        """
        if isinstance(message, ExpanderMessage):
            if message.type == ExpanderMessage.ZONE:
                zone = self._expander_to_zone(message.address, message.channel)

                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)

        else:
            # Panel is ready, restore all zones.
            if message.ready:
                for z in self._zones_faulted:
                    self._update_zone(z, Zone.CLEAR)

                self._last_zone_fault = 0

            # Process fault
            elif "FAULT" in message.text or message.check_zone:
                # 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+).*$')

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

                    zone = m.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._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 _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 = it.next()

                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 = it.next()

                    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 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):
        """
        Adds a zone to the internal zone list.

        :param status: The zone status.
        """
        if not zone in self._zones:
            self._zones[zone] = Zone(zone=zone, name=name, status=status)

        if status != Zone.CLEAR:
            self.on_fault(zone)

    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)

        if status is not None:
            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)

    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

    def _expander_to_zone(self, address, channel):
        """
        Convert an address and channel into a zone number.

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

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

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

        return address + channel + (idx * 7) + 1

"""
Contains different types of devices belonging to the AD2 family.

"""

import usb.core, usb.util
import serial, serial.tools.list_ports
import socket

from OpenSSL import SSL, crypto
from pyftdi.pyftdi.ftdi import *
from pyftdi.pyftdi.usbtools import *
from .util import CommError, TimeoutError, NoDeviceError

class Device(object):
    """
    Generic parent device to all AD2 products.
    """

    # Generic device events
    on_open = event.Event('Called when the device has been opened')
    on_close = event.Event('Called when the device has been closed')
    on_read = event.Event('Called when a line has been read from the device')
    on_write = event.Event('Called when data has been written to the device')

        """
        """
        self._id = ''
        self._buffer = ''
        self._device = None
        self._running = False
        self._read_thread = Device.ReadThread(self)

    @property
    def id(self):
        """
        Retrieve the device ID.

        :returns: The identification string for the device.
        """
        return self._id

    @id.setter
    def id(self, value):
        """
        Sets the device ID.

        :param value: The device identification.
        :type value: str
        """
        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:
            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: The device used by the reader thread.
            :type device: 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, err:
                    pass

                    self._running = False

                    #raise err

                time.sleep(0.01)

class USBDevice(Device):
    """
    AD2USB device exposed with PyFTDI's interface.
    """

    FTDI_VENDOR_ID = 0x0403
    """Vendor ID used to recognize AD2USB devices."""
    FTDI_PRODUCT_ID = 0x6001
    """Product ID used to recognize AD2USB devices."""
    BAUDRATE = 115200
    """Default baudrate for AD2USB devices."""

    def find_all():
        """
        Returns all FTDI devices matching our vendor and product IDs.

        :returns: list of devices
        :raises: CommError
        """
        devices = []

        try:
            devices = Ftdi.find_all([(USBDevice.FTDI_VENDOR_ID, USBDevice.FTDI_PRODUCT_ID)], nocache=True)

        except (usb.core.USBError, FtdiError), err:
            raise CommError('Error enumerating AD2USB devices: {0}'.format(str(err)), err)

        return devices

    @property
    def interface(self):
        """
        Retrieves the interface used to connect to the device.

        :returns: the interface used to connect to the device.
        """
        return (self._serial_number, self._endpoint)

    @interface.setter
    def interface(self, value):
        """
        Sets the interface used to connect to the device.

        :param value: Tuple containing the serial number and endpoint number to use.
        :type value: tuple
        """
        self._serial_number = value[0]
        self._endpoint = value[1]

    @property
    def serial_number(self):
        """
        Retrieves the serial number of the device.

        :returns: The 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: The serial number of the device.
        :type value: string
        """
        self._serial_number = value

    @property
    def description(self):
        """
        Retrieves the description of the device.

        :returns: The description of the device.
        """
        return self._description

    @description.setter
    def description(self, value):
        """
        Sets the description of the device.

        :param value: The description of the device.
        """
        self._description = value

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

        :param interface: Tuple containing the device number and endpoint number to use.
        :type interface: tuple
        """
        Device.__init__(self)

        self._device = Ftdi()
        self._serial_number = interface[0]
        self._endpoint = interface[1]
        self._vendor_id = USBDevice.FTDI_VENDOR_ID
        self._product_id = USBDevice.FTDI_PRODUCT_ID
        self._device_number = None
        self._description = None

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

        :param baudrate: The 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: NoDeviceError
        """
        # Set up defaults
        if baudrate is None:
            baudrate = USBDevice.BAUDRATE

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

            self._id = 'USB {0}:{1}'.format(self._device.usb_dev.bus, self._device.usb_dev.address)

            raise NoDeviceError('Error opening device: {0}'.format(str(err)), err)

        else:
            if not no_reader_thread:
                self._read_thread.start()

            self.on_open()

        """
        """
        try:
            # HACK: Probably should fork pyftdi and make this call in .close().
            self._device.usb_dev.attach_kernel_driver(self._device_number)

            Device.close(self)

        except:
            pass

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

        :param data: Data to write

        """
        try:
            self._device.write_data(data)

            self.on_write(data)

        except FtdiError, err:
            raise CommError('Error writing to device: {0}'.format(str(err)), err)

    def read(self):
        """
        Reads a single character from the device.

        :returns: The 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: The line that was read.
        :raises: CommError, TimeoutError
        """

        if purge_buffer:
            self._buffer = ''

        def timeout_event():
            timeout_event.reading = False

        timeout_event.reading = True

        got_line = False
        ret = None

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

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

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

                    if buf == "\n":
                        if len(self._buffer) > 1:
                            if self._buffer[-2] == "\r":
                                self._buffer = self._buffer[:-2]

                                # ignore if we just got \r\n with nothing else in the buffer.
                                if len(self._buffer) != 0:
                                    got_line = True
                                    break
                        else:
                            self._buffer = self._buffer[:-1]

            if timer:
                timer.cancel()

        else:
            if got_line:
                ret = self._buffer
                self._buffer = ''

                self.on_read(ret)

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

        return ret

class SerialDevice(Device):
    """
    AD2USB or AD2SERIAL device exposed with 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: str

        """

        try:
            if pattern:
                devices = serial.tools.list_ports.grep(pattern)
            else:
                devices = serial.tools.list_ports.comports()

        except SerialException, err:
            raise CommError('Error enumerating serial devices: {0}'.format(str(err)), err)

    @property
        """

        """
        return self._port

        """

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

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

        :param interface: The device to open.
        :type interface: str
        """

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

        """

        :param baudrate: The baudrate to use with the device.

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

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

        self._device.port = self._port

        # Open the device and start up the reader thread.
        try:
            self._device.open()
            self._device.baudrate = baudrate            # 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.

        except (serial.SerialException, ValueError), err:
            raise NoDeviceError('Error opening device on port {0}.'.format(self._port), err)

        else:

        """
        """
        try:

        except:
            pass

        """

        :param data: The data to write.

        """
        try:
            self._device.write(data)

        except serial.SerialTimeoutException, err:
            pass

        except serial.SerialException, err:
            raise CommError('Error writing to device.', err)

        else:

        """

        """
        ret = None

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

        return ret

        """

        :param timeout: The read timeout.

        :returns: The line read.
        """

        ret = None

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

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

                                    break
                        else:

        except (OSError, serial.SerialException), err:
            if timer:

        else:

        if timer:
            else:

        return ret

class SocketDevice(Device):
    """
    Device that supports communication with an AD2 that is exposed via ser2sock or another
    Serial to IP interface.
    """

    @property
        """

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

        """

        :param value: Tuple containing the device number and endpoint number to use.
        """
        self._host = value[0]
        self._port = value[1]

    @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: The certificate path
        """
        return self._ssl_certificate

    @ssl_certificate.setter
    def ssl_certificate(self, value):
        """
        Sets the SSL client certificate to use for authentication.

        :param value: The path to the SSL certificate.
        """
        self._ssl_certificate = value

    @property
    def ssl_key(self):
        """
        Retrieves the SSL client certificate key used for authentication.

        :returns: The key path
        """
        return self._ssl_key

    @ssl_key.setter
    def ssl_key(self, value):
        """
        Sets the SSL client certificate key to use for authentication.

        :param value: The path to the SSL key.
        """
        self._ssl_key = value

    @property
    def ssl_ca(self):
        """
        Retrieves the SSL Certificate Authority certificate used for authentication.

        :returns: The CA path
        """
        return self._ssl_ca

    @ssl_ca.setter
    def ssl_ca(self, value):
        """
        Sets the SSL Certificate Authority certificate used for authentication.

        :param value: The path to the SSL CA certificate.
        """
        self._ssl_ca = value

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

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

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

        :param baudrate: The baudrate to use
        :param no_reader_thread: Whether or not to automatically open the reader thread.

        :raises: NoDeviceError, CommError
        """

        try:
            self._device = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

            if self._use_ssl:
                self._init_ssl()

            self._device.connect((self._host, self._port))

            self._id = '{0}:{1}'.format(self._host, self._port)

        except socket.error, err:
            raise NoDeviceError('Error opening device at {0}:{1}'.format(self._host, self._port), err)

        else:

        """
        """
        try:
            if self.ssl:
                self._device.shutdown()
            else:
                self._device.shutdown(socket.SHUT_RDWR)     # Make sure that it closes immediately.

        except Exception, ex:
            pass

        """

        :returns: The 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), err:

        return data_sent

        """

        """
        data = None

        try:
            data = self._device.recv(1)

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

        return data

        """

        :returns: The line read from the device.
        """

        ret = None

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

                                    break
                        else:

            if timer:

        else:

        if timer:
            else:

        return ret

    def _init_ssl(self):
        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, err:
            raise CommError('Error setting up SSL connection.', err)

    def _verify_ssl_callback(self, connection, x509, errnum, errdepth, ok):
        return ok

"""
Provides the full AD2 class and factory.

"""

from .devices import USBDevice
from .util import CommError, NoDeviceError
from .messages import Message, ExpanderMessage, RFMessage, LRRMessage
from .zonetracking import Zonetracker

class AD2Factory(object):
    """
    Factory for creation of AD2USB devices as well as provides attach/detach events."
    """

    # Factory events
    on_attached = event.Event('Called when an AD2USB device has been detected.')
    on_detached = event.Event('Called when an AD2USB device has been removed.')

    __devices = []

    @classmethod
    def find_all(cls):
        """
        Returns all AD2USB devices located on the system.

        :returns: list of devices found
        """
        cls.__devices = USBDevice.find_all()

        return cls.__devices

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

        :returns: cached list of devices found.
        """

    def create(cls, device=None):
        """
        Factory method that returns the requested AD2USB device, or the first device.

        :param device: Tuple describing the USB device to open, as returned by find_all().
        :type device: tuple

        :returns: AD2USB object utilizing the specified device.
        """
        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
        device = USBDevice((sernum, ifcount - 1))

        return AD2(device)

    def __init__(self, attached_event=None, detached_event=None):
        """

        :param attached_event: Event to trigger when a device is attached.
        :type attached_event: function
        :param detached_event: Event to trigger when a device is detached.
        :type detached_event: function
        """
        self._detect_thread = AD2Factory.DetectThread(self)

        if attached_event:
            self.on_attached += attached_event

        if detached_event:
            self.on_detached += detached_event

        AD2Factory.find_all()

        self.start()

        """
        Clean up and shut down.
        """
        self.stop()

    def start(self):
        """
        Starts the detection thread, if not already running.
        """
        if not self._detect_thread.is_alive():
            self._detect_thread.start()

    def stop(self):
        """
        Stops the detection thread.
        """
        self._detect_thread.stop()

    def get_device(self, device=None):
        """

        """
        return AD2Factory.create(device)

    class DetectThread(threading.Thread):
        """
        Thread that handles detection of added/removed devices.
        """
        def __init__(self, factory):
            """

            :param factory: AD2Factory object to use with the thread.
            :type factory: AD2Factory
            """

            self._factory = factory

            """
            Stops the thread.
            """

            """
            The actual detection process.
            """

            last_devices = set()

                try:
                    AD2Factory.find_all()

                    current_devices = set(AD2Factory.devices())
                    new_devices = [d for d in current_devices if d not in last_devices]
                    removed_devices = [d for d in last_devices if d not in current_devices]
                    last_devices = current_devices

                    for d in new_devices:
                        self._factory.on_attached(d)

                    for d in removed_devices:
                        self._factory.on_detached(d)

                except CommError, err:
                    pass

                time.sleep(0.25)

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

    # High-level Events
    on_arm = event.Event('Called when the panel is armed.')
    on_disarm = event.Event('Called when the panel is disarmed.')
    on_power_changed = event.Event('Called when panel power switches between AC and DC.')
    on_alarm = event.Event('Called when the alarm is triggered.')
    on_fire = event.Event('Called when a fire is detected.')
    on_bypass = event.Event('Called when a zone is bypassed.')
    on_boot = event.Event('Called when the device finishes bootings.')
    on_config_received = event.Event('Called when the device receives its configuration.')
    on_zone_fault = event.Event('Called when the device detects a zone fault.')
    on_zone_restore = event.Event('Called when the device detects that a fault is restored.')
    on_low_battery = event.Event('Called when the device detects a low battery.')
    on_panic = event.Event('Called when the device detects a panic.')
    on_relay_changed = event.Event('Called when a relay is opened or closed on an expander board.')

    # Mid-level Events
    on_message = event.Event('Called when a message has been received from the device.')
    on_lrr_message = event.Event('Called when an LRR message is received.')
    on_rfx_message = event.Event('Called when an RFX message is received.')

    # Low-level Events
    on_open = event.Event('Called when the device has been opened.')
    on_close = event.Event('Called when the device has been closed.')
    on_read = event.Event('Called when a line has been read from the device.')
    on_write = event.Event('Called when data has been written to the device.')

    F1 = unichr(1) + unichr(1) + unichr(1)
    """Represents panel function key #1"""
    F2 = unichr(2) + unichr(2) + unichr(2)
    """Represents panel function key #2"""
    F3 = unichr(3) + unichr(3) + unichr(3)
    """Represents panel function key #3"""
    F4 = unichr(4) + unichr(4) + unichr(4)
    """Represents panel function key #4"""

    BATTERY_TIMEOUT = 30
    """Timeout before the battery status reverts."""
    FIRE_TIMEOUT = 30
    """Timeout before the fire status reverts."""

    def __init__(self, device):
        """

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

        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 = None
        self._relay_status = {}

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

    @property
        """
        The ID of the AD2 device.

        """
        return self._device.id

        """

        :param baudrate: The baudrate used for the device.
        :param interface: The interface used for the device.
        :type interface: varies depends on device type.. FIXME
        :param index: Interface index.. can probably remove.  FIXME
        :type index: int
        :param no_reader_thread: Specifies whether or not the automatic reader thread should be started or not
        """
        self._wire_events()
        self._device.open(baudrate=baudrate, no_reader_thread=no_reader_thread)

        """
        """
        if self._device:

        del self._device

    def send(self, data):

    def get_config(self):
        """
        Retrieves the configuration from the device.
        """
        self.send("C\r")

    def save_config(self):
        """
        Sets configuration entries on the device.
        """
        config_string = ''

        # HACK: Both of these methods are ugly.. but I can't think of an elegant way of doing it.

        #config_string += 'ADDRESS={0}&'.format(self.address)
        #config_string += 'CONFIGBITS={0:x}&'.format(self.configbits)
        #config_string += 'MASK={0:x}&'.format(self.address_mask)
        #config_string += 'EXP={0}&'.format(''.join(['Y' if z else 'N' for z in self.emulate_zone]))
        #config_string += 'REL={0}&'.format(''.join(['Y' if r else 'N' for r in self.emulate_relay]))
        #config_string += 'LRR={0}&'.format('Y' if self.emulate_lrr else 'N')
        #config_string += 'DEDUPLICATE={0}'.format('Y' if self.deduplicate else 'N')

        config_entries = []
        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_string = '&'.join(['='.join(t) for t in config_entries])

        self.send("C{0}\r".format(config_string))

    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: The zone to fault.
        :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):
            zone = self._zonetracker._expander_to_zone(*zone)

        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: The 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 messages from the panel.

        :param data: Panel data to parse.

        :returns: An object representing the message.
        """
        if data is None:
            raise InvalidMessageError()

        msg = None

        header = data[0:4]
        if header[0] != '!' or header == '!KPE':
            msg = Message(data)

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

        elif header == '!EXP' or header == '!REL':
            msg = ExpanderMessage(data)

            self._update_internal_states(msg)

        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)

        return msg

    def _handle_rfx(self, data):
        msg = RFMessage(data)

        self.on_rfx_message(msg)

        return msg

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

        :param data: LRR message to parse.

        :returns: An object representing the LRR message.
        """
        msg = LRRMessage(data)

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

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

        self.on_lrr_message(msg)

        return msg

    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('&'):
            k, v = setting.split('=')

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

        self.on_config_received()

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

        :param message: Message to update internal states with.
        :type message: Message, ExpanderMessage, LRRMessage, or RFMessage
        """
        if isinstance(message, Message):
            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(self._power_status)

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

                    self.on_alarm(self._alarm_status)

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

                    self.on_bypass(self._bypass_status)

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

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

            if message.battery_low == self._battery_status[0]:
                self._battery_status = (self._battery_status[0], time.time())
            else:
                if message.battery_low == True or time.time() > self._battery_status[1] + AD2.BATTERY_TIMEOUT:
                    self._battery_status = (message.battery_low, time.time())
                    self.on_low_battery(self._battery_status)

            if message.fire_alarm == self._fire_status[0]:
                self._fire_status = (self._fire_status[0], time.time())
            else:
                if message.fire_alarm == True or time.time() > self._fire_status[1] + AD2.FIRE_TIMEOUT:
                    self._fire_status = (message.fire_alarm, time.time())
                    self.on_fire(self._fire_status)

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

                self.on_relay_changed(message)

        self._update_zone_tracker(message)

    def _update_zone_tracker(self, message):
        """
        Trigger an update of the zonetracker.

        :param message: The 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):
        """
        Internal handler for opening the device.
        """
        self.on_open(args)

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

    def _on_read(self, sender, args):
        """
        Internal handler for reading from the device.
        """
        self.on_read(args)

        msg = self._handle_message(args)
        if msg:
            self.on_message(msg)

    def _on_write(self, sender, args):
        """
        Internal handler for writing to the device.
        """
        self.on_write(args)

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

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

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

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 _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)
        return self

    def remove(self, func):

        """Remove existing event handler function.

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

        self._getfunctionlist().remove(func)

    def fire(self, earg=None):

        """Fire event and call all handler functions

        You can call EventHandler object itself like e(earg) instead of
        e.fire(earg).
        """

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

    __iadd__ = add
    __isub__ = remove
    __call__ = fire