-
-
Notifications
You must be signed in to change notification settings - Fork 38.3k
ISY994 sensor improvements #10805
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ISY994 sensor improvements #10805
Changes from 9 commits
3b8a2a7
72bbd5d
f10822f
6cbe3b7
7f9189c
489fbbc
4de13c4
efb000a
76b9299
624ee06
24b7af4
9cfeaa3
b9f8422
e5da5bc
1c59cc5
ccf3a9e
92d8dd4
e12ee0f
fef3515
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,24 +4,31 @@ | |
| 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,37 @@ 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: | ||
| subnode_id = int(node.nid[-1]) | ||
| 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) | ||
|
|
||
| for program in isy.PROGRAMS.get(DOMAIN, []): | ||
| try: | ||
|
|
@@ -49,22 +83,273 @@ def setup_platform(hass, config: ConfigType, | |
|
|
||
|
|
||
| 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 = self._detect_device_type() | ||
| # pylint: disable=protected-access | ||
| if self._node.status._val == -1*float('inf'): | ||
| 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) | ||
|
|
||
| try: | ||
| self._negative_node.controlEvents.subscribe( | ||
| self._negative_node_control_handler) | ||
| except AttributeError: | ||
| # Heartbeat node doesn't exist | ||
| pass | ||
|
|
||
| def _detect_device_type(self) -> str: | ||
| try: | ||
| device_type = self._node.type | ||
| except AttributeError: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When can this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to protect against the |
||
| return None | ||
|
|
||
| split_type = device_type.split('.') | ||
| for device_class, ids in ISY_DEVICE_TYPES.items(): | ||
| if split_type[0] + '.' + split_type[1] in ids: | ||
| return device_class | ||
|
|
||
| return None | ||
|
|
||
| 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.""" | ||
| try: | ||
| self._heartbeat_device.heartbeat() | ||
| except AttributeError: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not check
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Same answer as the line 115 comment) |
||
| # No heartbeat device exists | ||
| pass | ||
|
|
||
| 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 | ||
|
|
||
| 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.""" | ||
| """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 | ||
|
|
||
| class ISYBinarySensorProgram(ISYBinarySensorDevice): | ||
| """Representation of an ISY994 binary sensor program.""" | ||
| @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.""" | ||
| ISYBinarySensorDevice.__init__(self, node) | ||
| 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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not check
if self._negative_node is not Noneinstead?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went with the Python pattern of "ask for forgiveness" for no other reason than it seems like the Pythonic way. Happy to change it, and I'd love to hear if you have thoughts about when
exceptshould be used vs checking for None.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there's a simple check that is safe and always works, I go with an
if. If there are multiple checks needed or if those checks don't always work, or if cases are unpredictable, I go withtry... except. But it all depends... 😄Here the case looks contained to me since we initialize the attribute to
None.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it! Since we control that property in a closed environment, we should just check for values that we know it can be. I just pushed the change for these comments