From 4ec88b41dcb18caeebf9eb3eb60e5874ec815776 Mon Sep 17 00:00:00 2001 From: shbatm Date: Thu, 7 May 2020 23:15:42 -0500 Subject: [PATCH] Migrate ISY994 to PyISY v2 (#35338) * Remove unnecessary pylint exceptions * Move up some change to binary_sensors and switch. Fix program d.s.a's. * ISY994 Basic support for PyISYv2 - Bare minimum changes to be able to support PyISYv2. - Renaming imports and functions to new names. - Use necessary constants from module. - **BREAKING CHANGE** Remove ISY Climate Module support. - Climate module was retired on 3/30/2020: [UDI Annoucement](https://www.universal-devices.com/byebyeclimatemodule/) - **BREAKING CHANGE** Device State Attributes use NodeProperty - Some attributes names and types will have changed as part of the changes to PyISY. If a user relied on a device state attribute for a given entity, they should check that it is still there and formatted the same. In general, *more* state attributes should be getting picked up now that the underlying changes have been made. - **BREAKING CHANGE** `isy994_control` event changes (using NodeProperty) - Control events now return an object with additional information. Control events are now parsed to the friendly names and will need to be updated in automations. Remove cast * PyISY v2.0.2, add extra UOMs, omit EMPTY_TIME attributes * Fix typo in function doc string. Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/isy994/__init__.py | 15 +-- .../components/isy994/binary_sensor.py | 42 ++++--- homeassistant/components/isy994/const.py | 39 +++++-- homeassistant/components/isy994/cover.py | 23 ++-- homeassistant/components/isy994/entity.py | 102 ++++++++++++----- homeassistant/components/isy994/fan.py | 14 +-- homeassistant/components/isy994/helpers.py | 88 ++++++++------- homeassistant/components/isy994/light.py | 23 ++-- homeassistant/components/isy994/lock.py | 27 ++--- homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/sensor.py | 103 ++++++------------ homeassistant/components/isy994/switch.py | 29 +++-- requirements_all.txt | 6 +- 13 files changed, 268 insertions(+), 245 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 8076debc2d1..e0fcab2fe20 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -1,7 +1,7 @@ """Support the ISY-994 controllers.""" from urllib.parse import urlparse -import PyISY +from pyisy import ISY import voluptuous as vol from homeassistant.const import ( @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, - CONF_ENABLE_CLIMATE, CONF_IGNORE_STRING, CONF_SENSOR_STRING, CONF_TLS_VER, @@ -25,11 +24,10 @@ from .const import ( DOMAIN, ISY994_NODES, ISY994_PROGRAMS, - ISY994_WEATHER, SUPPORTED_PLATFORMS, SUPPORTED_PROGRAM_PLATFORMS, ) -from .helpers import _categorize_nodes, _categorize_programs, _categorize_weather +from .helpers import _categorize_nodes, _categorize_programs CONFIG_SCHEMA = vol.Schema( { @@ -45,7 +43,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING ): cv.string, - vol.Optional(CONF_ENABLE_CLIMATE, default=True): cv.boolean, } ) }, @@ -59,8 +56,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: for platform in SUPPORTED_PLATFORMS: hass.data[ISY994_NODES][platform] = [] - hass.data[ISY994_WEATHER] = [] - hass.data[ISY994_PROGRAMS] = {} for platform in SUPPORTED_PROGRAM_PLATFORMS: hass.data[ISY994_PROGRAMS][platform] = [] @@ -73,7 +68,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: host = urlparse(isy_config.get(CONF_HOST)) ignore_identifier = isy_config.get(CONF_IGNORE_STRING) sensor_identifier = isy_config.get(CONF_SENSOR_STRING) - enable_climate = isy_config.get(CONF_ENABLE_CLIMATE) if host.scheme == "http": https = False @@ -86,7 +80,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return False # Connect to ISY controller. - isy = PyISY.ISY( + isy = ISY( host.hostname, port, username=user, @@ -101,9 +95,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(hass, isy.programs) - if enable_climate and isy.configuration.get("Weather Information"): - _categorize_weather(hass, isy.climate) - def stop(event: object) -> None: """Stop ISY auto updates.""" isy.auto_update = False diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index a7c9d1b7943..7c8fcdf8a46 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta from typing import Callable +from pyisy.constants import ISY_VALUE_UNKNOWN + from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR, BinarySensorEntity, @@ -22,14 +24,14 @@ def setup_platform( ): """Set up the ISY994 binary sensor platform.""" devices = [] - devices_by_nid = {} + devices_by_address = {} 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_nid[node.nid] = device + devices_by_address[node.address] = device else: # We'll process the child nodes last, to ensure all parent nodes # have been processed @@ -37,17 +39,17 @@ def setup_platform( for node in child_nodes: try: - parent_device = devices_by_nid[node.parent_node.nid] + 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.nid, - node.parent_nid, + node.address, + node.primary_node, ) else: device_type = _detect_device_type(node) - subnode_id = int(node.nid[-1], 16) + 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 @@ -86,11 +88,6 @@ def _detect_device_type(node) -> str: return None -def _is_val_unknown(val): - """Determine if a number value represents UNKNOWN from PyISY.""" - return val == -1 * float("inf") - - class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): """Representation of an ISY994 binary sensor device. @@ -106,21 +103,21 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): self._negative_node = None self._heartbeat_device = None self._device_class_from_type = _detect_device_type(self._node) - if _is_val_unknown(self._node.status._val): + if self._node.status == ISY_VALUE_UNKNOWN: self._computed_state = None self._status_was_unknown = True else: - self._computed_state = bool(self._node.status._val) + self._computed_state = bool(self._node.status) self._status_was_unknown = False async def async_added_to_hass(self) -> None: """Subscribe to the node and subnode event emitters.""" await super().async_added_to_hass() - self._node.controlEvents.subscribe(self._positive_node_control_handler) + self._node.control_events.subscribe(self._positive_node_control_handler) if self._negative_node is not None: - self._negative_node.controlEvents.subscribe( + self._negative_node.control_events.subscribe( self._negative_node_control_handler ) @@ -146,20 +143,19 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): """ self._negative_node = child - # pylint: disable=protected-access - if not _is_val_unknown(self._negative_node.status._val): + if self._negative_node.status != ISY_VALUE_UNKNOWN: # If the negative node has a value, it means the negative node is # in use for this device. Next we need to check to see if the # negative and positive nodes disagree on the state (both ON or # both OFF). - if self._negative_node.status._val == self._node.status._val: + if self._negative_node.status == self._node.status: # The states disagree, therefore we cannot determine the state # of the sensor until we receive our first ON event. self._computed_state = None def _negative_node_control_handler(self, event: object) -> None: """Handle an "On" control event from the "negative" node.""" - if event == "DON": + if event.control == "DON": _LOGGER.debug( "Sensor %s turning Off via the Negative node sending a DON command", self.name, @@ -175,7 +171,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): will come to this node, with the negative node representing Off events """ - if event == "DON": + if event.control == "DON": _LOGGER.debug( "Sensor %s turning On via the Primary node sending a DON command", self.name, @@ -183,7 +179,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): self._computed_state = True self.schedule_update_ha_state() self._heartbeat() - if event == "DOF": + if event.control == "DOF": _LOGGER.debug( "Sensor %s turning Off via the Primary node sending a DOF command", self.name, @@ -263,14 +259,14 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): """Subscribe to the node and subnode event emitters.""" await super().async_added_to_hass() - self._node.controlEvents.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 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": + if event.control == "DON": self.heartbeat() def heartbeat(self): diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index ee50a4ef0df..b26041fd4b0 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -37,6 +37,7 @@ from homeassistant.const import ( LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_METERS, + LENGTH_MILES, MASS_KILOGRAMS, MASS_POUNDS, POWER_WATT, @@ -81,7 +82,6 @@ MANUFACTURER = "Universal Devices, Inc" CONF_IGNORE_STRING = "ignore_string" CONF_SENSOR_STRING = "sensor_string" -CONF_ENABLE_CLIMATE = "enable_climate" CONF_TLS_VER = "tls" DEFAULT_IGNORE_STRING = "{IGNORE ME}" @@ -89,8 +89,6 @@ DEFAULT_SENSOR_STRING = "sensor" DEFAULT_TLS_VERSION = 1.1 KEY_ACTIONS = "actions" -KEY_FOLDER = "folder" -KEY_MY_PROGRAMS = "My Programs" KEY_STATUS = "status" SUPPORTED_PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH] @@ -104,7 +102,6 @@ ISY_GROUP_PLATFORM = SWITCH ISY994_ISY = "isy" ISY994_NODES = "isy994_nodes" -ISY994_WEATHER = "isy994_weather" ISY994_PROGRAMS = "isy994_programs" # Do not use the Home Assistant consts for the states here - we're matching exact API @@ -288,12 +285,26 @@ UOM_FRIENDLY_NAME = { "90": FREQUENCY_HERTZ, "91": DEGREE, "92": f"{DEGREE} South", + "100": "", # Range 0-255, no unit. "101": f"{DEGREE} (x2)", "102": "kWs", "103": "$", "104": "ยข", "105": LENGTH_INCHES, - "106": "mm/day", + "106": f"mm/{TIME_DAYS}", + "107": "", # raw 1-byte unsigned value + "108": "", # raw 2-byte unsigned value + "109": "", # raw 3-byte unsigned value + "110": "", # raw 4-byte unsigned value + "111": "", # raw 1-byte signed value + "112": "", # raw 2-byte signed value + "113": "", # raw 3-byte signed value + "114": "", # raw 4-byte signed value + "116": LENGTH_MILES, + "117": "mb", + "118": "hPa", + "119": f"{POWER_WATT}{TIME_HOURS}", + "120": f"{LENGTH_INCHES}/{TIME_DAYS}", } UOM_TO_STATES = { @@ -466,6 +477,21 @@ UOM_TO_STATES = { 7: HVAC_MODE_AUTO, # Program Cool-Set @ Local Device Only }, "99": {7: FAN_ON, 8: FAN_AUTO}, # Insteon Thermostat Fan Mode + "115": { # Most recent On style action taken for lamp control + 0: "on", + 1: "off", + 2: "fade up", + 3: "fade down", + 4: "fade stop", + 5: "fast on", + 6: "fast off", + 7: "triple press on", + 8: "triple press off", + 9: "4x press on", + 10: "4x press off", + 11: "5x press on", + 12: "5x press off", + }, } ISY_BIN_SENS_DEVICE_TYPES = { @@ -474,6 +500,3 @@ ISY_BIN_SENS_DEVICE_TYPES = { "motion": ["16.1.", "16.4.", "16.5.", "16.3.", "16.22."], "climate": ["5.11.", "5.10."], } - -# TEMPORARY CONSTANTS -- REMOVE AFTER PyISYv2 IS AVAILABLE -ISY_VALUE_UNKNOWN = -1 * float("inf") diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 1661bfe37ab..567838c570f 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -1,8 +1,10 @@ """Support for ISY994 covers.""" from typing import Callable +from pyisy.constants import ISY_VALUE_UNKNOWN + from homeassistant.components.cover import DOMAIN as COVER, CoverEntity -from homeassistant.const import STATE_CLOSED, STATE_OPEN +from homeassistant.const import STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN from homeassistant.helpers.typing import ConfigType from . import ISY994_NODES, ISY994_PROGRAMS @@ -30,8 +32,8 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity): @property def current_cover_position(self) -> int: """Return the current cover position.""" - if self.is_unknown() or self.value is None: - return None + if self.value in [None, ISY_VALUE_UNKNOWN]: + return STATE_UNKNOWN return sorted((0, self.value, 100))[1] @property @@ -42,19 +44,18 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - if self.is_unknown(): - return None - # TEMPORARY: Cast value to int until PyISYv2. - return UOM_TO_STATES["97"].get(int(self.value), STATE_OPEN) + if self.value == ISY_VALUE_UNKNOWN: + return STATE_UNKNOWN + return UOM_TO_STATES["97"].get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" - if not self._node.on(val=100): + if not self._node.turn_on(val=100): _LOGGER.error("Unable to open the cover") def close_cover(self, **kwargs) -> None: """Send the close cover command to the ISY994 cover device.""" - if not self._node.off(): + if not self._node.turn_off(): _LOGGER.error("Unable to close the cover") @@ -68,10 +69,10 @@ class ISYCoverProgramEntity(ISYProgramEntity, CoverEntity): def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover program.""" - if not self._actions.runThen(): + if not self._actions.run_then(): _LOGGER.error("Unable to open the cover") def close_cover(self, **kwargs) -> None: """Send the close cover command to the ISY994 cover program.""" - if not self._actions.runElse(): + if not self._actions.run_else(): _LOGGER.error("Unable to close the cover") diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index f592de6c9b2..c9dc3bbb56d 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -1,5 +1,14 @@ """Representation of ISYEntity Types.""" +from pyisy.constants import ( + COMMAND_FRIENDLY_NAME, + EMPTY_TIME, + EVENT_PROPS_IGNORED, + ISY_VALUE_UNKNOWN, +) +from pyisy.helpers import NodeProperty + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import Dict @@ -7,39 +16,48 @@ from homeassistant.helpers.typing import Dict class ISYEntity(Entity): """Representation of an ISY994 device.""" - _attrs = {} _name: str = None def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._attrs = {} self._change_handler = None self._control_handler = None async def async_added_to_hass(self) -> None: """Subscribe to the node change events.""" - self._change_handler = self._node.status.subscribe("changed", self.on_update) + self._change_handler = self._node.status_events.subscribe(self.on_update) - if hasattr(self._node, "controlEvents"): - self._control_handler = self._node.controlEvents.subscribe(self.on_control) + if hasattr(self._node, "control_events"): + self._control_handler = self._node.control_events.subscribe(self.on_control) 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: + def on_control(self, event: NodeProperty) -> None: """Handle a control event from the ISY994 Node.""" - self.hass.bus.fire( - "isy994_control", {"entity_id": self.entity_id, "control": event} - ) + event_data = { + "entity_id": self.entity_id, + "control": event.control, + "value": event.value, + "formatted": event.formatted, + "uom": event.uom, + "precision": event.prec, + } + + if event.value is None or event.control not in EVENT_PROPS_IGNORED: + # New state attributes may be available, update the state. + self.schedule_update_ha_state() + + self.hass.bus.fire("isy994_control", event_data) @property def unique_id(self) -> str: """Get the unique identifier of the device.""" - # pylint: disable=protected-access - if hasattr(self._node, "_id"): - return self._node._id - + if hasattr(self._node, "address"): + return self._node.address return None @property @@ -55,21 +73,13 @@ class ISYEntity(Entity): @property def value(self) -> int: """Get the current value of the device.""" - # pylint: disable=protected-access - return self._node.status._val - - def is_unknown(self) -> bool: - """Get whether or not the value of this Entity's node is unknown. - - PyISY reports unknown values as -inf - """ - return self.value == -1 * float("inf") + return self._node.status @property def state(self): """Return the state of the ISY device.""" - if self.is_unknown(): - return None + if self.value == ISY_VALUE_UNKNOWN: + return STATE_UNKNOWN return super().state @@ -78,12 +88,25 @@ class ISYNodeEntity(ISYEntity): @property def device_state_attributes(self) -> Dict: - """Get the state attributes for the device.""" + """Get the state attributes for the device. + + The 'aux_properties' in the pyisy Node class are combined with the + other attributes which have been picked up from the event stream and + the combined result are returned as the device state attributes. + """ attr = {} if hasattr(self._node, "aux_properties"): - for name, val in self._node.aux_properties.items(): - attr[name] = f"{val.get('value')} {val.get('uom')}" - return attr + # Cast as list due to RuntimeError if a new property is added while running. + for name, value in list(self._node.aux_properties.items()): + attr_name = COMMAND_FRIENDLY_NAME.get(name, name) + attr[attr_name] = str(value.formatted).lower() + + # If a Group/Scene, set a property if the entire scene is on/off + if hasattr(self._node, "group_all_on"): + attr["group_all_on"] = STATE_ON if self._node.group_all_on else STATE_OFF + + self._attrs.update(attr) + return self._attrs class ISYProgramEntity(ISYEntity): @@ -94,3 +117,28 @@ class ISYProgramEntity(ISYEntity): super().__init__(status) self._name = name self._actions = actions + + @property + def device_state_attributes(self) -> Dict: + """Get the state attributes for the device.""" + attr = {} + if self._actions: + attr["actions_enabled"] = self._actions.enabled + if self._actions.last_finished != EMPTY_TIME: + attr["actions_last_finished"] = self._actions.last_finished + if self._actions.last_run != EMPTY_TIME: + attr["actions_last_run"] = self._actions.last_run + if self._actions.last_update != EMPTY_TIME: + attr["actions_last_update"] = self._actions.last_update + attr["ran_else"] = self._actions.ran_else + attr["ran_then"] = self._actions.ran_then + attr["run_at_startup"] = self._actions.run_at_startup + attr["running"] = self._actions.running + attr["status_enabled"] = self._node.enabled + if self._node.last_finished != EMPTY_TIME: + attr["status_last_finished"] = self._node.last_finished + if self._node.last_run != EMPTY_TIME: + attr["status_last_run"] = self._node.last_run + if self._node.last_update != EMPTY_TIME: + attr["status_last_update"] = self._node.last_update + return attr diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 2315610dcf8..013191093e4 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -60,7 +60,7 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): def set_speed(self, speed: str) -> None: """Send the set speed command to the ISY994 fan device.""" - self._node.on(val=STATE_TO_VALUE.get(speed, 255)) + self._node.turn_on(val=STATE_TO_VALUE.get(speed, 255)) def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" @@ -68,7 +68,7 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 fan device.""" - self._node.off() + self._node.turn_off() @property def speed_list(self) -> list: @@ -87,21 +87,19 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity): @property def speed(self) -> str: """Return the current speed.""" - # TEMPORARY: Cast value to int until PyISYv2. - return VALUE_TO_STATE.get(int(self.value)) + return VALUE_TO_STATE.get(self.value) @property def is_on(self) -> bool: """Get if the fan is on.""" - # TEMPORARY: Cast value to int until PyISYv2. - return int(self.value) != 0 + return self.value != 0 def turn_off(self, **kwargs) -> None: """Send the turn on command to ISY994 fan program.""" - if not self._actions.runThen(): + if not self._actions.run_then(): _LOGGER.error("Unable to turn off the fan") def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn off command to ISY994 fan program.""" - if not self._actions.runElse(): + if not self._actions.run_else(): _LOGGER.error("Unable to turn on the fan") diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 4f6bba6b659..f6d85c033fe 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -1,7 +1,9 @@ """Sorting helpers for ISY994 device classifications.""" -from collections import namedtuple +from typing import Union -from PyISY.Nodes import Group +from pyisy.constants import PROTO_GROUP, PROTO_INSTEON, PROTO_PROGRAM, TAG_FOLDER +from pyisy.nodes import Group, Node, Nodes +from pyisy.programs import Programs from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.fan import DOMAIN as FAN @@ -13,22 +15,17 @@ from .const import ( _LOGGER, ISY994_NODES, ISY994_PROGRAMS, - ISY994_WEATHER, ISY_GROUP_PLATFORM, KEY_ACTIONS, - KEY_FOLDER, - KEY_MY_PROGRAMS, KEY_STATUS, NODE_FILTERS, SUPPORTED_PLATFORMS, SUPPORTED_PROGRAM_PLATFORMS, ) -WeatherNode = namedtuple("WeatherNode", ("status", "name", "uom")) - def _check_for_node_def( - hass: HomeAssistantType, node, single_platform: str = None + hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None ) -> bool: """Check if the node matches the node_def_id for any platforms. @@ -52,7 +49,7 @@ def _check_for_node_def( def _check_for_insteon_type( - hass: HomeAssistantType, node, single_platform: str = None + hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None ) -> bool: """Check if the node matches the Insteon type for any platforms. @@ -60,6 +57,8 @@ def _check_for_insteon_type( works for Insteon device. "Node Server" (v5+) and Z-Wave and others will not have a type. """ + if not hasattr(node, "protocol") or node.protocol != PROTO_INSTEON: + return False if not hasattr(node, "type") or node.type is None: # Node doesn't have a type (non-Insteon device most likely) return False @@ -77,7 +76,7 @@ def _check_for_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 # on ISY 5.x firmware as it uses the superior NodeDefs method - if platform == FAN and int(node.nid[-1]) == 1: + if platform == FAN and int(node.address[-1]) == 1: hass.data[ISY994_NODES][LIGHT].append(node) return True @@ -88,7 +87,10 @@ def _check_for_insteon_type( def _check_for_uom_id( - hass: HomeAssistantType, node, single_platform: str = None, uom_list: list = None + hass: HomeAssistantType, + node: Union[Group, Node], + single_platform: str = None, + uom_list: list = None, ) -> bool: """Check if a node's uom matches any of the platforms uom filter. @@ -116,7 +118,10 @@ def _check_for_uom_id( def _check_for_states_in_uom( - hass: HomeAssistantType, node, single_platform: str = None, states_list: list = None + hass: HomeAssistantType, + node: Union[Group, Node], + single_platform: str = None, + states_list: list = None, ) -> bool: """Check if a list of uoms matches two possible filters. @@ -168,7 +173,10 @@ def _is_sensor_a_binary_sensor(hass: HomeAssistantType, node) -> bool: def _categorize_nodes( - hass: HomeAssistantType, nodes, ignore_identifier: str, sensor_identifier: str + hass: HomeAssistantType, + nodes: Nodes, + ignore_identifier: str, + sensor_identifier: str, ) -> None: """Sort the nodes to their proper platforms.""" for (path, node) in nodes: @@ -177,7 +185,7 @@ def _categorize_nodes( # Don't import this node as a device at all continue - if isinstance(node, Group): + if hasattr(node, "protocol") and node.protocol == PROTO_GROUP: hass.data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node) continue @@ -203,47 +211,37 @@ def _categorize_nodes( continue -def _categorize_programs(hass: HomeAssistantType, programs: dict) -> None: +def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None: """Categorize the ISY994 programs.""" for platform in SUPPORTED_PROGRAM_PLATFORMS: - try: - folder = programs[KEY_MY_PROGRAMS][f"HA.{platform}"] - except KeyError: + folder = programs.get_by_name(f"HA.{platform}") + if not folder: continue + for dtype, _, node_id in folder.children: - if dtype != KEY_FOLDER: + if dtype != TAG_FOLDER: continue entity_folder = folder[node_id] - try: - status = entity_folder[KEY_STATUS] - assert status.dtype == "program", "Not a program" - if platform != BINARY_SENSOR: - actions = entity_folder[KEY_ACTIONS] - assert actions.dtype == "program", "Not a program" - else: - actions = None - except (AttributeError, KeyError, AssertionError): + + actions = None + status = entity_folder.get_by_name(KEY_STATUS) + if not status or not status.protocol == PROTO_PROGRAM: _LOGGER.warning( - "Program entity '%s' not loaded due " - "to invalid folder structure.", + "Program %s entity '%s' not loaded, invalid/missing status program.", + platform, entity_folder.name, ) continue + if platform != BINARY_SENSOR: + actions = entity_folder.get_by_name(KEY_ACTIONS) + if not actions or not actions.protocol == PROTO_PROGRAM: + _LOGGER.warning( + "Program %s entity '%s' not loaded, invalid/missing actions program.", + platform, + entity_folder.name, + ) + continue + entity = (entity_folder.name, status, actions) hass.data[ISY994_PROGRAMS][platform].append(entity) - - -def _categorize_weather(hass: HomeAssistantType, climate) -> None: - """Categorize the ISY994 weather data.""" - climate_attrs = dir(climate) - weather_nodes = [ - WeatherNode( - getattr(climate, attr), - attr.replace("_", " "), - getattr(climate, f"{attr}_units"), - ) - for attr in climate_attrs - if f"{attr}_units" in climate_attrs - ] - hass.data[ISY994_WEATHER].extend(weather_nodes) diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index e5f35bc62fb..257ecd853f8 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -1,11 +1,14 @@ """Support for ISY994 lights.""" -from typing import Callable +from typing import Callable, Dict + +from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.light import ( DOMAIN as LIGHT, SUPPORT_BRIGHTNESS, LightEntity, ) +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -38,24 +41,24 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): @property def is_on(self) -> bool: """Get whether the ISY994 light is on.""" - if self.is_unknown(): + if self.value == ISY_VALUE_UNKNOWN: return False - return self.value != 0 + return int(self.value) != 0 @property def brightness(self) -> float: """Get the brightness of the ISY994 light.""" - return None if self.is_unknown() else self.value + return STATE_UNKNOWN if self.value == ISY_VALUE_UNKNOWN else int(self.value) def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 light device.""" self._last_brightness = self.brightness - if not self._node.off(): + if not self._node.turn_off(): _LOGGER.debug("Unable to turn off light") def on_update(self, event: object) -> None: """Save brightness in the update event from the ISY994 Node.""" - if not self.is_unknown() and self.value != 0: + if self.value not in (0, ISY_VALUE_UNKNOWN): self._last_brightness = self.value super().on_update(event) @@ -64,7 +67,7 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): """Send the turn on command to the ISY994 light device.""" if brightness is None and self._last_brightness: brightness = self._last_brightness - if not self._node.on(val=brightness): + if not self._node.turn_on(val=brightness): _LOGGER.debug("Unable to turn on light") @property @@ -73,9 +76,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): return SUPPORT_BRIGHTNESS @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict: """Return the light attributes.""" - return {ATTR_LAST_BRIGHTNESS: self._last_brightness} + attribs = super().device_state_attributes + attribs[ATTR_LAST_BRIGHTNESS] = self._last_brightness + return attribs async def async_added_to_hass(self) -> None: """Restore last_brightness on restart.""" diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index b1e94cadac8..22cd5e08e44 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -1,6 +1,8 @@ """Support for ISY994 locks.""" from typing import Callable +from pyisy.constants import ISY_VALUE_UNKNOWN + from homeassistant.components.lock import DOMAIN as LOCK, LockEntity from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED from homeassistant.helpers.typing import ConfigType @@ -29,11 +31,6 @@ def setup_platform( class ISYLockEntity(ISYNodeEntity, LockEntity): """Representation of an ISY994 lock device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 lock device.""" - super().__init__(node) - self._conn = node.parent.parent.conn - @property def is_locked(self) -> bool: """Get whether the lock is in locked state.""" @@ -42,28 +39,20 @@ class ISYLockEntity(ISYNodeEntity, LockEntity): @property def state(self) -> str: """Get the state of the lock.""" - if self.is_unknown(): - return None + if self.value == ISY_VALUE_UNKNOWN: + return STATE_UNKNOWN return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" - # Hack until PyISY is updated - req_url = self._conn.compileURL(["nodes", self.unique_id, "cmd", "SECMD", "1"]) - response = self._conn.request(req_url) - - if response is None: + if not self._node.secure_lock(): _LOGGER.error("Unable to lock device") self._node.update(0.5) def unlock(self, **kwargs) -> None: """Send the unlock command to the ISY994 device.""" - # Hack until PyISY is updated - req_url = self._conn.compileURL(["nodes", self.unique_id, "cmd", "SECMD", "0"]) - response = self._conn.request(req_url) - - if response is None: + if not self._node.secure_unlock(): _LOGGER.error("Unable to lock device") self._node.update(0.5) @@ -84,10 +73,10 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity): def lock(self, **kwargs) -> None: """Lock the device.""" - if not self._actions.runThen(): + if not self._actions.run_then(): _LOGGER.error("Unable to lock device") def unlock(self, **kwargs) -> None: """Unlock the device.""" - if not self._actions.runElse(): + if not self._actions.run_else(): _LOGGER.error("Unable to unlock device") diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 88de7db824c..e132b7043e6 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -2,6 +2,6 @@ "domain": "isy994", "name": "Universal Devices ISY994", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["PyISY==1.1.2"], + "requirements": ["pyisy==2.0.2"], "codeowners": ["@bdraco", "@shbatm"] } diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index bf787582910..b9144c8bcd1 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -1,13 +1,15 @@ """Support for ISY994 sensors.""" from typing import Callable +from pyisy.constants import ISY_VALUE_UNKNOWN + from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.helpers.typing import ConfigType -from . import ISY994_NODES, ISY994_WEATHER +from . import ISY994_NODES from .const import _LOGGER, UOM_FRIENDLY_NAME, UOM_TO_STATES -from .entity import ISYEntity, ISYNodeEntity +from .entity import ISYNodeEntity def setup_platform( @@ -20,9 +22,6 @@ def setup_platform( _LOGGER.debug("Loading %s", node.name) devices.append(ISYSensorEntity(node)) - for node in hass.data[ISY994_WEATHER]: - devices.append(ISYWeatherDevice(node)) - add_entities(devices) @@ -32,42 +31,40 @@ class ISYSensorEntity(ISYNodeEntity): @property def raw_unit_of_measurement(self) -> str: """Get the raw unit of measurement for the ISY994 sensor device.""" - if len(self._node.uom) == 1: - if self._node.uom[0] in UOM_FRIENDLY_NAME: - friendly_name = UOM_FRIENDLY_NAME.get(self._node.uom[0]) - if friendly_name in (TEMP_CELSIUS, TEMP_FAHRENHEIT): - friendly_name = self.hass.config.units.temperature_unit - return friendly_name - return self._node.uom[0] - return None + uom = self._node.uom + + # Backwards compatibility for ISYv4 Firmware: + if isinstance(uom, list): + return UOM_FRIENDLY_NAME.get(uom[0], uom[0]) + return UOM_FRIENDLY_NAME.get(uom) @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" - if self.is_unknown(): - return None + if self.value == ISY_VALUE_UNKNOWN: + return STATE_UNKNOWN - if len(self._node.uom) == 1: - if self._node.uom[0] in UOM_TO_STATES: - states = UOM_TO_STATES.get(self._node.uom[0]) - # TEMPORARY: Cast value to int until PyISYv2. - if int(self.value) in states: - return states.get(int(self.value)) - elif self._node.prec and self._node.prec != [0]: - str_val = str(self.value) - int_prec = int(self._node.prec) - decimal_part = str_val[-int_prec:] - whole_part = str_val[: len(str_val) - int_prec] - val = float(f"{whole_part}.{decimal_part}") - raw_units = self.raw_unit_of_measurement - if raw_units in (TEMP_CELSIUS, TEMP_FAHRENHEIT): - val = self.hass.config.units.temperature(val, raw_units) + uom = self._node.uom + # Backwards compatibility for ISYv4 Firmware: + if isinstance(uom, list): + uom = uom[0] + if not uom: + return STATE_UNKNOWN - return str(val) - else: - return self.value - - return None + states = UOM_TO_STATES.get(uom) + if states and states.get(self.value): + return states.get(self.value) + if self._node.prec and int(self._node.prec) != 0: + str_val = str(self.value) + int_prec = int(self._node.prec) + decimal_part = str_val[-int_prec:] + whole_part = str_val[: len(str_val) - int_prec] + val = float(f"{whole_part}.{decimal_part}") + raw_units = self.raw_unit_of_measurement + if raw_units in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + val = self.hass.config.units.temperature(val, raw_units) + return val + return self.value @property def unit_of_measurement(self) -> str: @@ -76,37 +73,3 @@ class ISYSensorEntity(ISYNodeEntity): if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS): return self.hass.config.units.temperature_unit return raw_units - - -# Depreciated, not renaming. Will be removed in next PR. -class ISYWeatherDevice(ISYEntity): - """Representation of an ISY994 weather device.""" - - @property - def raw_units(self) -> str: - """Return the raw unit of measurement.""" - if self._node.uom == "F": - return TEMP_FAHRENHEIT - if self._node.uom == "C": - return TEMP_CELSIUS - return self._node.uom - - @property - def state(self) -> object: - """Return the value of the node.""" - # pylint: disable=protected-access - val = self._node.status._val - raw_units = self._node.uom - - if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]: - return self.hass.config.units.temperature(val, raw_units) - return val - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement for the node.""" - raw_units = self.raw_units - - if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]: - return self.hass.config.units.temperature_unit - return raw_units diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index e87ed846fd9..64f7c840054 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -1,7 +1,10 @@ """Support for ISY994 switches.""" from typing import Callable +from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP + from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.typing import ConfigType from . import ISY994_NODES, ISY994_PROGRAMS @@ -15,8 +18,7 @@ def setup_platform( """Set up the ISY994 switch platform.""" devices = [] for node in hass.data[ISY994_NODES][SWITCH]: - if not node.dimmable: - devices.append(ISYSwitchEntity(node)) + devices.append(ISYSwitchEntity(node)) for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]: devices.append(ISYSwitchProgramEntity(name, status, actions)) @@ -30,18 +32,27 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity): @property def is_on(self) -> bool: """Get whether the ISY994 device is in the on state.""" + if self.value == ISY_VALUE_UNKNOWN: + return STATE_UNKNOWN return bool(self.value) def turn_off(self, **kwargs) -> None: - """Send the turn on command to the ISY994 switch.""" - if not self._node.off(): - _LOGGER.debug("Unable to turn on switch.") + """Send the turn off command to the ISY994 switch.""" + if not self._node.turn_off(): + _LOGGER.debug("Unable to turn off switch.") def turn_on(self, **kwargs) -> None: - """Send the turn off command to the ISY994 switch.""" - if not self._node.on(): + """Send the turn on command to the ISY994 switch.""" + if not self._node.turn_on(): _LOGGER.debug("Unable to turn on switch.") + @property + def icon(self) -> str: + """Get the icon for groups.""" + if hasattr(self._node, "protocol") and self._node.protocol == PROTO_GROUP: + return "mdi:google-circles-communities" # Matches isy scene icon + return super().icon + class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): """A representation of an ISY994 program switch.""" @@ -53,12 +64,12 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): def turn_on(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch program.""" - if not self._actions.runThen(): + if not self._actions.run_then(): _LOGGER.error("Unable to turn on switch") def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 switch program.""" - if not self._actions.runElse(): + if not self._actions.run_else(): _LOGGER.error("Unable to turn off switch") @property diff --git a/requirements_all.txt b/requirements_all.txt index 806624a33c1..61ee2c27182 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -49,9 +49,6 @@ PyEssent==0.13 # homeassistant.components.github PyGithub==1.43.8 -# homeassistant.components.isy994 -PyISY==1.1.2 - # homeassistant.components.mvglive PyMVGLive==1.1.4 @@ -1380,6 +1377,9 @@ pyirishrail==0.0.2 # homeassistant.components.iss pyiss==1.0.1 +# homeassistant.components.isy994 +pyisy==2.0.2 + # homeassistant.components.itach pyitachip2ir==0.0.7