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 <nick@koston.org>

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
shbatm 2020-05-07 23:15:42 -05:00 committed by GitHub
parent 7ac547a6e0
commit 4ec88b41dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 268 additions and 245 deletions

View file

@ -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

View file

@ -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):

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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")

View file

@ -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):
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)

View file

@ -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."""

View file

@ -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")

View file

@ -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"]
}

View file

@ -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,28 +31,30 @@ 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]:
uom = self._node.uom
# Backwards compatibility for ISYv4 Firmware:
if isinstance(uom, list):
uom = uom[0]
if not uom:
return STATE_UNKNOWN
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:]
@ -62,13 +63,9 @@ class ISYSensorEntity(ISYNodeEntity):
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 str(val)
else:
return val
return self.value
return None
@property
def unit_of_measurement(self) -> str:
"""Get the unit of measurement for the ISY994 sensor device."""
@ -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

View file

@ -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,7 +18,6 @@ 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))
for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]:
@ -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

View file

@ -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