Update PyISY to v3.0.0 and ISY994 to use Async IO (#50806)

This commit is contained in:
shbatm 2021-05-18 14:15:47 -05:00 committed by GitHub
parent 1d174a1f6f
commit 775af9d2c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 325 additions and 248 deletions

View file

@ -1,16 +1,23 @@
"""Support the ISY-994 controllers."""
from __future__ import annotations
from functools import partial
from urllib.parse import urlparse
from pyisy import ISY
from aiohttp import CookieJar
import async_timeout
from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.typing import ConfigType
@ -32,7 +39,7 @@ from .const import (
ISY994_VARIABLES,
MANUFACTURER,
PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS,
PROGRAM_PLATFORMS,
UNDO_UPDATE_LISTENER,
)
from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables
@ -115,7 +122,7 @@ async def async_setup_entry(
hass_isy_data[ISY994_NODES][platform] = []
hass_isy_data[ISY994_PROGRAMS] = {}
for platform in SUPPORTED_PROGRAM_PLATFORMS:
for platform in PROGRAM_PLATFORMS:
hass_isy_data[ISY994_PROGRAMS][platform] = []
hass_isy_data[ISY994_VARIABLES] = []
@ -139,17 +146,19 @@ async def async_setup_entry(
if host.scheme == "http":
https = False
port = host.port or 80
session = aiohttp_client.async_create_clientsession(
hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True)
)
elif host.scheme == "https":
https = True
port = host.port or 443
session = aiohttp_client.async_get_clientsession(hass)
else:
_LOGGER.error("The isy994 host value in configuration is invalid")
return False
# Connect to ISY controller.
isy = await hass.async_add_executor_job(
partial(
ISY,
isy = ISY(
host.hostname,
port,
username=user,
@ -157,13 +166,30 @@ async def async_setup_entry(
use_https=https,
tls_ver=tls_version,
webroot=host.path,
websession=session,
use_websocket=True,
)
)
if not isy.connected:
return False
# Trigger a status update for all nodes, not done automatically in PyISY v2.x
await hass.async_add_executor_job(isy.nodes.update)
try:
with async_timeout.timeout(30):
await isy.initialize()
except ISYInvalidAuthError as err:
_LOGGER.error(
"Invalid credentials for the ISY, please adjust settings and try again: %s",
err,
)
return False
except ISYConnectionError as err:
_LOGGER.error(
"Failed to connect to the ISY, please adjust settings and try again: %s",
err,
)
raise ConfigEntryNotReady from err
except ISYResponseParseError as err:
_LOGGER.warning(
"Error processing responses from the ISY; device may be busy, trying again later"
)
raise ConfigEntryNotReady from err
_categorize_nodes(hass_isy_data, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(hass_isy_data, isy.programs)
@ -181,13 +207,21 @@ async def async_setup_entry(
def _start_auto_update() -> None:
"""Start isy auto update."""
_LOGGER.debug("ISY Starting Event Stream and automatic updates")
isy.auto_update = True
isy.websocket.start()
def _stop_auto_update(event) -> None:
"""Stop the isy auto update on Home Assistant Shutdown."""
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
isy.websocket.stop()
await hass.async_add_executor_job(_start_auto_update)
undo_listener = entry.add_update_listener(_async_update_listener)
hass_isy_data[UNDO_UPDATE_LISTENER] = undo_listener
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_auto_update)
)
# Register Integration-wide Services:
async_setup_services(hass)
@ -248,9 +282,9 @@ async def async_unload_entry(
isy = hass_isy_data[ISY994_ISY]
def _stop_auto_update() -> None:
"""Start isy auto update."""
"""Stop the isy auto update."""
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
isy.auto_update = False
isy.websocket.stop()
await hass.async_add_executor_job(_stop_auto_update)

View file

@ -251,11 +251,11 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
"""Subscribe to the node and subnode event emitters."""
await super().async_added_to_hass()
self._node.control_events.subscribe(self._positive_node_control_handler)
self._node.control_events.subscribe(self._async_positive_node_control_handler)
if self._negative_node is not None:
self._negative_node.control_events.subscribe(
self._negative_node_control_handler
self._async_negative_node_control_handler
)
def add_heartbeat_device(self, device) -> None:
@ -267,10 +267,10 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
"""
self._heartbeat_device = device
def _heartbeat(self) -> None:
def _async_heartbeat(self) -> None:
"""Send a heartbeat to our heartbeat device, if we have one."""
if self._heartbeat_device is not None:
self._heartbeat_device.heartbeat()
self._heartbeat_device.async_heartbeat()
def add_negative_node(self, child) -> None:
"""Add a negative node to this binary sensor device.
@ -292,7 +292,8 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
# of the sensor until we receive our first ON event.
self._computed_state = None
def _negative_node_control_handler(self, event: object) -> None:
@callback
def _async_negative_node_control_handler(self, event: object) -> None:
"""Handle an "On" control event from the "negative" node."""
if event.control == CMD_ON:
_LOGGER.debug(
@ -300,10 +301,11 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
self.name,
)
self._computed_state = False
self.schedule_update_ha_state()
self._heartbeat()
self.async_write_ha_state()
self._async_heartbeat()
def _positive_node_control_handler(self, event: object) -> None:
@callback
def _async_positive_node_control_handler(self, event: object) -> None:
"""Handle On and Off control event coming from the primary node.
Depending on device configuration, sometimes only On events
@ -316,18 +318,19 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
self.name,
)
self._computed_state = True
self.schedule_update_ha_state()
self._heartbeat()
self.async_write_ha_state()
self._async_heartbeat()
if event.control == CMD_OFF:
_LOGGER.debug(
"Sensor %s turning Off via the Primary node sending a DOF command",
self.name,
)
self._computed_state = False
self.schedule_update_ha_state()
self._heartbeat()
self.async_write_ha_state()
self._async_heartbeat()
def on_update(self, event: object) -> None:
@callback
def async_on_update(self, event: object) -> None:
"""Primary node status updates.
We MOSTLY ignore these updates, as we listen directly to the Control
@ -340,8 +343,8 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
if self._status_was_unknown and self._computed_state is None:
self._computed_state = bool(self._node.status)
self._status_was_unknown = False
self.schedule_update_ha_state()
self._heartbeat()
self.async_write_ha_state()
self._async_heartbeat()
@property
def is_on(self) -> bool:
@ -395,9 +398,10 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
The ISY uses both DON and DOF commands (alternating) for a heartbeat.
"""
if event.control in [CMD_ON, CMD_OFF]:
self.heartbeat()
self.async_heartbeat()
def heartbeat(self):
@callback
def async_heartbeat(self):
"""Mark the device as online, and restart the 25 hour timer.
This gets called when the heartbeat node beats, but also when the
@ -407,7 +411,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
"""
self._computed_state = False
self._restart_timer()
self.schedule_update_ha_state()
self.async_write_ha_state()
def _restart_timer(self):
"""Restart the 25 hour timer."""
@ -423,7 +427,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
"""Heartbeat missed; set state to ON to indicate dead battery."""
self._computed_state = True
self._heartbeat_timer = None
self.schedule_update_ha_state()
self.async_write_ha_state()
point_in_time = dt_util.utcnow() + timedelta(hours=25)
_LOGGER.debug(
@ -436,7 +440,8 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
self.hass, timer_elapsed, point_in_time
)
def on_update(self, event: object) -> None:
@callback
def async_on_update(self, event: object) -> None:
"""Ignore node status updates.
We listen directly to the Control events for this device.

View file

@ -203,7 +203,7 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
return None
return UOM_TO_STATES[UOM_FAN_MODES].get(fan_mode.value)
def set_temperature(self, **kwargs) -> None:
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
target_temp = kwargs.get(ATTR_TEMPERATURE)
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
@ -214,27 +214,27 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
if self.hvac_mode == HVAC_MODE_HEAT:
target_temp_low = target_temp
if target_temp_low is not None:
self._node.set_climate_setpoint_heat(int(target_temp_low))
await self._node.set_climate_setpoint_heat(int(target_temp_low))
# Presumptive setting--event stream will correct if cmd fails:
self._target_temp_low = target_temp_low
if target_temp_high is not None:
self._node.set_climate_setpoint_cool(int(target_temp_high))
await self._node.set_climate_setpoint_cool(int(target_temp_high))
# Presumptive setting--event stream will correct if cmd fails:
self._target_temp_high = target_temp_high
self.schedule_update_ha_state()
self.async_write_ha_state()
def set_fan_mode(self, fan_mode: str) -> None:
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
_LOGGER.debug("Requested fan mode %s", fan_mode)
self._node.set_fan_mode(HA_FAN_TO_ISY.get(fan_mode))
await self._node.set_fan_mode(HA_FAN_TO_ISY.get(fan_mode))
# Presumptive setting--event stream will correct if cmd fails:
self._fan_mode = fan_mode
self.schedule_update_ha_state()
self.async_write_ha_state()
def set_hvac_mode(self, hvac_mode: str) -> None:
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
_LOGGER.debug("Requested operation mode %s", hvac_mode)
self._node.set_climate_mode(HA_HVAC_TO_ISY.get(hvac_mode))
await self._node.set_climate_mode(HA_HVAC_TO_ISY.get(hvac_mode))
# Presumptive setting--event stream will correct if cmd fails:
self._hvac_mode = hvac_mode
self.schedule_update_ha_state()
self.async_write_ha_state()

View file

@ -2,6 +2,9 @@
import logging
from urllib.parse import urlparse
from aiohttp import CookieJar
import async_timeout
from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError
from pyisy.configuration import Configuration
from pyisy.connection import Connection
import voluptuous as vol
@ -11,6 +14,7 @@ from homeassistant.components import ssdp
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from .const import (
CONF_IGNORE_STRING,
@ -57,25 +61,41 @@ async def validate_input(hass: core.HomeAssistant, data):
if host.scheme == "http":
https = False
port = host.port or 80
session = aiohttp_client.async_create_clientsession(
hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True)
)
elif host.scheme == "https":
https = True
port = host.port or 443
session = aiohttp_client.async_get_clientsession(hass)
else:
_LOGGER.error("The isy994 host value in configuration is invalid")
raise InvalidHost
# Connect to ISY controller.
isy_conf = await hass.async_add_executor_job(
_fetch_isy_configuration,
isy_conn = Connection(
host.hostname,
port,
user,
password,
https,
tls_version,
host.path,
use_https=https,
tls_ver=tls_version,
webroot=host.path,
websession=session,
)
try:
with async_timeout.timeout(30):
isy_conf_xml = await isy_conn.test_connection()
except ISYInvalidAuthError as error:
raise InvalidAuth from error
except ISYConnectionError as error:
raise CannotConnect from error
try:
isy_conf = Configuration(xml=isy_conf_xml)
except ISYResponseParseError as error:
raise CannotConnect from error
if not isy_conf or "name" not in isy_conf or not isy_conf["name"]:
raise CannotConnect
@ -83,26 +103,6 @@ async def validate_input(hass: core.HomeAssistant, data):
return {"title": f"{isy_conf['name']} ({host.hostname})", "uuid": isy_conf["uuid"]}
def _fetch_isy_configuration(
address, port, username, password, use_https, tls_ver, webroot
):
"""Validate and fetch the configuration from the ISY."""
try:
isy_conn = Connection(
address,
port,
username,
password,
use_https,
tls_ver,
webroot=webroot,
)
except ValueError as err:
raise InvalidAuth(err.args[0]) from err
return Configuration(xml=isy_conn.get_config())
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Universal Devices ISY994."""

View file

@ -130,7 +130,7 @@ KEY_ACTIONS = "actions"
KEY_STATUS = "status"
PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH, CLIMATE]
SUPPORTED_PROGRAM_PLATFORMS = [BINARY_SENSOR, LOCK, FAN, COVER, SWITCH]
PROGRAM_PLATFORMS = [BINARY_SENSOR, LOCK, FAN, COVER, SWITCH]
SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"]
@ -184,6 +184,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener"
# Used for discovery
UDN_UUID_PREFIX = "uuid:"
ISY_URL_POSTFIX = "/desc"
EVENTS_SUFFIX = "_ISYSUB"
# Special Units of Measure
UOM_ISYV4_DEGREES = "degrees"
@ -352,7 +353,7 @@ UOM_FRIENDLY_NAME = {
"22": "%RH",
"23": PRESSURE_INHG,
"24": SPEED_INCHES_PER_HOUR,
UOM_INDEX: "index", # Index type. Use "node.formatted" for value
UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value
"26": TEMP_KELVIN,
"27": "keyword",
"28": MASS_KILOGRAMS,

View file

@ -1,4 +1,5 @@
"""Support for ISY994 covers."""
from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.cover import (
@ -67,23 +68,23 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity):
"""Flag supported features."""
return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
def open_cover(self, **kwargs) -> None:
async def async_open_cover(self, **kwargs) -> None:
"""Send the open cover command to the ISY994 cover device."""
val = 100 if self._node.uom == UOM_BARRIER else None
if not self._node.turn_on(val=val):
if not await self._node.turn_on(val=val):
_LOGGER.error("Unable to open the cover")
def close_cover(self, **kwargs) -> None:
async def async_close_cover(self, **kwargs) -> None:
"""Send the close cover command to the ISY994 cover device."""
if not self._node.turn_off():
if not await self._node.turn_off():
_LOGGER.error("Unable to close the cover")
def set_cover_position(self, **kwargs):
async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
position = kwargs[ATTR_POSITION]
if self._node.uom == UOM_8_BIT_RANGE:
position = round(position * 255.0 / 100.0)
if not self._node.turn_on(val=position):
if not await self._node.turn_on(val=position):
_LOGGER.error("Unable to set cover position")
@ -95,12 +96,12 @@ class ISYCoverProgramEntity(ISYProgramEntity, CoverEntity):
"""Get whether the ISY994 cover program is closed."""
return bool(self._node.status)
def open_cover(self, **kwargs) -> None:
async def async_open_cover(self, **kwargs) -> None:
"""Send the open cover command to the ISY994 cover program."""
if not self._actions.run_then():
if not await self._actions.run_then():
_LOGGER.error("Unable to open the cover")
def close_cover(self, **kwargs) -> None:
async def async_close_cover(self, **kwargs) -> None:
"""Send the close cover command to the ISY994 cover program."""
if not self._actions.run_else():
if not await self._actions.run_else():
_LOGGER.error("Unable to close the cover")

View file

@ -11,9 +11,11 @@ from pyisy.constants import (
from pyisy.helpers import NodeProperty
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity
from .const import _LOGGER, DOMAIN
from .const import DOMAIN
class ISYEntity(Entity):
@ -30,16 +32,20 @@ class ISYEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Subscribe to the node change events."""
self._change_handler = self._node.status_events.subscribe(self.on_update)
self._change_handler = self._node.status_events.subscribe(self.async_on_update)
if hasattr(self._node, "control_events"):
self._control_handler = self._node.control_events.subscribe(self.on_control)
self._control_handler = self._node.control_events.subscribe(
self.async_on_control
)
def on_update(self, event: object) -> None:
@callback
def async_on_update(self, event: object) -> None:
"""Handle the update event from the ISY994 Node."""
self.schedule_update_ha_state()
self.async_write_ha_state()
def on_control(self, event: NodeProperty) -> None:
@callback
def async_on_control(self, event: NodeProperty) -> None:
"""Handle a control event from the ISY994 Node."""
event_data = {
"entity_id": self.entity_id,
@ -52,7 +58,7 @@ class ISYEntity(Entity):
if event.control not in EVENT_PROPS_IGNORED:
# New state attributes may be available, update the state.
self.schedule_update_ha_state()
self.async_write_ha_state()
self.hass.bus.fire("isy994_control", event_data)
@ -99,9 +105,9 @@ class ISYEntity(Entity):
f"ProductTypeID:{node.zwave_props.prod_type_id} "
f"ProductID:{node.zwave_props.product_id}"
)
# Note: sw_version is not exposed by the ISY for the individual devices.
if hasattr(node, "folder") and node.folder is not None:
device_info["suggested_area"] = node.folder
# Note: sw_version is not exposed by the ISY for the individual devices.
return device_info
@ -155,25 +161,23 @@ class ISYNodeEntity(ISYEntity):
self._attrs.update(attr)
return self._attrs
def send_node_command(self, command):
async def async_send_node_command(self, command):
"""Respond to an entity service command call."""
if not hasattr(self._node, command):
_LOGGER.error(
"Invalid Service Call %s for device %s", command, self.entity_id
raise HomeAssistantError(
f"Invalid service call: {command} for device {self.entity_id}"
)
return
getattr(self._node, command)()
await getattr(self._node, command)()
def send_raw_node_command(
async def async_send_raw_node_command(
self, command, value=None, unit_of_measurement=None, parameters=None
):
"""Respond to an entity service raw command call."""
if not hasattr(self._node, "send_cmd"):
_LOGGER.error(
"Invalid Service Call %s for device %s", command, self.entity_id
raise HomeAssistantError(
f"Invalid service call: {command} for device {self.entity_id}"
)
return
self._node.send_cmd(command, value, unit_of_measurement, parameters)
await self._node.send_cmd(command, value, unit_of_measurement, parameters)
class ISYProgramEntity(ISYEntity):

View file

@ -65,17 +65,17 @@ class ISYFanEntity(ISYNodeEntity, FanEntity):
return None
return self._node.status != 0
def set_percentage(self, percentage: int) -> None:
async def async_set_percentage(self, percentage: int) -> None:
"""Set node to speed percentage for the ISY994 fan device."""
if percentage == 0:
self._node.turn_off()
await self._node.turn_off()
return
isy_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
self._node.turn_on(val=isy_speed)
await self._node.turn_on(val=isy_speed)
def turn_on(
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
@ -83,11 +83,11 @@ class ISYFanEntity(ISYNodeEntity, FanEntity):
**kwargs,
) -> None:
"""Send the turn on command to the ISY994 fan device."""
self.set_percentage(percentage)
await self.async_set_percentage(percentage)
def turn_off(self, **kwargs) -> None:
async def async_turn_off(self, **kwargs) -> None:
"""Send the turn off command to the ISY994 fan device."""
self._node.turn_off()
await self._node.turn_off()
@property
def supported_features(self) -> int:
@ -108,8 +108,6 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity):
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
if self._node.protocol == PROTO_INSTEON:
return 3
return int_states_in_range(SPEED_RANGE)
@property
@ -117,12 +115,12 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity):
"""Get if the fan is on."""
return self._node.status != 0
def turn_off(self, **kwargs) -> None:
async def async_turn_off(self, **kwargs) -> None:
"""Send the turn on command to ISY994 fan program."""
if not self._actions.run_then():
if not await self._actions.run_then():
_LOGGER.error("Unable to turn off the fan")
def turn_on(
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
@ -130,5 +128,5 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity):
**kwargs,
) -> None:
"""Send the turn off command to ISY994 fan program."""
if not self._actions.run_else():
if not await self._actions.run_else():
_LOGGER.error("Unable to turn on the fan")

View file

@ -41,12 +41,12 @@ from .const import (
KEY_STATUS,
NODE_FILTERS,
PLATFORMS,
PROGRAM_PLATFORMS,
SUBNODE_CLIMATE_COOL,
SUBNODE_CLIMATE_HEAT,
SUBNODE_EZIO2X4_SENSORS,
SUBNODE_FANLINC_LIGHT,
SUBNODE_IOLINC_RELAY,
SUPPORTED_PROGRAM_PLATFORMS,
TYPE_CATEGORY_SENSOR_ACTUATORS,
TYPE_EZIO2X4,
UOM_DOUBLE_TEMP,
@ -167,7 +167,6 @@ def _check_for_zwave_cat(
device_type.startswith(t)
for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT])
):
hass_isy_data[ISY994_NODES][platform].append(node)
return True
@ -314,7 +313,7 @@ def _categorize_nodes(
def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None:
"""Categorize the ISY994 programs."""
for platform in SUPPORTED_PROGRAM_PLATFORMS:
for platform in PROGRAM_PLATFORMS:
folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
if not folder:
continue

View file

@ -9,7 +9,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@ -72,30 +72,32 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
return round(self._node.status * 255.0 / 100.0)
return int(self._node.status)
def turn_off(self, **kwargs) -> None:
async def async_turn_off(self, **kwargs) -> None:
"""Send the turn off command to the ISY994 light device."""
self._last_brightness = self.brightness
if not self._node.turn_off():
if not await self._node.turn_off():
_LOGGER.debug("Unable to turn off light")
def on_update(self, event: object) -> None:
@callback
def async_on_update(self, event: object) -> None:
"""Save brightness in the update event from the ISY994 Node."""
if self._node.status not in (0, ISY_VALUE_UNKNOWN):
self._last_brightness = self._node.status
if self._node.uom == UOM_PERCENTAGE:
self._last_brightness = round(self._node.status * 255.0 / 100.0)
else:
self._last_brightness = self._node.status
super().on_update(event)
super().async_on_update(event)
# pylint: disable=arguments-differ
def turn_on(self, brightness=None, **kwargs) -> None:
async def async_turn_on(self, brightness=None, **kwargs) -> None:
"""Send the turn on command to the ISY994 light device."""
if self._restore_light_state and brightness is None and self._last_brightness:
brightness = self._last_brightness
# Special Case for ISY Z-Wave Devices using % instead of 0-255:
if brightness is not None and self._node.uom == UOM_PERCENTAGE:
brightness = round(brightness * 100.0 / 255.0)
if not self._node.turn_on(val=brightness):
if not await self._node.turn_on(val=brightness):
_LOGGER.debug("Unable to turn on light")
@property
@ -125,10 +127,10 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
):
self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS]
def set_on_level(self, value):
async def async_set_on_level(self, value):
"""Set the ON Level for a device."""
self._node.set_on_level(value)
await self._node.set_on_level(value)
def set_ramp_rate(self, value):
async def async_set_ramp_rate(self, value):
"""Set the Ramp Rate for a device."""
self._node.set_ramp_rate(value)
await self._node.set_ramp_rate(value)

View file

@ -1,4 +1,5 @@
"""Support for ISY994 locks."""
from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
@ -41,14 +42,14 @@ class ISYLockEntity(ISYNodeEntity, LockEntity):
return None
return VALUE_TO_STATE.get(self._node.status)
def lock(self, **kwargs) -> None:
async def async_lock(self, **kwargs) -> None:
"""Send the lock command to the ISY994 device."""
if not self._node.secure_lock():
if not await self._node.secure_lock():
_LOGGER.error("Unable to lock device")
def unlock(self, **kwargs) -> None:
async def async_unlock(self, **kwargs) -> None:
"""Send the unlock command to the ISY994 device."""
if not self._node.secure_unlock():
if not await self._node.secure_unlock():
_LOGGER.error("Unable to lock device")
@ -60,12 +61,12 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
"""Return true if the device is locked."""
return bool(self._node.status)
def lock(self, **kwargs) -> None:
async def async_lock(self, **kwargs) -> None:
"""Lock the device."""
if not self._actions.run_then():
if not await self._actions.run_then():
_LOGGER.error("Unable to lock device")
def unlock(self, **kwargs) -> None:
async def async_unlock(self, **kwargs) -> None:
"""Unlock the device."""
if not self._actions.run_else():
if not await self._actions.run_else():
_LOGGER.error("Unable to unlock device")

View file

@ -2,7 +2,7 @@
"domain": "isy994",
"name": "Universal Devices ISY994",
"documentation": "https://www.home-assistant.io/integrations/isy994",
"requirements": ["pyisy==2.1.1"],
"requirements": ["pyisy==3.0.0"],
"codeowners": ["@bdraco", "@shbatm"],
"config_flow": true,
"ssdp": [
@ -11,8 +11,6 @@
"deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1"
}
],
"dhcp": [
{"hostname":"isy*", "macaddress":"0021B9*"}
],
"dhcp": [{ "hostname": "isy*", "macaddress": "0021B9*" }],
"iot_class": "local_push"
}

View file

@ -83,6 +83,10 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
if uom in [UOM_INDEX, UOM_ON_OFF]:
return self._node.formatted
# Check if this is an index type and get formatted value
if uom == UOM_INDEX and hasattr(self._node, "formatted"):
return self._node.formatted
# Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self._node.prec)
@ -123,7 +127,8 @@ class ISYSensorVariableEntity(ISYEntity, SensorEntity):
return {
"init_value": convert_isy_value_to_hass(
self._node.init, "", self._node.prec
)
),
"last_edited": self._node.last_edited,
}
@property

View file

@ -27,7 +27,7 @@ from .const import (
ISY994_PROGRAMS,
ISY994_VARIABLES,
PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS,
PROGRAM_PLATFORMS,
)
# Common Services for All Platforms:
@ -183,12 +183,12 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
address,
isy.configuration["uuid"],
)
await hass.async_add_executor_job(isy.query, address)
await isy.query(address)
return
_LOGGER.debug(
"Requesting system query of ISY %s", isy.configuration["uuid"]
)
await hass.async_add_executor_job(isy.query)
await isy.query()
async def async_run_network_resource_service_handler(service):
"""Handle a network resource service call."""
@ -208,10 +208,10 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if name:
command = isy.networking.get_by_name(name)
if command is not None:
await hass.async_add_executor_job(command.run)
await command.run()
return
_LOGGER.error(
"Could not run network resource command. Not found or enabled on the ISY"
"Could not run network resource command; not found or enabled on the ISY"
)
async def async_send_program_command_service_handler(service):
@ -231,9 +231,9 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if name:
program = isy.programs.get_by_name(name)
if program is not None:
await hass.async_add_executor_job(getattr(program, command))
await getattr(program, command)()
return
_LOGGER.error("Could not send program command. Not found or enabled on the ISY")
_LOGGER.error("Could not send program command; not found or enabled on the ISY")
async def async_set_variable_service_handler(service):
"""Handle a set variable service call."""
@ -254,9 +254,9 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if address and vtype:
variable = isy.variables.vobjs[vtype].get(address)
if variable is not None:
await hass.async_add_executor_job(variable.set_value, value, init)
await variable.set_value(value, init)
return
_LOGGER.error("Could not set variable value. Not found or enabled on the ISY")
_LOGGER.error("Could not set variable value; not found or enabled on the ISY")
async def async_cleanup_registry_entries(service) -> None:
"""Remove extra entities that are no longer part of the integration."""
@ -283,7 +283,7 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if hasattr(node, "address"):
current_unique_ids.append(f"{uuid}_{node.address}")
for platform in SUPPORTED_PROGRAM_PLATFORMS:
for platform in PROGRAM_PLATFORMS:
for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]:
if hasattr(node, "address"):
current_unique_ids.append(f"{uuid}_{node.address}")
@ -355,7 +355,7 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
async def _async_send_raw_node_command(call: ServiceCall):
await hass.helpers.service.entity_service_call(
async_get_platforms(hass, DOMAIN), SERVICE_SEND_RAW_NODE_COMMAND, call
async_get_platforms(hass, DOMAIN), "async_send_raw_node_command", call
)
hass.services.async_register(
@ -367,7 +367,7 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
async def _async_send_node_command(call: ServiceCall):
await hass.helpers.service.entity_service_call(
async_get_platforms(hass, DOMAIN), SERVICE_SEND_NODE_COMMAND, call
async_get_platforms(hass, DOMAIN), "async_send_node_command", call
)
hass.services.async_register(
@ -408,8 +408,8 @@ def async_setup_light_services(hass: HomeAssistant):
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_ON_LEVEL, SERVICE_SET_VALUE_SCHEMA, SERVICE_SET_ON_LEVEL
SERVICE_SET_ON_LEVEL, SERVICE_SET_VALUE_SCHEMA, "async_set_on_level"
)
platform.async_register_entity_service(
SERVICE_SET_RAMP_RATE, SERVICE_SET_RAMP_RATE_SCHEMA, SERVICE_SET_RAMP_RATE
SERVICE_SET_RAMP_RATE, SERVICE_SET_RAMP_RATE_SCHEMA, "async_set_ramp_rate"
)

View file

@ -57,19 +57,19 @@ send_node_command:
selector:
select:
options:
- 'beep'
- 'brighten'
- 'dim'
- 'disable'
- 'enable'
- 'fade_down'
- 'fade_stop'
- 'fade_up'
- 'fast_off'
- 'fast_on'
- 'query'
- "beep"
- "brighten"
- "dim"
- "disable"
- "enable"
- "fade_down"
- "fade_stop"
- "fade_up"
- "fast_off"
- "fast_on"
- "query"
set_on_level:
name: Set on level
name: Set On Level
description: Send a ISY set_on_level command to a Node.
target:
entity:
@ -188,14 +188,14 @@ send_program_command:
selector:
select:
options:
- 'disable'
- 'disable_run_at_startup'
- 'enable'
- 'enable_run_at_startup'
- 'run'
- 'run_else'
- 'run_then'
- 'stop'
- "disable"
- "disable_run_at_startup"
- "enable"
- "enable_run_at_startup"
- "run"
- "run_else"
- "run_then"
- "stop"
isy:
name: ISY
description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same program name or address on multiple ISYs, omitting this will run the command on them all.

View file

@ -1,4 +1,5 @@
"""Support for ISY994 switches."""
from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
@ -39,14 +40,14 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity):
return None
return bool(self._node.status)
def turn_off(self, **kwargs) -> None:
async def async_turn_off(self, **kwargs) -> None:
"""Send the turn off command to the ISY994 switch."""
if not self._node.turn_off():
if not await self._node.turn_off():
_LOGGER.debug("Unable to turn off switch")
def turn_on(self, **kwargs) -> None:
async def async_turn_on(self, **kwargs) -> None:
"""Send the turn on command to the ISY994 switch."""
if not self._node.turn_on():
if not await self._node.turn_on():
_LOGGER.debug("Unable to turn on switch")
@property
@ -65,14 +66,14 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity):
"""Get whether the ISY994 switch program is on."""
return bool(self._node.status)
def turn_on(self, **kwargs) -> None:
async def async_turn_on(self, **kwargs) -> None:
"""Send the turn on command to the ISY994 switch program."""
if not self._actions.run_then():
if not await self._actions.run_then():
_LOGGER.error("Unable to turn on switch")
def turn_off(self, **kwargs) -> None:
async def async_turn_off(self, **kwargs) -> None:
"""Send the turn off command to the ISY994 switch program."""
if not self._actions.run_else():
if not await self._actions.run_else():
_LOGGER.error("Unable to turn off switch")
@property

View file

@ -1491,7 +1491,7 @@ pyirishrail==0.0.2
pyiss==1.0.1
# homeassistant.components.isy994
pyisy==2.1.1
pyisy==3.0.0
# homeassistant.components.itach
pyitachip2ir==0.0.7

View file

@ -826,7 +826,7 @@ pyipp==0.11.0
pyiqvia==0.3.1
# homeassistant.components.isy994
pyisy==2.1.1
pyisy==3.0.0
# homeassistant.components.kira
pykira==0.1.1

View file

@ -1,10 +1,11 @@
"""Test the Universal Devices ISY994 config flow."""
import re
from unittest.mock import patch
from pyisy import ISYConnectionError, ISYInvalidAuthError
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components import dhcp, ssdp
from homeassistant.components.isy994.config_flow import CannotConnect
from homeassistant.components.isy994.const import (
CONF_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE,
@ -63,12 +64,30 @@ MOCK_IMPORT_FULL_CONFIG = {
MOCK_DEVICE_NAME = "Name of the device"
MOCK_UUID = "ce:fb:72:31:b7:b9"
MOCK_MAC = "cefb7231b7b9"
MOCK_VALIDATED_RESPONSE = {"name": MOCK_DEVICE_NAME, "uuid": MOCK_UUID}
PATCH_CONFIGURATION = "homeassistant.components.isy994.config_flow.Configuration"
PATCH_CONNECTION = "homeassistant.components.isy994.config_flow.Connection"
PATCH_ASYNC_SETUP = "homeassistant.components.isy994.async_setup"
PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.isy994.async_setup_entry"
MOCK_CONFIG_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<app_full_version>5.0.16C</app_full_version>
<platform>ISY-C-994</platform>
<root>
<id>ce:fb:72:31:b7:b9</id>
<name>Name of the device</name>
</root>
<features>
<feature>
<id>21040</id>
<desc>Networking Module</desc>
<isInstalled>true</isInstalled>
<isAvailable>true</isAvailable>
</feature>
</features>
</configuration>
"""
INTEGRATION = "homeassistant.components.isy994"
PATCH_CONNECTION = f"{INTEGRATION}.config_flow.Connection.test_connection"
PATCH_ASYNC_SETUP = f"{INTEGRATION}.async_setup"
PATCH_ASYNC_SETUP_ENTRY = f"{INTEGRATION}.async_setup_entry"
async def test_form(hass: HomeAssistant):
@ -80,17 +99,12 @@ async def test_form(hass: HomeAssistant):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,
@ -129,9 +143,9 @@ async def test_form_invalid_auth(hass: HomeAssistant):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
with patch(
PATCH_CONNECTION,
side_effect=ValueError("PyISY could not connect to the ISY."),
side_effect=ISYInvalidAuthError(),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -142,14 +156,52 @@ async def test_form_invalid_auth(hass: HomeAssistant):
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant):
"""Test we handle cannot connect error."""
async def test_form_isy_connection_error(hass: HomeAssistant):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
with patch(
PATCH_CONNECTION,
side_effect=CannotConnect,
side_effect=ISYConnectionError(),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_isy_parse_response_error(hass: HomeAssistant, caplog):
"""Test we handle poorly formatted XML response from ISY."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
PATCH_CONNECTION,
return_value=MOCK_CONFIG_RESPONSE.rsplit("\n", 3)[0], # Test with invalid XML
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert "ISY Could not parse response, poorly formatted XML." in caplog.text
async def test_form_no_name_in_response(hass: HomeAssistant):
"""Test we handle invalid response from ISY with name not set."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
PATCH_CONNECTION,
return_value=re.sub(
r"\<name\>.*\n", "", MOCK_CONFIG_RESPONSE
), # Test with <name> line removed.
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -170,12 +222,7 @@ async def test_form_existing_config_entry(hass: HomeAssistant):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,
@ -185,15 +232,12 @@ async def test_form_existing_config_entry(hass: HomeAssistant):
async def test_import_flow_some_fields(hass: HomeAssistant) -> None:
"""Test import config flow with just the basic fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
), patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@ -209,15 +253,12 @@ async def test_import_flow_some_fields(hass: HomeAssistant) -> None:
async def test_import_flow_with_https(hass: HomeAssistant) -> None:
"""Test import config with https."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
), patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@ -232,15 +273,12 @@ async def test_import_flow_with_https(hass: HomeAssistant) -> None:
async def test_import_flow_all_fields(hass: HomeAssistant) -> None:
"""Test import config flow with all fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
), patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@ -297,17 +335,12 @@ async def test_form_ssdp(hass: HomeAssistant):
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,
@ -339,17 +372,12 @@ async def test_form_dhcp(hass: HomeAssistant):
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY,
return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USER_INPUT,