diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e36302..a5b61c9ffede 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,24 +4,31 @@ Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta from typing import Callable # noqa +from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN import homeassistant.components.isy994 as isy from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - UOM = ['2', '78'] STATES = [STATE_OFF, STATE_ON, 'true', 'false'] +ISY_DEVICE_TYPES = { + 'moisture': ['16.8', '16.13', '16.14'], + 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] +} + # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, @@ -32,10 +39,46 @@ def setup_platform(hass, config: ConfigType, return False devices = [] + devices_by_nid = {} + child_nodes = [] for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, states=STATES): - devices.append(ISYBinarySensorDevice(node)) + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) + + for node in child_nodes: + try: + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + node.nid, node.parent_nid) + else: + device_type = _detect_device_type(node) + if device_type in ['moisture', 'opening']: + subnode_id = int(node.nid[-1]) + # Leak and door/window sensors work the same way with negative + # nodes and heartbeat nodes + if subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + elif subnode_id == 2: + parent_device.add_negative_node(node) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) for program in isy.PROGRAMS.get(DOMAIN, []): try: @@ -48,23 +91,281 @@ def setup_platform(hass, config: ConfigType, add_devices(devices) +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + if not _is_val_unknown(self._negative_node): + # If the negative node has a value, it means the negative node is + # in use for this device. Therefore, we cannot determine the state + # of the sensor until we receive our first ON event. + self._computed_state = None + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type + + +class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + super().__init__(node) + self._name = name @property def is_on(self) -> bool: """Get whether the ISY994 binary sensor device is on.""" return bool(self.value) - - -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" - - def __init__(self, name, node) -> None: - """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) - self._name = name diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278ce..4dd1c9be364a 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -69,7 +69,10 @@ class ISYCoverDevice(isy.ISYDevice, CoverDevice): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7de..af1846c7bf81 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ Support the ISY-994 controllers. For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -91,6 +92,34 @@ def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: return filtered_nodes +def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool: + """Determine if the given node is a sensor.""" + if not isinstance(node, PYISY.Nodes.Node): + return False + + if sensor_identifier in path or sensor_identifier in node.name: + return True + + # This method is most reliable but only works on 5.x firmware + try: + if node.node_def_id == 'BinaryAlarm': + return True + except AttributeError: + pass + + # This method works on all firmwares, but only for Insteon devices + try: + device_type = node.type + except AttributeError: + # Node has no type; most likely not an Insteon device + pass + else: + split_type = device_type.split('.') + return split_type[0] == '16' # 16 represents Insteon binary sensors + + return False + + def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: """Categorize the ISY994 nodes.""" global SENSOR_NODES @@ -106,7 +135,7 @@ def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: hidden = hidden_identifier in path or hidden_identifier in node.name if hidden: node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: + if _is_node_a_sensor(node, path, sensor_identifier): SENSOR_NODES.append(node) elif isinstance(node, PYISY.Nodes.Node): NODES.append(node) @@ -227,15 +256,31 @@ class ISYDevice(Entity): def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) + @property def domain(self) -> str: """Get the domain of the device.""" @@ -270,6 +315,21 @@ class ISYDevice(Entity): # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + else: + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f240..63272b90b1fa 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -66,7 +66,10 @@ class ISYLockDevice(isy.ISYDevice, LockDevice): @property def state(self) -> str: """Get the state of the lock.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index f64fa6191e22..e961c63a1b51 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -282,6 +282,9 @@ class ISYSensorDevice(isy.ISYDevice): @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" + if self.is_unknown(): + return None + if len(self._node.uom) == 1: if self._node.uom[0] in UOM_TO_STATES: states = UOM_TO_STATES.get(self._node.uom[0]) diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index b930bedc2c7a..0f1ec62eaeef 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -69,7 +69,10 @@ class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): @property def state(self) -> str: """Get the state of the ISY994 device.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" diff --git a/requirements_all.txt b/requirements_all.txt index 6431d5263593..ee1c29381950 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,7 +23,7 @@ certifi>=2017.4.17 DoorBirdPy==0.1.0 # homeassistant.components.isy994 -PyISY==1.0.8 +PyISY==1.1.0 # homeassistant.components.notify.html5 PyJWT==1.5.3