-
-
Notifications
You must be signed in to change notification settings - Fork 37.9k
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 6 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,28 @@ | |
| 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 datetime | ||
| from typing import Callable # noqa | ||
|
|
||
| from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN | ||
| import homeassistant.components.isy994 as isy | ||
| from homeassistant.const import STATE_ON, STATE_OFF | ||
| from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN | ||
| from homeassistant.helpers.typing import ConfigType | ||
|
|
||
| _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 +36,27 @@ 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: | ||
| devices_by_nid[node.parent_node.nid].add_child_node(node) | ||
| 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) | ||
|
|
||
| for program in isy.PROGRAMS.get(DOMAIN, []): | ||
| try: | ||
|
|
@@ -49,22 +70,183 @@ 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_node = None | ||
| self._heartbeat_timestamp = None | ||
| self._device_class_from_type = self._detect_device_type() | ||
| # pylint: disable=protected-access | ||
| if self._node.status._val == -1: | ||
| 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.""" | ||
| 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: | ||
|
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. 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
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. If there's a simple check that is safe and always works, I go with an
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. 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 |
||
| # Heartbeat node doesn't exist | ||
| pass | ||
|
|
||
| try: | ||
| self._heartbeat_node.controlEvents.subscribe( | ||
| self._heartbeat_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_child_node(self, child): | ||
| """Add a child node to this binary sensor device. | ||
|
|
||
| The child node can either be a node that receives the 'off' events, or | ||
| a heartbeat node for reporting that this device is still alive | ||
| """ | ||
| subnode_id = int(child.nid[-1]) | ||
| if subnode_id == 2: | ||
| # "Negative" node that can be used to represent a negative state | ||
| # when it reports "On" | ||
| self._negative_node = child | ||
| elif subnode_id == 4: | ||
| # Heartbeat node that just reports "On" every 24 hours | ||
| self._heartbeat_timestamp = STATE_UNKNOWN | ||
|
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. I don't think you should use
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. It's important for there to be a difference between the attribute not existing (None) and the attribute currently being "Unknown" because we haven't seen a heartbeat since the last boot. If it was simply None until the first heartbeat, an automation can't tell if this device ever SHOULD give a heartbeat. |
||
| self._heartbeat_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() | ||
|
|
||
| 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() | ||
| 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() | ||
|
|
||
| def _heartbeat_node_control_handler(self, event: object) -> None: | ||
| """Update the heartbeat timestamp when an On event is sent.""" | ||
| if event == 'DON': | ||
| self._heartbeat_timestamp = datetime.now().isoformat() | ||
| self.schedule_update_ha_state() | ||
|
|
||
| # 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 | ||
|
|
||
| try: | ||
| if self.device_class == 'moisture': | ||
| return not self._computed_state | ||
| 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 could this happen? It looks like it will either return
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. If you check the ISY_DEVICE_TYPES const up top,
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. What object attribute access could lead to attribute error?
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. Fixed - artifact of a refactor |
||
| pass | ||
|
|
||
| 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 STATE_UNKNOWN | ||
|
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. Return
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. Hm, I see that the
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. No, we should let the entity base class handle this. |
||
| return STATE_ON if self.is_on else STATE_OFF | ||
|
|
||
| @property | ||
| def device_class(self) -> str: | ||
| """Return the class of this device. | ||
|
|
||
| class ISYBinarySensorProgram(ISYBinarySensorDevice): | ||
| """Representation of an ISY994 binary sensor program.""" | ||
| This was discovered by parsing the device type code during init | ||
| """ | ||
| return self._device_class_from_type | ||
|
|
||
| @property | ||
| def device_state_attributes(self): | ||
| """Get the state attributes for the device.""" | ||
| attr = super().device_state_attributes | ||
| if self._heartbeat_timestamp is not None: | ||
| attr['last_heartbeat'] = self._heartbeat_timestamp | ||
|
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. Timestamps in state attributes is frowned upon, and usually not allowed.
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. I don't see the alternative here. Heartbeats are daily pings and that's it. The idea here is that you could write some automatons that sent an alert if the heartbeat timestamp becomes more than 24 hours old. I'm certainly open to suggestions but this seemed like the most straightforward way to present it to the user. I thought about making it some sort of entity that handled the 24 hour countdown internally, but that would depend on all Insteon devices always being on a 24 hour heartbeat, which is an assumption I don't think we should make... |
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| 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 | ||
|
|
@@ -91,6 +92,16 @@ 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: | ||
| if sensor_identifier in path or sensor_identifier in node.name: | ||
| return True | ||
|
|
||
| try: | ||
| return node.node_def_id == 'BinaryAlarm' | ||
| except AttributeError: | ||
| return False | ||
|
|
||
|
|
||
| def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: | ||
| """Categorize the ISY994 nodes.""" | ||
| global SENSOR_NODES | ||
|
|
@@ -106,7 +117,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 +238,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( | ||
|
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. You should probably move this subscription to the coroutine
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. Done! Good catch to eliminate a race condition. This issue was present before as well! |
||
| 'changed', self.on_update) | ||
|
|
||
| if hasattr(self._node, 'controlEvents'): | ||
| self._control_handler = self._node.controlEvents.subscribe( | ||
|
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. You should probably move this subscription to the coroutine
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. You know what, it didn't occur to me that this PR is actually branched off of PR #10664, which is where this code comes from. That being said, I'll make the change to the base PR and rebase this one to that.
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 now done even though GitHub is not recognizing this comment as outdated) |
||
| 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.""" | ||
|
|
||
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.
This is a coroutine, so you should
yield fromit to schedule it.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.
Thanks for the help here - I am a little unclear on when to yield in a coroutine. Are you saying that I should
yield fromthe super() call, but the other calls in this method are fine?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.
Yes!