From cb6f50b7ffe80bcfbeda2efdd45cebaa4a22c883 Mon Sep 17 00:00:00 2001
From: Dan Cinnamon <Cinntax@users.noreply.github.com>
Date: Sun, 19 Jun 2016 12:45:07 -0500
Subject: [PATCH] Envisalink support (#2304)

* Created a new platform for envisalink-based alarm panels (Honeywell/DSC)

* Added a sensor component and cleanup

* Completed initial development.

* Fixing pylint issues.

* Fix more pylint issues

* Fixed more validation issues.

* Final pylint issues

* Final tweaks prior to PR.

* Fixed final pylint issue

* Resolved a few minor issues, and used volumptous for validation.

* Fixing final lint issues

* Fixes to validation schema and refactoring.
---
 .coveragerc                                   |   3 +
 .../alarm_control_panel/envisalink.py         | 105 +++++++++
 .../components/binary_sensor/envisalink.py    |  71 ++++++
 homeassistant/components/envisalink.py        | 210 ++++++++++++++++++
 homeassistant/components/sensor/envisalink.py |  68 ++++++
 requirements_all.txt                          |   4 +
 6 files changed, 461 insertions(+)
 create mode 100644 homeassistant/components/alarm_control_panel/envisalink.py
 create mode 100644 homeassistant/components/binary_sensor/envisalink.py
 create mode 100644 homeassistant/components/envisalink.py
 create mode 100644 homeassistant/components/sensor/envisalink.py

diff --git a/.coveragerc b/.coveragerc
index e8666da60f4..265c653d636 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -20,6 +20,9 @@ omit =
     homeassistant/components/ecobee.py
     homeassistant/components/*/ecobee.py
 
+    homeassistant/components/envisalink.py
+    homeassistant/components/*/envisalink.py
+
     homeassistant/components/insteon_hub.py
     homeassistant/components/*/insteon_hub.py
 
diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py
new file mode 100644
index 00000000000..ebd54da1558
--- /dev/null
+++ b/homeassistant/components/alarm_control_panel/envisalink.py
@@ -0,0 +1,105 @@
+"""
+Support for Envisalink-based alarm control panels (Honeywell/DSC).
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/alarm_control_panel.envisalink/
+"""
+import logging
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.components.envisalink import (EVL_CONTROLLER,
+                                                 EnvisalinkDevice,
+                                                 PARTITION_SCHEMA,
+                                                 CONF_CODE,
+                                                 CONF_PARTITIONNAME,
+                                                 SIGNAL_PARTITION_UPDATE,
+                                                 SIGNAL_KEYPAD_UPDATE)
+from homeassistant.const import (
+    STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
+    STATE_UNKNOWN, STATE_ALARM_TRIGGERED)
+
+DEPENDENCIES = ['envisalink']
+_LOGGER = logging.getLogger(__name__)
+
+
+# pylint: disable=unused-argument
+def setup_platform(hass, config, add_devices_callback, discovery_info=None):
+    """Perform the setup for Envisalink alarm panels."""
+    _configured_partitions = discovery_info['partitions']
+    _code = discovery_info[CONF_CODE]
+    for part_num in _configured_partitions:
+        _device_config_data = PARTITION_SCHEMA(
+            _configured_partitions[part_num])
+        _device = EnvisalinkAlarm(
+            part_num,
+            _device_config_data[CONF_PARTITIONNAME],
+            _code,
+            EVL_CONTROLLER.alarm_state['partition'][part_num],
+            EVL_CONTROLLER)
+        add_devices_callback([_device])
+
+    return True
+
+
+class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
+    """Represents the Envisalink-based alarm panel."""
+
+    # pylint: disable=too-many-arguments
+    def __init__(self, partition_number, alarm_name, code, info, controller):
+        """Initialize the alarm panel."""
+        from pydispatch import dispatcher
+        self._partition_number = partition_number
+        self._code = code
+        _LOGGER.debug('Setting up alarm: ' + alarm_name)
+        EnvisalinkDevice.__init__(self, alarm_name, info, controller)
+        dispatcher.connect(self._update_callback,
+                           signal=SIGNAL_PARTITION_UPDATE,
+                           sender=dispatcher.Any)
+        dispatcher.connect(self._update_callback,
+                           signal=SIGNAL_KEYPAD_UPDATE,
+                           sender=dispatcher.Any)
+
+    def _update_callback(self, partition):
+        """Update HA state, if needed."""
+        if partition is None or int(partition) == self._partition_number:
+            self.update_ha_state()
+
+    @property
+    def code_format(self):
+        """The characters if code is defined."""
+        return self._code
+
+    @property
+    def state(self):
+        """Return the state of the device."""
+        if self._info['status']['alarm']:
+            return STATE_ALARM_TRIGGERED
+        elif self._info['status']['armed_away']:
+            return STATE_ALARM_ARMED_AWAY
+        elif self._info['status']['armed_stay']:
+            return STATE_ALARM_ARMED_HOME
+        elif self._info['status']['alpha']:
+            return STATE_ALARM_DISARMED
+        else:
+            return STATE_UNKNOWN
+
+    def alarm_disarm(self, code=None):
+        """Send disarm command."""
+        if self._code:
+            EVL_CONTROLLER.disarm_partition(str(code),
+                                            self._partition_number)
+
+    def alarm_arm_home(self, code=None):
+        """Send arm home command."""
+        if self._code:
+            EVL_CONTROLLER.arm_stay_partition(str(code),
+                                              self._partition_number)
+
+    def alarm_arm_away(self, code=None):
+        """Send arm away command."""
+        if self._code:
+            EVL_CONTROLLER.arm_away_partition(str(code),
+                                              self._partition_number)
+
+    def alarm_trigger(self, code=None):
+        """Alarm trigger command. Not possible for us."""
+        raise NotImplementedError()
diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py
new file mode 100644
index 00000000000..144de83aa53
--- /dev/null
+++ b/homeassistant/components/binary_sensor/envisalink.py
@@ -0,0 +1,71 @@
+"""
+Support for Envisalink zone states- represented as binary sensors.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/binary_sensor.envisalink/
+"""
+import logging
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.envisalink import (EVL_CONTROLLER,
+                                                 ZONE_SCHEMA,
+                                                 CONF_ZONENAME,
+                                                 CONF_ZONETYPE,
+                                                 EnvisalinkDevice,
+                                                 SIGNAL_ZONE_UPDATE)
+from homeassistant.const import ATTR_LAST_TRIP_TIME
+
+DEPENDENCIES = ['envisalink']
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_devices_callback, discovery_info=None):
+    """Perform the setup for Envisalink sensor devices."""
+    _configured_zones = discovery_info['zones']
+    for zone_num in _configured_zones:
+        _device_config_data = ZONE_SCHEMA(_configured_zones[zone_num])
+        _device = EnvisalinkBinarySensor(
+            zone_num,
+            _device_config_data[CONF_ZONENAME],
+            _device_config_data[CONF_ZONETYPE],
+            EVL_CONTROLLER.alarm_state['zone'][zone_num],
+            EVL_CONTROLLER)
+        add_devices_callback([_device])
+
+
+class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice):
+    """Representation of an envisalink Binary Sensor."""
+
+    # pylint: disable=too-many-arguments
+    def __init__(self, zone_number, zone_name, zone_type, info, controller):
+        """Initialize the binary_sensor."""
+        from pydispatch import dispatcher
+        self._zone_type = zone_type
+        self._zone_number = zone_number
+
+        _LOGGER.debug('Setting up zone: ' + zone_name)
+        EnvisalinkDevice.__init__(self, zone_name, info, controller)
+        dispatcher.connect(self._update_callback,
+                           signal=SIGNAL_ZONE_UPDATE,
+                           sender=dispatcher.Any)
+
+    @property
+    def device_state_attributes(self):
+        """Return the state attributes."""
+        attr = {}
+        attr[ATTR_LAST_TRIP_TIME] = self._info['last_fault']
+        return attr
+
+    @property
+    def is_on(self):
+        """Return true if sensor is on."""
+        return self._info['status']['open']
+
+    @property
+    def sensor_class(self):
+        """Return the class of this sensor, from SENSOR_CLASSES."""
+        return self._zone_type
+
+    def _update_callback(self, zone):
+        """Update the zone's state, if needed."""
+        if zone is None or int(zone) == self._zone_number:
+            self.update_ha_state()
diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py
new file mode 100644
index 00000000000..f1a7009e059
--- /dev/null
+++ b/homeassistant/components/envisalink.py
@@ -0,0 +1,210 @@
+"""
+Support for Envisalink devices.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/envisalink/
+"""
+import logging
+import time
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.discovery import load_platform
+
+REQUIREMENTS = ['pyenvisalink==0.9', 'pydispatcher==2.0.5']
+
+_LOGGER = logging.getLogger(__name__)
+DOMAIN = 'envisalink'
+
+EVL_CONTROLLER = None
+
+CONF_EVL_HOST = 'host'
+CONF_EVL_PORT = 'port'
+CONF_PANEL_TYPE = 'panel_type'
+CONF_EVL_VERSION = 'evl_version'
+CONF_CODE = 'code'
+CONF_USERNAME = 'user_name'
+CONF_PASS = 'password'
+CONF_EVL_KEEPALIVE = 'keepalive_interval'
+CONF_ZONEDUMP_INTERVAL = 'zonedump_interval'
+CONF_ZONES = 'zones'
+CONF_PARTITIONS = 'partitions'
+
+CONF_ZONENAME = 'name'
+CONF_ZONETYPE = 'type'
+CONF_PARTITIONNAME = 'name'
+
+DEFAULT_PORT = 4025
+DEFAULT_EVL_VERSION = 3
+DEFAULT_KEEPALIVE = 60
+DEFAULT_ZONEDUMP_INTERVAL = 30
+DEFAULT_ZONETYPE = 'opening'
+
+SIGNAL_ZONE_UPDATE = 'zones_updated'
+SIGNAL_PARTITION_UPDATE = 'partition_updated'
+SIGNAL_KEYPAD_UPDATE = 'keypad_updated'
+
+ZONE_SCHEMA = vol.Schema({
+    vol.Required(CONF_ZONENAME): cv.string,
+    vol.Optional(CONF_ZONETYPE, default=DEFAULT_ZONETYPE): cv.string})
+
+PARTITION_SCHEMA = vol.Schema({
+    vol.Required(CONF_PARTITIONNAME): cv.string})
+
+CONFIG_SCHEMA = vol.Schema({
+    DOMAIN: vol.Schema({
+        vol.Required(CONF_EVL_HOST): cv.string,
+        vol.Required(CONF_PANEL_TYPE):
+            vol.All(cv.string, vol.In(['HONEYWELL', 'DSC'])),
+        vol.Required(CONF_USERNAME): cv.string,
+        vol.Required(CONF_PASS): cv.string,
+        vol.Required(CONF_CODE): cv.string,
+        vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA},
+        vol.Optional(CONF_PARTITIONS): {vol.Coerce(int): PARTITION_SCHEMA},
+        vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT):
+            vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
+        vol.Optional(CONF_EVL_VERSION, default=DEFAULT_EVL_VERSION):
+            vol.All(vol.Coerce(int), vol.Range(min=3, max=4)),
+        vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE):
+            vol.All(vol.Coerce(int), vol.Range(min=15)),
+        vol.Optional(CONF_ZONEDUMP_INTERVAL,
+                     default=DEFAULT_ZONEDUMP_INTERVAL):
+            vol.All(vol.Coerce(int), vol.Range(min=15)),
+    }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+# pylint: disable=unused-argument, too-many-function-args, too-many-locals
+# pylint: disable=too-many-return-statements
+def setup(hass, base_config):
+    """Common setup for Envisalink devices."""
+    from pyenvisalink import EnvisalinkAlarmPanel
+    from pydispatch import dispatcher
+
+    global EVL_CONTROLLER
+
+    config = base_config.get(DOMAIN)
+
+    _host = config.get(CONF_EVL_HOST)
+    _port = config.get(CONF_EVL_PORT)
+    _code = config.get(CONF_CODE)
+    _panel_type = config.get(CONF_PANEL_TYPE)
+    _version = config.get(CONF_EVL_VERSION)
+    _user = config.get(CONF_USERNAME)
+    _pass = config.get(CONF_PASS)
+    _keep_alive = config.get(CONF_EVL_KEEPALIVE)
+    _zone_dump = config.get(CONF_ZONEDUMP_INTERVAL)
+    _zones = config.get(CONF_ZONES)
+    _partitions = config.get(CONF_PARTITIONS)
+    _connect_status = {}
+    EVL_CONTROLLER = EnvisalinkAlarmPanel(_host,
+                                          _port,
+                                          _panel_type,
+                                          _version,
+                                          _user,
+                                          _pass,
+                                          _zone_dump,
+                                          _keep_alive)
+
+    def login_fail_callback(data):
+        """Callback for when the evl rejects our login."""
+        _LOGGER.error("The envisalink rejected your credentials.")
+        _connect_status['fail'] = 1
+
+    def connection_fail_callback(data):
+        """Network failure callback."""
+        _LOGGER.error("Could not establish a connection with the envisalink.")
+        _connect_status['fail'] = 1
+
+    def connection_success_callback(data):
+        """Callback for a successful connection."""
+        _LOGGER.info("Established a connection with the envisalink.")
+        _connect_status['success'] = 1
+
+    def zones_updated_callback(data):
+        """Handle zone timer updates."""
+        _LOGGER.info("Envisalink sent a zone update event.  Updating zones...")
+        dispatcher.send(signal=SIGNAL_ZONE_UPDATE,
+                        sender=None,
+                        zone=data)
+
+    def alarm_data_updated_callback(data):
+        """Handle non-alarm based info updates."""
+        _LOGGER.info("Envisalink sent new alarm info. Updating alarms...")
+        dispatcher.send(signal=SIGNAL_KEYPAD_UPDATE,
+                        sender=None,
+                        partition=data)
+
+    def partition_updated_callback(data):
+        """Handle partition changes thrown by evl (including alarms)."""
+        _LOGGER.info("The envisalink sent a partition update event.")
+        dispatcher.send(signal=SIGNAL_PARTITION_UPDATE,
+                        sender=None,
+                        partition=data)
+
+    def stop_envisalink(event):
+        """Shutdown envisalink connection and thread on exit."""
+        _LOGGER.info("Shutting down envisalink.")
+        EVL_CONTROLLER.stop()
+
+    def start_envisalink(event):
+        """Startup process for the envisalink."""
+        EVL_CONTROLLER.start()
+        for _ in range(10):
+            if 'success' in _connect_status:
+                hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink)
+                return True
+            elif 'fail' in _connect_status:
+                return False
+            else:
+                time.sleep(1)
+
+        _LOGGER.error("Timeout occurred while establishing evl connection.")
+        return False
+
+    EVL_CONTROLLER.callback_zone_timer_dump = zones_updated_callback
+    EVL_CONTROLLER.callback_zone_state_change = zones_updated_callback
+    EVL_CONTROLLER.callback_partition_state_change = partition_updated_callback
+    EVL_CONTROLLER.callback_keypad_update = alarm_data_updated_callback
+    EVL_CONTROLLER.callback_login_failure = login_fail_callback
+    EVL_CONTROLLER.callback_login_timeout = connection_fail_callback
+    EVL_CONTROLLER.callback_login_success = connection_success_callback
+
+    _result = start_envisalink(None)
+    if not _result:
+        return False
+
+    # Load sub-components for envisalink
+    if _partitions:
+        load_platform(hass, 'alarm_control_panel', 'envisalink',
+                      {'partitions': _partitions,
+                       'code': _code}, config)
+        load_platform(hass, 'sensor', 'envisalink',
+                      {'partitions': _partitions,
+                       'code': _code}, config)
+    if _zones:
+        load_platform(hass, 'binary_sensor', 'envisalink',
+                      {'zones': _zones}, config)
+
+    return True
+
+
+class EnvisalinkDevice(Entity):
+    """Representation of an envisalink devicetity."""
+
+    def __init__(self, name, info, controller):
+        """Initialize the device."""
+        self._controller = controller
+        self._info = info
+        self._name = name
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def should_poll(self):
+        """No polling needed."""
+        return False
diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py
new file mode 100644
index 00000000000..cd71673b99f
--- /dev/null
+++ b/homeassistant/components/sensor/envisalink.py
@@ -0,0 +1,68 @@
+"""
+Support for Envisalink sensors (shows panel info).
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.envisalink/
+"""
+import logging
+from homeassistant.components.envisalink import (EVL_CONTROLLER,
+                                                 PARTITION_SCHEMA,
+                                                 CONF_PARTITIONNAME,
+                                                 EnvisalinkDevice,
+                                                 SIGNAL_KEYPAD_UPDATE)
+
+DEPENDENCIES = ['envisalink']
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_devices_callback, discovery_info=None):
+    """Perform the setup for Envisalink sensor devices."""
+    _configured_partitions = discovery_info['partitions']
+    for part_num in _configured_partitions:
+        _device_config_data = PARTITION_SCHEMA(
+            _configured_partitions[part_num])
+        _device = EnvisalinkSensor(
+            _device_config_data[CONF_PARTITIONNAME],
+            part_num,
+            EVL_CONTROLLER.alarm_state['partition'][part_num],
+            EVL_CONTROLLER)
+        add_devices_callback([_device])
+
+
+class EnvisalinkSensor(EnvisalinkDevice):
+    """Representation of an envisalink keypad."""
+
+    def __init__(self, partition_name, partition_number, info, controller):
+        """Initialize the sensor."""
+        from pydispatch import dispatcher
+        self._icon = 'mdi:alarm'
+        self._partition_number = partition_number
+        _LOGGER.debug('Setting up sensor for partition: ' + partition_name)
+        EnvisalinkDevice.__init__(self,
+                                  partition_name + ' Keypad',
+                                  info,
+                                  controller)
+
+        dispatcher.connect(self._update_callback,
+                           signal=SIGNAL_KEYPAD_UPDATE,
+                           sender=dispatcher.Any)
+
+    @property
+    def icon(self):
+        """Return the icon if any."""
+        return self._icon
+
+    @property
+    def state(self):
+        """Return the overall state."""
+        return self._info['status']['alpha']
+
+    @property
+    def device_state_attributes(self):
+        """Return the state attributes."""
+        return self._info['status']
+
+    def _update_callback(self, partition):
+        """Update the partition state in HA, if needed."""
+        if partition is None or int(partition) == self._partition_number:
+            self.update_ha_state()
diff --git a/requirements_all.txt b/requirements_all.txt
index 368ea27649c..bafb405b956 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -241,9 +241,13 @@ pyasn1==0.1.9
 # homeassistant.components.media_player.cast
 pychromecast==0.7.2
 
+# homeassistant.components.envisalink
 # homeassistant.components.zwave
 pydispatcher==2.0.5
 
+# homeassistant.components.envisalink
+pyenvisalink==0.9
+
 # homeassistant.components.ifttt
 pyfttt==0.3