Improve ISY994 Z-Wave and binary sensor device sorting (#35391)

* Split BinarySensor Entity

* Fix Device Class for Insteon

* ISY994 Improved device sorting (incl Z-Wave Cats) post-PyISYv2

- Z-Wave device classification using new properties.
    - ISY Z-Wave Devices can be classified by their Z-Wave categories, since `node_def_id` does not provide useful information for these nodes.
- Better classification of binary_sensors.
    - Incorporate better classifications of different types of binary_sensor devices supported by the ISY. These are all field tested/requested based on the HACS-ISY994 version and feedback given through the Community.
- State Unknown updates for binary_sensors.
    - Properly fix unknown states on startup for binary_sensors with multiple sub-nodes that don't report the status after an ISY restart until that subnode is activated/triggered the next time. Now instead of adding nodes as unknown status, it adds them in the "assumed safe" state (usually off). This his helpful for things like motion sensor low battery nodes, which may show "unknown" for 12 months before turning "ON" (low batt).
- Fix for heartbeat devices (home-assistant/core#21996)
    - Heartbeat devices toggle between both DON and DOF commands, each day when it sends the heartbeat it sends the opposite. Update heartbeat nodes to look for both.

* Additional sorting fixes
pull/35399/head
shbatm 2020-05-08 17:18:50 -05:00 committed by GitHub
parent 4cf186a47e
commit e65f72f2ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 450 additions and 147 deletions

View File

@ -1,23 +1,60 @@
"""Support for ISY994 binary sensors.""" """Support for ISY994 binary sensors."""
from datetime import timedelta from datetime import timedelta
from typing import Callable from typing import Callable, Union
from pyisy.constants import ISY_VALUE_UNKNOWN from pyisy.constants import (
CMD_OFF,
CMD_ON,
ISY_VALUE_UNKNOWN,
PROTO_INSTEON,
PROTO_ZWAVE,
)
from pyisy.nodes import Group, Node
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_COLD,
DEVICE_CLASS_HEAT,
DEVICE_CLASS_LIGHT,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PROBLEM,
DOMAIN as BINARY_SENSOR, DOMAIN as BINARY_SENSOR,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import ISY994_NODES, ISY994_PROGRAMS from . import ISY994_NODES, ISY994_PROGRAMS
from .const import _LOGGER, ISY_BIN_SENS_DEVICE_TYPES from .const import (
_LOGGER,
BINARY_SENSOR_DEVICE_TYPES_ISY,
BINARY_SENSOR_DEVICE_TYPES_ZWAVE,
TYPE_CATEGORY_CLIMATE,
)
from .entity import ISYNodeEntity, ISYProgramEntity from .entity import ISYNodeEntity, ISYProgramEntity
DEVICE_PARENT_REQUIRED = [
DEVICE_CLASS_OPENING,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
]
SUBNODE_CLIMATE_COOL = 2
SUBNODE_CLIMATE_HEAT = 3
SUBNODE_NEGATIVE = 2
SUBNODE_HEARTBEAT = 4
SUBNODE_DUSK_DAWN = 2
SUBNODE_LOW_BATTERY = 3
SUBNODE_TAMPER = (10, 16) # Int->10 or Hex->0xA depending on firmware
SUBNODE_MOTION_DISABLED = (13, 19) # Int->13 or Hex->0xD depending on firmware
TYPE_INSTEON_MOTION = ("16.1.", "16.22.")
def setup_platform( def setup_platform(
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
@ -28,44 +65,102 @@ def setup_platform(
child_nodes = [] child_nodes = []
for node in hass.data[ISY994_NODES][BINARY_SENSOR]: for node in hass.data[ISY994_NODES][BINARY_SENSOR]:
if node.parent_node is None: device_class, device_type = _detect_device_type_and_class(node)
device = ISYBinarySensorEntity(node) if node.protocol == PROTO_INSTEON:
devices.append(device) if node.parent_node is not None:
devices_by_address[node.address] = device # We'll process the Insteon child nodes last, to ensure all parent
# nodes have been processed
child_nodes.append((node, device_class, device_type))
continue
device = ISYInsteonBinarySensorEntity(node, device_class)
else: else:
# We'll process the child nodes last, to ensure all parent nodes device = ISYBinarySensorEntity(node, device_class)
# have been processed devices.append(device)
child_nodes.append(node) devices_by_address[node.address] = device
for node in child_nodes: # Handle some special child node cases for Insteon Devices
try: for (node, device_class, device_type) in child_nodes:
parent_device = devices_by_address[node.parent_node.address] subnode_id = int(node.address.split(" ")[-1], 16)
except KeyError: # Handle Insteon Thermostats
_LOGGER.error( if device_type.startswith(TYPE_CATEGORY_CLIMATE):
"Node %s has a parent node %s, but no device " if subnode_id == SUBNODE_CLIMATE_COOL:
"was created for the parent. Skipping.", # Subnode 2 is the "Cool Control" sensor
node.address, # It never reports its state until first use is
node.primary_node, # detected after an ISY Restart, so we assume it's off.
) # As soon as the ISY Event Stream connects if it has a
else: # valid state, it will be set.
device_type = _detect_device_type(node) device = ISYInsteonBinarySensorEntity(node, DEVICE_CLASS_COLD, False)
subnode_id = int(node.address[-1], 16)
if device_type in ("opening", "moisture"):
# These sensors use an optional "negative" subnode 2 to snag
# all state changes
if subnode_id == 2:
parent_device.add_negative_node(node)
elif 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)
else:
# We don't yet have any special logic for other sensor types,
# so add the nodes as individual devices
device = ISYBinarySensorEntity(node)
devices.append(device) devices.append(device)
elif subnode_id == SUBNODE_CLIMATE_HEAT:
# Subnode 3 is the "Heat Control" sensor
device = ISYInsteonBinarySensorEntity(node, DEVICE_CLASS_HEAT, False)
devices.append(device)
continue
if device_class in DEVICE_PARENT_REQUIRED:
parent_device = devices_by_address.get(node.parent_node.address)
if not parent_device:
_LOGGER.error(
"Node %s has a parent node %s, but no device "
"was created for the parent. Skipping.",
node.address,
node.parent_node,
)
continue
if device_class in (DEVICE_CLASS_OPENING, DEVICE_CLASS_MOISTURE):
# These sensors use an optional "negative" subnode 2 to
# snag all state changes
if subnode_id == SUBNODE_NEGATIVE:
parent_device.add_negative_node(node)
elif subnode_id == SUBNODE_HEARTBEAT:
# 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)
continue
if (
device_class == DEVICE_CLASS_MOTION
and device_type is not None
and any([device_type.startswith(t) for t in TYPE_INSTEON_MOTION])
):
# Special cases for Insteon Motion Sensors I & II:
# Some subnodes never report status until activated, so
# the initial state is forced "OFF"/"NORMAL" if the
# parent device has a valid state. This is corrected
# upon connection to the ISY event stream if subnode has a valid state.
initial_state = None if parent_device.state == STATE_UNKNOWN else False
if subnode_id == SUBNODE_DUSK_DAWN:
# Subnode 2 is the Dusk/Dawn sensor
device = ISYInsteonBinarySensorEntity(node, DEVICE_CLASS_LIGHT)
devices.append(device)
continue
if subnode_id == SUBNODE_LOW_BATTERY:
# Subnode 3 is the low battery node
device = ISYInsteonBinarySensorEntity(
node, DEVICE_CLASS_BATTERY, initial_state
)
devices.append(device)
continue
if subnode_id in SUBNODE_TAMPER:
# Tamper Sub-node for MS II. Sometimes reported as "A" sometimes
# reported as "10", which translate from Hex to 10 and 16 resp.
device = ISYInsteonBinarySensorEntity(
node, DEVICE_CLASS_PROBLEM, initial_state
)
devices.append(device)
continue
if subnode_id in SUBNODE_MOTION_DISABLED:
# Motion Disabled Sub-node for MS II ("D" or "13")
device = ISYInsteonBinarySensorEntity(node)
devices.append(device)
continue
# We don't yet have any special logic for other sensor
# types, so add the nodes as individual devices
device = ISYBinarySensorEntity(node, device_class)
devices.append(device)
for name, status, _ in hass.data[ISY994_PROGRAMS][BINARY_SENSOR]: for name, status, _ in hass.data[ISY994_PROGRAMS][BINARY_SENSOR]:
devices.append(ISYBinarySensorProgramEntity(name, status)) devices.append(ISYBinarySensorProgramEntity(name, status))
@ -73,23 +168,70 @@ def setup_platform(
add_entities(devices) add_entities(devices)
def _detect_device_type(node) -> str: def _detect_device_type_and_class(node: Union[Group, Node]) -> (str, str):
try: try:
device_type = node.type device_type = node.type
except AttributeError: except AttributeError:
# The type attribute didn't exist in the ISY's API response # The type attribute didn't exist in the ISY's API response
return None return (None, None)
split_type = device_type.split(".") # Z-Wave Devices:
for device_class, ids in ISY_BIN_SENS_DEVICE_TYPES.items(): if node.protocol == PROTO_ZWAVE:
if f"{split_type[0]}.{split_type[1]}" in ids: device_type = f"Z{node.zwave_props.category}"
return device_class for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ZWAVE]:
if (
node.zwave_props.category
in BINARY_SENSOR_DEVICE_TYPES_ZWAVE[device_class]
):
return device_class, device_type
return (None, device_type)
return None # Other devices (incl Insteon.)
for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ISY]:
if any(
[
device_type.startswith(t)
for t in set(BINARY_SENSOR_DEVICE_TYPES_ISY[device_class])
]
):
return device_class, device_type
return (None, device_type)
class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
"""Representation of an ISY994 binary sensor device. """Representation of a basic ISY994 binary sensor device."""
def __init__(self, node, force_device_class=None, unknown_state=None) -> None:
"""Initialize the ISY994 binary sensor device."""
super().__init__(node)
self._device_class = force_device_class
@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.value == ISY_VALUE_UNKNOWN:
return STATE_UNKNOWN
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
class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
"""Representation of an ISY994 Insteon binary sensor device.
Often times, a single device is represented by multiple nodes in the ISY, 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 allowing for different nuances in how those devices report their on and
@ -97,14 +239,13 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
Assistant entity and handles both ways that ISY binary sensors can work. Assistant entity and handles both ways that ISY binary sensors can work.
""" """
def __init__(self, node) -> None: def __init__(self, node, force_device_class=None, unknown_state=None) -> None:
"""Initialize the ISY994 binary sensor device.""" """Initialize the ISY994 binary sensor device."""
super().__init__(node) super().__init__(node, force_device_class)
self._negative_node = None self._negative_node = None
self._heartbeat_device = None self._heartbeat_device = None
self._device_class_from_type = _detect_device_type(self._node)
if self._node.status == ISY_VALUE_UNKNOWN: if self._node.status == ISY_VALUE_UNKNOWN:
self._computed_state = None self._computed_state = unknown_state
self._status_was_unknown = True self._status_was_unknown = True
else: else:
self._computed_state = bool(self._node.status) self._computed_state = bool(self._node.status)
@ -155,7 +296,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
def _negative_node_control_handler(self, event: object) -> None: def _negative_node_control_handler(self, event: object) -> None:
"""Handle an "On" control event from the "negative" node.""" """Handle an "On" control event from the "negative" node."""
if event.control == "DON": if event.control == CMD_ON:
_LOGGER.debug( _LOGGER.debug(
"Sensor %s turning Off via the Negative node sending a DON command", "Sensor %s turning Off via the Negative node sending a DON command",
self.name, self.name,
@ -171,7 +312,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
will come to this node, with the negative node representing Off will come to this node, with the negative node representing Off
events events
""" """
if event.control == "DON": if event.control == CMD_ON:
_LOGGER.debug( _LOGGER.debug(
"Sensor %s turning On via the Primary node sending a DON command", "Sensor %s turning On via the Primary node sending a DON command",
self.name, self.name,
@ -179,7 +320,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
self._computed_state = True self._computed_state = True
self.schedule_update_ha_state() self.schedule_update_ha_state()
self._heartbeat() self._heartbeat()
if event.control == "DOF": if event.control == CMD_OFF:
_LOGGER.debug( _LOGGER.debug(
"Sensor %s turning Off via the Primary node sending a DOF command", "Sensor %s turning Off via the Primary node sending a DOF command",
self.name, self.name,
@ -199,7 +340,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
an accompanying Control event, so we need to watch for it. an accompanying Control event, so we need to watch for it.
""" """
if self._status_was_unknown and self._computed_state is None: if self._status_was_unknown and self._computed_state is None:
self._computed_state = bool(int(self._node.status)) self._computed_state = bool(self._node.status)
self._status_was_unknown = False self._status_was_unknown = False
self.schedule_update_ha_state() self.schedule_update_ha_state()
self._heartbeat() self._heartbeat()
@ -216,44 +357,37 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
# Do this first so we don't invert None on moisture sensors # Do this first so we don't invert None on moisture sensors
return None return None
if self.device_class == "moisture": if self.device_class == DEVICE_CLASS_MOISTURE:
return not self._computed_state return not self._computed_state
return 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 @property
def state(self): def state(self):
"""Return the state of the binary sensor.""" """Return the state of the binary sensor."""
if self._computed_state is None: if self._computed_state is None:
return None return STATE_UNKNOWN
return STATE_ON if self.is_on else STATE_OFF 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(ISYNodeEntity, BinarySensorEntity): class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
"""Representation of the battery state of an ISY994 sensor.""" """Representation of the battery state of an ISY994 sensor."""
def __init__(self, node, parent_device) -> None: def __init__(self, node, parent_device) -> None:
"""Initialize the ISY994 binary sensor device.""" """Initialize the ISY994 binary sensor device.
Computed state is set to UNKNOWN unless the ISY provided a valid
state. See notes above regarding ISY Sensor status on ISY restart.
If a valid state is provided (either on or off), the computed state in
HA is set to OFF (Normal). If the heartbeat is not received in 25 hours
then the computed state is set to ON (Low Battery).
"""
super().__init__(node) super().__init__(node)
self._computed_state = None
self._parent_device = parent_device self._parent_device = parent_device
self._heartbeat_timer = None self._heartbeat_timer = None
self._computed_state = None
if self.state != STATE_UNKNOWN:
self._computed_state = False
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Subscribe to the node and subnode event emitters.""" """Subscribe to the node and subnode event emitters."""
@ -261,12 +395,15 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
self._node.control_events.subscribe(self._heartbeat_node_control_handler) self._node.control_events.subscribe(self._heartbeat_node_control_handler)
# Start the timer on bootup, so we can change from UNKNOWN to ON # Start the timer on bootup, so we can change from UNKNOWN to OFF
self._restart_timer() self._restart_timer()
def _heartbeat_node_control_handler(self, event: object) -> None: def _heartbeat_node_control_handler(self, event: object) -> None:
"""Update the heartbeat timestamp when an On event is sent.""" """Update the heartbeat timestamp when any ON/OFF event is sent.
if event.control == "DON":
The ISY uses both DON and DOF commands (alternating) for a heartbeat.
"""
if event.control in [CMD_ON, CMD_OFF]:
self.heartbeat() self.heartbeat()
def heartbeat(self): def heartbeat(self):
@ -292,14 +429,16 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
@callback @callback
def timer_elapsed(now) -> None: def timer_elapsed(now) -> None:
"""Heartbeat missed; set state to indicate dead battery.""" """Heartbeat missed; set state to ON to indicate dead battery."""
self._computed_state = True self._computed_state = True
self._heartbeat_timer = None self._heartbeat_timer = None
self.schedule_update_ha_state() self.schedule_update_ha_state()
point_in_time = dt_util.utcnow() + timedelta(hours=25) point_in_time = dt_util.utcnow() + timedelta(hours=25)
_LOGGER.debug( _LOGGER.debug(
"Timer starting. Now: %s Then: %s", dt_util.utcnow(), point_in_time "Heartbeat timer starting. Now: %s Then: %s",
dt_util.utcnow(),
point_in_time,
) )
self._heartbeat_timer = async_track_point_in_utc_time( self._heartbeat_timer = async_track_point_in_utc_time(
@ -335,7 +474,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
@property @property
def device_class(self) -> str: def device_class(self) -> str:
"""Get the class of this device.""" """Get the class of this device."""
return "battery" return DEVICE_CLASS_BATTERY
@property @property
def device_state_attributes(self): def device_state_attributes(self):

View File

@ -1,7 +1,22 @@
"""Constants for the ISY994 Platform.""" """Constants for the ISY994 Platform."""
import logging import logging
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_COLD,
DEVICE_CLASS_DOOR,
DEVICE_CLASS_GAS,
DEVICE_CLASS_HEAT,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PROBLEM,
DEVICE_CLASS_SAFETY,
DEVICE_CLASS_SMOKE,
DEVICE_CLASS_SOUND,
DEVICE_CLASS_VIBRATION,
DOMAIN as BINARY_SENSOR,
)
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL, CURRENT_HVAC_COOL,
CURRENT_HVAC_FAN, CURRENT_HVAC_FAN,
@ -87,6 +102,7 @@ CONF_TLS_VER = "tls"
DEFAULT_IGNORE_STRING = "{IGNORE ME}" DEFAULT_IGNORE_STRING = "{IGNORE ME}"
DEFAULT_SENSOR_STRING = "sensor" DEFAULT_SENSOR_STRING = "sensor"
DEFAULT_TLS_VERSION = 1.1 DEFAULT_TLS_VERSION = 1.1
DEFAULT_PROGRAM_STRING = "HA."
KEY_ACTIONS = "actions" KEY_ACTIONS = "actions"
KEY_STATUS = "status" KEY_STATUS = "status"
@ -104,13 +120,33 @@ ISY994_ISY = "isy"
ISY994_NODES = "isy994_nodes" ISY994_NODES = "isy994_nodes"
ISY994_PROGRAMS = "isy994_programs" ISY994_PROGRAMS = "isy994_programs"
FILTER_UOM = "uom"
FILTER_STATES = "states"
FILTER_NODE_DEF_ID = "node_def_id"
FILTER_INSTEON_TYPE = "insteon_type"
FILTER_ZWAVE_CAT = "zwave_cat"
# Generic Insteon Type Categories for Filters
TYPE_CATEGORY_CONTROLLERS = "0."
TYPE_CATEGORY_DIMMABLE = "1."
TYPE_CATEGORY_SWITCHED = "2."
TYPE_CATEGORY_IRRIGATION = "4."
TYPE_CATEGORY_CLIMATE = "5."
TYPE_CATEGORY_POOL_CTL = "6."
TYPE_CATEGORY_SENSOR_ACTUATORS = "7."
TYPE_CATEGORY_ENERGY_MGMT = "9."
TYPE_CATEGORY_COVER = "14."
TYPE_CATEOGRY_LOCK = "15."
TYPE_CATEGORY_SAFETY = "16."
TYPE_CATEGORY_X10 = "113."
# Do not use the Home Assistant consts for the states here - we're matching exact API # Do not use the Home Assistant consts for the states here - we're matching exact API
# responses, not using them for Home Assistant states # responses, not using them for Home Assistant states
NODE_FILTERS = { NODE_FILTERS = {
BINARY_SENSOR: { BINARY_SENSOR: {
"uom": [], FILTER_UOM: [],
"states": [], FILTER_STATES: [],
"node_def_id": [ FILTER_NODE_DEF_ID: [
"BinaryAlarm", "BinaryAlarm",
"BinaryAlarm_ADV", "BinaryAlarm_ADV",
"BinaryControl", "BinaryControl",
@ -120,16 +156,17 @@ NODE_FILTERS = {
"OnOffControl", "OnOffControl",
"OnOffControl_ADV", "OnOffControl_ADV",
], ],
"insteon_type": [ FILTER_INSTEON_TYPE: [
"7.0.", "7.0.",
"7.13.", "7.13.",
"16.", TYPE_CATEGORY_SAFETY,
], # Does a startswith() match; include the dot ], # Does a startswith() match; include the dot
FILTER_ZWAVE_CAT: (["104", "112", "138"] + list(map(str, range(148, 180)))),
}, },
SENSOR: { SENSOR: {
# This is just a more-readable way of including MOST uoms between 1-100 # This is just a more-readable way of including MOST uoms between 1-100
# (Remember that range() is non-inclusive of the stop value) # (Remember that range() is non-inclusive of the stop value)
"uom": ( FILTER_UOM: (
["1"] ["1"]
+ list(map(str, range(3, 11))) + list(map(str, range(3, 11)))
+ list(map(str, range(12, 51))) + list(map(str, range(12, 51)))
@ -138,32 +175,43 @@ NODE_FILTERS = {
+ ["79"] + ["79"]
+ list(map(str, range(82, 97))) + list(map(str, range(82, 97)))
), ),
"states": [], FILTER_STATES: [],
"node_def_id": ["IMETER_SOLO", "EZIO2x4_Input_ADV"], FILTER_NODE_DEF_ID: [
"insteon_type": ["9.0.", "9.7."], "IMETER_SOLO",
"EZIO2x4_Input_ADV",
"KeypadButton",
"KeypadButton_ADV",
"RemoteLinc2",
"RemoteLinc2_ADV",
],
FILTER_INSTEON_TYPE: ["0.16.", "0.17.", "0.18.", "9.0.", "9.7."],
FILTER_ZWAVE_CAT: (["118", "143"] + list(map(str, range(180, 185)))),
}, },
LOCK: { LOCK: {
"uom": ["11"], FILTER_UOM: ["11"],
"states": ["locked", "unlocked"], FILTER_STATES: ["locked", "unlocked"],
"node_def_id": ["DoorLock"], FILTER_NODE_DEF_ID: ["DoorLock"],
"insteon_type": ["15.", "4.64."], FILTER_INSTEON_TYPE: [TYPE_CATEOGRY_LOCK, "4.64."],
FILTER_ZWAVE_CAT: ["111"],
}, },
FAN: { FAN: {
"uom": [], FILTER_UOM: [],
"states": ["off", "low", "med", "high"], FILTER_STATES: ["off", "low", "med", "high"],
"node_def_id": ["FanLincMotor"], FILTER_NODE_DEF_ID: ["FanLincMotor"],
"insteon_type": ["1.46."], FILTER_INSTEON_TYPE: ["1.46."],
FILTER_ZWAVE_CAT: [],
}, },
COVER: { COVER: {
"uom": ["97"], FILTER_UOM: ["97"],
"states": ["open", "closed", "closing", "opening", "stopped"], FILTER_STATES: ["open", "closed", "closing", "opening", "stopped"],
"node_def_id": [], FILTER_NODE_DEF_ID: [],
"insteon_type": [], FILTER_INSTEON_TYPE: [],
FILTER_ZWAVE_CAT: [],
}, },
LIGHT: { LIGHT: {
"uom": ["51"], FILTER_UOM: ["51"],
"states": ["on", "off", "%"], FILTER_STATES: ["on", "off", "%"],
"node_def_id": [ FILTER_NODE_DEF_ID: [
"BallastRelayLampSwitch", "BallastRelayLampSwitch",
"BallastRelayLampSwitch_ADV", "BallastRelayLampSwitch_ADV",
"DimmerLampOnly", "DimmerLampOnly",
@ -174,19 +222,18 @@ NODE_FILTERS = {
"KeypadDimmer", "KeypadDimmer",
"KeypadDimmer_ADV", "KeypadDimmer_ADV",
], ],
"insteon_type": ["1."], FILTER_INSTEON_TYPE: [TYPE_CATEGORY_DIMMABLE],
FILTER_ZWAVE_CAT: ["109", "119"],
}, },
SWITCH: { SWITCH: {
"uom": ["2", "78"], FILTER_UOM: ["2", "78"],
"states": ["on", "off"], FILTER_STATES: ["on", "off"],
"node_def_id": [ FILTER_NODE_DEF_ID: [
"AlertModuleArmed", "AlertModuleArmed",
"AlertModuleSiren", "AlertModuleSiren",
"AlertModuleSiren_ADV", "AlertModuleSiren_ADV",
"EZIO2x4_Output", "EZIO2x4_Output",
"EZRAIN_Output", "EZRAIN_Output",
"KeypadButton",
"KeypadButton_ADV",
"KeypadRelay", "KeypadRelay",
"KeypadRelay_ADV", "KeypadRelay_ADV",
"RelayLampOnly", "RelayLampOnly",
@ -195,13 +242,18 @@ NODE_FILTERS = {
"RelayLampSwitch_ADV", "RelayLampSwitch_ADV",
"RelaySwitchOnlyPlusQuery", "RelaySwitchOnlyPlusQuery",
"RelaySwitchOnlyPlusQuery_ADV", "RelaySwitchOnlyPlusQuery_ADV",
"RemoteLinc2",
"RemoteLinc2_ADV",
"Siren", "Siren",
"Siren_ADV", "Siren_ADV",
"X10", "X10",
], ],
"insteon_type": ["0.16.", "2.", "7.3.255.", "9.10.", "9.11.", "113."], FILTER_INSTEON_TYPE: [
TYPE_CATEGORY_SWITCHED,
"7.3.255.",
"9.10.",
"9.11.",
TYPE_CATEGORY_X10,
],
FILTER_ZWAVE_CAT: ["121", "122", "123", "137", "141", "147"],
}, },
} }
@ -494,9 +546,31 @@ UOM_TO_STATES = {
}, },
} }
ISY_BIN_SENS_DEVICE_TYPES = { BINARY_SENSOR_DEVICE_TYPES_ISY = {
"moisture": ["16.8.", "16.13.", "16.14."], DEVICE_CLASS_MOISTURE: ["16.8.", "16.13.", "16.14."],
"opening": ["16.9.", "16.6.", "16.7.", "16.2.", "16.17.", "16.20.", "16.21."], DEVICE_CLASS_OPENING: [
"motion": ["16.1.", "16.4.", "16.5.", "16.3.", "16.22."], "16.9.",
"climate": ["5.11.", "5.10."], "16.6.",
"16.7.",
"16.2.",
"16.17.",
"16.20.",
"16.21.",
],
DEVICE_CLASS_MOTION: ["16.1.", "16.4.", "16.5.", "16.3.", "16.22."],
}
BINARY_SENSOR_DEVICE_TYPES_ZWAVE = {
DEVICE_CLASS_SAFETY: ["137", "172", "176", "177", "178"],
DEVICE_CLASS_SMOKE: ["138", "156"],
DEVICE_CLASS_PROBLEM: ["148", "149", "157", "158", "164", "174", "175"],
DEVICE_CLASS_GAS: ["150", "151"],
DEVICE_CLASS_SOUND: ["153"],
DEVICE_CLASS_COLD: ["152", "168"],
DEVICE_CLASS_HEAT: ["154", "166", "167"],
DEVICE_CLASS_MOISTURE: ["159", "169"],
DEVICE_CLASS_DOOR: ["160"],
DEVICE_CLASS_BATTERY: ["162"],
DEVICE_CLASS_MOTION: ["155"],
DEVICE_CLASS_VIBRATION: ["173"],
} }

View File

@ -1,7 +1,13 @@
"""Sorting helpers for ISY994 device classifications.""" """Sorting helpers for ISY994 device classifications."""
from typing import Union from typing import Union
from pyisy.constants import PROTO_GROUP, PROTO_INSTEON, PROTO_PROGRAM, TAG_FOLDER from pyisy.constants import (
PROTO_GROUP,
PROTO_INSTEON,
PROTO_PROGRAM,
PROTO_ZWAVE,
TAG_FOLDER,
)
from pyisy.nodes import Group, Node, Nodes from pyisy.nodes import Group, Node, Nodes
from pyisy.programs import Programs from pyisy.programs import Programs
@ -9,10 +15,17 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.switch import DOMAIN as SWITCH
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from .const import ( from .const import (
_LOGGER, _LOGGER,
DEFAULT_PROGRAM_STRING,
FILTER_INSTEON_TYPE,
FILTER_NODE_DEF_ID,
FILTER_STATES,
FILTER_UOM,
FILTER_ZWAVE_CAT,
ISY994_NODES, ISY994_NODES,
ISY994_PROGRAMS, ISY994_PROGRAMS,
ISY_GROUP_PLATFORM, ISY_GROUP_PLATFORM,
@ -21,8 +34,18 @@ from .const import (
NODE_FILTERS, NODE_FILTERS,
SUPPORTED_PLATFORMS, SUPPORTED_PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS, SUPPORTED_PROGRAM_PLATFORMS,
TYPE_CATEGORY_SENSOR_ACTUATORS,
) )
BINARY_SENSOR_UOMS = ["2", "78"]
BINARY_SENSOR_ISY_STATES = ["on", "off"]
TYPE_EZIO2X4 = "7.3.255."
SUBNODE_EZIO2X4_SENSORS = [9, 10, 11, 12]
SUBNODE_FANLINC_LIGHT = 1
SUBNODE_IOLINC_RELAY = 2
def _check_for_node_def( def _check_for_node_def(
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
@ -40,11 +63,10 @@ def _check_for_node_def(
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform] platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms: for platform in platforms:
if node_def_id in NODE_FILTERS[platform]["node_def_id"]: if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]:
hass.data[ISY994_NODES][platform].append(node) hass.data[ISY994_NODES][platform].append(node)
return True return True
_LOGGER.warning("Unsupported node: %s, type: %s", node.name, node.type)
return False return False
@ -69,17 +91,69 @@ def _check_for_insteon_type(
if any( if any(
[ [
device_type.startswith(t) device_type.startswith(t)
for t in set(NODE_FILTERS[platform]["insteon_type"]) for t in set(NODE_FILTERS[platform][FILTER_INSTEON_TYPE])
] ]
): ):
# Hacky special-case just for FanLinc, which has a light module # Hacky special-cases for certain devices with different platforms
# as one of its nodes. Note that this special-case is not necessary # included as subnodes. Note that special-cases are not necessary
# on ISY 5.x firmware as it uses the superior NodeDefs method # on ISY 5.x firmware as it uses the superior NodeDefs method
if platform == FAN and int(node.address[-1]) == 1: subnode_id = int(node.address.split(" ")[-1], 16)
# FanLinc, which has a light module as one of its nodes.
if platform == FAN and subnode_id == SUBNODE_FANLINC_LIGHT:
hass.data[ISY994_NODES][LIGHT].append(node) hass.data[ISY994_NODES][LIGHT].append(node)
return True return True
# IOLincs which have a sensor and relay on 2 different nodes
if (
platform == BINARY_SENSOR
and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
and subnode_id == SUBNODE_IOLINC_RELAY
):
hass.data[ISY994_NODES][SWITCH].append(node)
return True
# Smartenit EZIO2X4
if (
platform == SWITCH
and device_type.startswith(TYPE_EZIO2X4)
and subnode_id in SUBNODE_EZIO2X4_SENSORS
):
hass.data[ISY994_NODES][BINARY_SENSOR].append(node)
return True
hass.data[ISY994_NODES][platform].append(node)
return True
return False
def _check_for_zwave_cat(
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
) -> bool:
"""Check if the node matches the ISY Z-Wave Category for any platforms.
This is for (presumably) every version of the ISY firmware, but only
works for Z-Wave Devices with the devtype.cat property.
"""
if not hasattr(node, "protocol") or node.protocol != PROTO_ZWAVE:
return False
if not hasattr(node, "zwave_props") or node.zwave_props is None:
# Node doesn't have a device type category (non-Z-Wave device)
return False
device_type = node.zwave_props.category
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms:
if any(
[
device_type.startswith(t)
for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT])
]
):
hass.data[ISY994_NODES][platform].append(node) hass.data[ISY994_NODES][platform].append(node)
return True return True
@ -97,22 +171,26 @@ def _check_for_uom_id(
This is used for versions of the ISY firmware that report uoms as a single This is used for versions of the ISY firmware that report uoms as a single
ID. We can often infer what type of device it is by that ID. ID. We can often infer what type of device it is by that ID.
""" """
if not hasattr(node, "uom") or node.uom is None: if not hasattr(node, "uom") or node.uom in [None, ""]:
# Node doesn't have a uom (Scenes for example) # Node doesn't have a uom (Scenes for example)
return False return False
node_uom = set(map(str.lower, node.uom)) # Backwards compatibility for ISYv4 Firmware:
node_uom = node.uom
if isinstance(node.uom, list):
node_uom = node.uom[0]
if uom_list: if uom_list:
if node_uom.intersection(uom_list): if node_uom in uom_list:
hass.data[ISY994_NODES][single_platform].append(node) hass.data[ISY994_NODES][single_platform].append(node)
return True return True
else: return False
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms: platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
if node_uom.intersection(NODE_FILTERS[platform]["uom"]): for platform in platforms:
hass.data[ISY994_NODES][platform].append(node) if node_uom in NODE_FILTERS[platform][FILTER_UOM]:
return True hass.data[ISY994_NODES][platform].append(node)
return True
return False return False
@ -129,27 +207,34 @@ def _check_for_states_in_uom(
possible "human readable" states. This filter passes if all of the possible possible "human readable" states. This filter passes if all of the possible
states fit inside the given filter. states fit inside the given filter.
""" """
if not hasattr(node, "uom") or node.uom is None: if not hasattr(node, "uom") or node.uom in [None, ""]:
# Node doesn't have a uom (Scenes for example) # Node doesn't have a uom (Scenes for example)
return False return False
# This only works for ISYv4 Firmware where uom is a list of states:
if not isinstance(node.uom, list):
return False
node_uom = set(map(str.lower, node.uom)) node_uom = set(map(str.lower, node.uom))
if states_list: if states_list:
if node_uom == set(states_list): if node_uom == set(states_list):
hass.data[ISY994_NODES][single_platform].append(node) hass.data[ISY994_NODES][single_platform].append(node)
return True return True
else: return False
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
for platform in platforms: platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
if node_uom == set(NODE_FILTERS[platform]["states"]): for platform in platforms:
hass.data[ISY994_NODES][platform].append(node) if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]):
return True hass.data[ISY994_NODES][platform].append(node)
return True
return False return False
def _is_sensor_a_binary_sensor(hass: HomeAssistantType, node) -> bool: def _is_sensor_a_binary_sensor(
hass: HomeAssistantType, node: Union[Group, Node]
) -> bool:
"""Determine if the given sensor node should be a binary_sensor.""" """Determine if the given sensor node should be a binary_sensor."""
if _check_for_node_def(hass, node, single_platform=BINARY_SENSOR): if _check_for_node_def(hass, node, single_platform=BINARY_SENSOR):
return True return True
@ -161,11 +246,11 @@ def _is_sensor_a_binary_sensor(hass: HomeAssistantType, node) -> bool:
# checks in the context of already knowing that this is definitely a # checks in the context of already knowing that this is definitely a
# sensor device. # sensor device.
if _check_for_uom_id( if _check_for_uom_id(
hass, node, single_platform=BINARY_SENSOR, uom_list=["2", "78"] hass, node, single_platform=BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS
): ):
return True return True
if _check_for_states_in_uom( if _check_for_states_in_uom(
hass, node, single_platform=BINARY_SENSOR, states_list=["on", "off"] hass, node, single_platform=BINARY_SENSOR, states_list=BINARY_SENSOR_ISY_STATES
): ):
return True return True
@ -205,16 +290,21 @@ def _categorize_nodes(
continue continue
if _check_for_insteon_type(hass, node): if _check_for_insteon_type(hass, node):
continue continue
if _check_for_zwave_cat(hass, node):
continue
if _check_for_uom_id(hass, node): if _check_for_uom_id(hass, node):
continue continue
if _check_for_states_in_uom(hass, node): if _check_for_states_in_uom(hass, node):
continue continue
# Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes.
hass.data[ISY994_NODES][SENSOR].append(node)
def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None: def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
"""Categorize the ISY994 programs.""" """Categorize the ISY994 programs."""
for platform in SUPPORTED_PROGRAM_PLATFORMS: for platform in SUPPORTED_PROGRAM_PLATFORMS:
folder = programs.get_by_name(f"HA.{platform}") folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
if not folder: if not folder:
continue continue