diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 7c8fcdf8a46..1ec54aa7b45 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -1,23 +1,60 @@ """Support for ISY994 binary sensors.""" 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 ( + 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, 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.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util 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 +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( hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None @@ -28,44 +65,102 @@ def setup_platform( child_nodes = [] for node in hass.data[ISY994_NODES][BINARY_SENSOR]: - if node.parent_node is None: - device = ISYBinarySensorEntity(node) - devices.append(device) - devices_by_address[node.address] = device + device_class, device_type = _detect_device_type_and_class(node) + if node.protocol == PROTO_INSTEON: + if node.parent_node is not None: + # 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: - # We'll process the child nodes last, to ensure all parent nodes - # have been processed - child_nodes.append(node) + device = ISYBinarySensorEntity(node, device_class) + devices.append(device) + devices_by_address[node.address] = device - for node in child_nodes: - try: - parent_device = devices_by_address[node.parent_node.address] - except KeyError: - _LOGGER.error( - "Node %s has a parent node %s, but no device " - "was created for the parent. Skipping.", - node.address, - node.primary_node, - ) - else: - device_type = _detect_device_type(node) - 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) + # Handle some special child node cases for Insteon Devices + for (node, device_class, device_type) in child_nodes: + subnode_id = int(node.address.split(" ")[-1], 16) + # Handle Insteon Thermostats + if device_type.startswith(TYPE_CATEGORY_CLIMATE): + if subnode_id == SUBNODE_CLIMATE_COOL: + # Subnode 2 is the "Cool Control" sensor + # It never reports its state until first use is + # detected after an ISY Restart, so we assume it's off. + # As soon as the ISY Event Stream connects if it has a + # valid state, it will be set. + device = ISYInsteonBinarySensorEntity(node, DEVICE_CLASS_COLD, False) 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]: devices.append(ISYBinarySensorProgramEntity(name, status)) @@ -73,23 +168,70 @@ def setup_platform( add_entities(devices) -def _detect_device_type(node) -> str: +def _detect_device_type_and_class(node: Union[Group, Node]) -> (str, str): try: device_type = node.type except AttributeError: # The type attribute didn't exist in the ISY's API response - return None + return (None, None) - split_type = device_type.split(".") - for device_class, ids in ISY_BIN_SENS_DEVICE_TYPES.items(): - if f"{split_type[0]}.{split_type[1]}" in ids: - return device_class + # Z-Wave Devices: + if node.protocol == PROTO_ZWAVE: + device_type = f"Z{node.zwave_props.category}" + 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): - """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, 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. """ - def __init__(self, node) -> None: + def __init__(self, node, force_device_class=None, unknown_state=None) -> None: """Initialize the ISY994 binary sensor device.""" - super().__init__(node) + super().__init__(node, force_device_class) self._negative_node = None self._heartbeat_device = None - self._device_class_from_type = _detect_device_type(self._node) if self._node.status == ISY_VALUE_UNKNOWN: - self._computed_state = None + self._computed_state = unknown_state self._status_was_unknown = True else: self._computed_state = bool(self._node.status) @@ -155,7 +296,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): def _negative_node_control_handler(self, event: object) -> None: """Handle an "On" control event from the "negative" node.""" - if event.control == "DON": + if event.control == CMD_ON: _LOGGER.debug( "Sensor %s turning Off via the Negative node sending a DON command", self.name, @@ -171,7 +312,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): will come to this node, with the negative node representing Off events """ - if event.control == "DON": + if event.control == CMD_ON: _LOGGER.debug( "Sensor %s turning On via the Primary node sending a DON command", self.name, @@ -179,7 +320,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): self._computed_state = True self.schedule_update_ha_state() self._heartbeat() - if event.control == "DOF": + if event.control == CMD_OFF: _LOGGER.debug( "Sensor %s turning Off via the Primary node sending a DOF command", self.name, @@ -199,7 +340,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): an accompanying Control event, so we need to watch for it. """ 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.schedule_update_ha_state() self._heartbeat() @@ -216,44 +357,37 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): # Do this first so we don't invert None on moisture sensors return None - if self.device_class == "moisture": + if self.device_class == 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_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_from_type - class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): """Representation of the battery state of an ISY994 sensor.""" 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) - self._computed_state = None self._parent_device = parent_device 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: """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) - # 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() def _heartbeat_node_control_handler(self, event: object) -> None: - """Update the heartbeat timestamp when an On event is sent.""" - if event.control == "DON": + """Update the heartbeat timestamp when any ON/OFF event is sent. + + The ISY uses both DON and DOF commands (alternating) for a heartbeat. + """ + if event.control in [CMD_ON, CMD_OFF]: self.heartbeat() def heartbeat(self): @@ -292,14 +429,16 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): @callback 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._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 + "Heartbeat timer starting. Now: %s Then: %s", + dt_util.utcnow(), + point_in_time, ) self._heartbeat_timer = async_track_point_in_utc_time( @@ -335,7 +474,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): @property def device_class(self) -> str: """Get the class of this device.""" - return "battery" + return DEVICE_CLASS_BATTERY @property def device_state_attributes(self): diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b26041fd4b0..3cc01d55b46 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -1,7 +1,22 @@ """Constants for the ISY994 Platform.""" 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 ( CURRENT_HVAC_COOL, CURRENT_HVAC_FAN, @@ -87,6 +102,7 @@ CONF_TLS_VER = "tls" DEFAULT_IGNORE_STRING = "{IGNORE ME}" DEFAULT_SENSOR_STRING = "sensor" DEFAULT_TLS_VERSION = 1.1 +DEFAULT_PROGRAM_STRING = "HA." KEY_ACTIONS = "actions" KEY_STATUS = "status" @@ -104,13 +120,33 @@ ISY994_ISY = "isy" ISY994_NODES = "isy994_nodes" 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 # responses, not using them for Home Assistant states NODE_FILTERS = { BINARY_SENSOR: { - "uom": [], - "states": [], - "node_def_id": [ + FILTER_UOM: [], + FILTER_STATES: [], + FILTER_NODE_DEF_ID: [ "BinaryAlarm", "BinaryAlarm_ADV", "BinaryControl", @@ -120,16 +156,17 @@ NODE_FILTERS = { "OnOffControl", "OnOffControl_ADV", ], - "insteon_type": [ + FILTER_INSTEON_TYPE: [ "7.0.", "7.13.", - "16.", + TYPE_CATEGORY_SAFETY, ], # Does a startswith() match; include the dot + FILTER_ZWAVE_CAT: (["104", "112", "138"] + list(map(str, range(148, 180)))), }, SENSOR: { # This is just a more-readable way of including MOST uoms between 1-100 # (Remember that range() is non-inclusive of the stop value) - "uom": ( + FILTER_UOM: ( ["1"] + list(map(str, range(3, 11))) + list(map(str, range(12, 51))) @@ -138,32 +175,43 @@ NODE_FILTERS = { + ["79"] + list(map(str, range(82, 97))) ), - "states": [], - "node_def_id": ["IMETER_SOLO", "EZIO2x4_Input_ADV"], - "insteon_type": ["9.0.", "9.7."], + FILTER_STATES: [], + FILTER_NODE_DEF_ID: [ + "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: { - "uom": ["11"], - "states": ["locked", "unlocked"], - "node_def_id": ["DoorLock"], - "insteon_type": ["15.", "4.64."], + FILTER_UOM: ["11"], + FILTER_STATES: ["locked", "unlocked"], + FILTER_NODE_DEF_ID: ["DoorLock"], + FILTER_INSTEON_TYPE: [TYPE_CATEOGRY_LOCK, "4.64."], + FILTER_ZWAVE_CAT: ["111"], }, FAN: { - "uom": [], - "states": ["off", "low", "med", "high"], - "node_def_id": ["FanLincMotor"], - "insteon_type": ["1.46."], + FILTER_UOM: [], + FILTER_STATES: ["off", "low", "med", "high"], + FILTER_NODE_DEF_ID: ["FanLincMotor"], + FILTER_INSTEON_TYPE: ["1.46."], + FILTER_ZWAVE_CAT: [], }, COVER: { - "uom": ["97"], - "states": ["open", "closed", "closing", "opening", "stopped"], - "node_def_id": [], - "insteon_type": [], + FILTER_UOM: ["97"], + FILTER_STATES: ["open", "closed", "closing", "opening", "stopped"], + FILTER_NODE_DEF_ID: [], + FILTER_INSTEON_TYPE: [], + FILTER_ZWAVE_CAT: [], }, LIGHT: { - "uom": ["51"], - "states": ["on", "off", "%"], - "node_def_id": [ + FILTER_UOM: ["51"], + FILTER_STATES: ["on", "off", "%"], + FILTER_NODE_DEF_ID: [ "BallastRelayLampSwitch", "BallastRelayLampSwitch_ADV", "DimmerLampOnly", @@ -174,19 +222,18 @@ NODE_FILTERS = { "KeypadDimmer", "KeypadDimmer_ADV", ], - "insteon_type": ["1."], + FILTER_INSTEON_TYPE: [TYPE_CATEGORY_DIMMABLE], + FILTER_ZWAVE_CAT: ["109", "119"], }, SWITCH: { - "uom": ["2", "78"], - "states": ["on", "off"], - "node_def_id": [ + FILTER_UOM: ["2", "78"], + FILTER_STATES: ["on", "off"], + FILTER_NODE_DEF_ID: [ "AlertModuleArmed", "AlertModuleSiren", "AlertModuleSiren_ADV", "EZIO2x4_Output", "EZRAIN_Output", - "KeypadButton", - "KeypadButton_ADV", "KeypadRelay", "KeypadRelay_ADV", "RelayLampOnly", @@ -195,13 +242,18 @@ NODE_FILTERS = { "RelayLampSwitch_ADV", "RelaySwitchOnlyPlusQuery", "RelaySwitchOnlyPlusQuery_ADV", - "RemoteLinc2", - "RemoteLinc2_ADV", "Siren", "Siren_ADV", "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 = { - "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.", "16.22."], - "climate": ["5.11.", "5.10."], +BINARY_SENSOR_DEVICE_TYPES_ISY = { + DEVICE_CLASS_MOISTURE: ["16.8.", "16.13.", "16.14."], + DEVICE_CLASS_OPENING: [ + "16.9.", + "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"], } diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index f6d85c033fe..4201a038fbc 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -1,7 +1,13 @@ """Sorting helpers for ISY994 device classifications.""" 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.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.light import DOMAIN as LIGHT from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.helpers.typing import HomeAssistantType from .const import ( _LOGGER, + DEFAULT_PROGRAM_STRING, + FILTER_INSTEON_TYPE, + FILTER_NODE_DEF_ID, + FILTER_STATES, + FILTER_UOM, + FILTER_ZWAVE_CAT, ISY994_NODES, ISY994_PROGRAMS, ISY_GROUP_PLATFORM, @@ -21,8 +34,18 @@ from .const import ( NODE_FILTERS, SUPPORTED_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( 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] 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) return True - _LOGGER.warning("Unsupported node: %s, type: %s", node.name, node.type) return False @@ -69,17 +91,69 @@ def _check_for_insteon_type( if any( [ 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 - # as one of its nodes. Note that this special-case is not necessary + # Hacky special-cases for certain devices with different platforms + # included as subnodes. Note that special-cases are not necessary # 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) 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) 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 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) 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 node_uom.intersection(uom_list): + if node_uom in uom_list: hass.data[ISY994_NODES][single_platform].append(node) return True - else: - platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform] - for platform in platforms: - if node_uom.intersection(NODE_FILTERS[platform]["uom"]): - hass.data[ISY994_NODES][platform].append(node) - return True + return False + + platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform] + for platform in platforms: + if node_uom in NODE_FILTERS[platform][FILTER_UOM]: + hass.data[ISY994_NODES][platform].append(node) + return True return False @@ -129,27 +207,34 @@ def _check_for_states_in_uom( possible "human readable" states. This filter passes if all of the possible 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) 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)) if states_list: if node_uom == set(states_list): hass.data[ISY994_NODES][single_platform].append(node) return True - else: - platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform] - for platform in platforms: - if node_uom == set(NODE_FILTERS[platform]["states"]): - hass.data[ISY994_NODES][platform].append(node) - return True + return False + + platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform] + for platform in platforms: + if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]): + hass.data[ISY994_NODES][platform].append(node) + return True 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.""" if _check_for_node_def(hass, node, single_platform=BINARY_SENSOR): 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 # sensor device. 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 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 @@ -205,16 +290,21 @@ def _categorize_nodes( continue if _check_for_insteon_type(hass, node): continue + if _check_for_zwave_cat(hass, node): + continue if _check_for_uom_id(hass, node): continue if _check_for_states_in_uom(hass, node): 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: """Categorize the ISY994 programs.""" 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: continue