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.""" """Support the ISY-994 controllers."""
from __future__ import annotations from __future__ import annotations
from functools import partial
from urllib.parse import urlparse 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 import voluptuous as vol
from homeassistant import config_entries 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.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 import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -32,7 +39,7 @@ from .const import (
ISY994_VARIABLES, ISY994_VARIABLES,
MANUFACTURER, MANUFACTURER,
PLATFORMS, PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS, PROGRAM_PLATFORMS,
UNDO_UPDATE_LISTENER, UNDO_UPDATE_LISTENER,
) )
from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables 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_NODES][platform] = []
hass_isy_data[ISY994_PROGRAMS] = {} 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_PROGRAMS][platform] = []
hass_isy_data[ISY994_VARIABLES] = [] hass_isy_data[ISY994_VARIABLES] = []
@ -139,31 +146,50 @@ async def async_setup_entry(
if host.scheme == "http": if host.scheme == "http":
https = False https = False
port = host.port or 80 port = host.port or 80
session = aiohttp_client.async_create_clientsession(
hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True)
)
elif host.scheme == "https": elif host.scheme == "https":
https = True https = True
port = host.port or 443 port = host.port or 443
session = aiohttp_client.async_get_clientsession(hass)
else: else:
_LOGGER.error("The isy994 host value in configuration is invalid") _LOGGER.error("The isy994 host value in configuration is invalid")
return False return False
# Connect to ISY controller. # Connect to ISY controller.
isy = await hass.async_add_executor_job( isy = ISY(
partial( host.hostname,
ISY, port,
host.hostname, username=user,
port, password=password,
username=user, use_https=https,
password=password, tls_ver=tls_version,
use_https=https, webroot=host.path,
tls_ver=tls_version, websession=session,
webroot=host.path, use_websocket=True,
)
) )
if not isy.connected:
return False
# Trigger a status update for all nodes, not done automatically in PyISY v2.x try:
await hass.async_add_executor_job(isy.nodes.update) 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_nodes(hass_isy_data, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(hass_isy_data, isy.programs) _categorize_programs(hass_isy_data, isy.programs)
@ -181,13 +207,21 @@ async def async_setup_entry(
def _start_auto_update() -> None: def _start_auto_update() -> None:
"""Start isy auto update.""" """Start isy auto update."""
_LOGGER.debug("ISY Starting Event Stream and automatic updates") _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) await hass.async_add_executor_job(_start_auto_update)
undo_listener = entry.add_update_listener(_async_update_listener) undo_listener = entry.add_update_listener(_async_update_listener)
hass_isy_data[UNDO_UPDATE_LISTENER] = undo_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: # Register Integration-wide Services:
async_setup_services(hass) async_setup_services(hass)
@ -248,9 +282,9 @@ async def async_unload_entry(
isy = hass_isy_data[ISY994_ISY] isy = hass_isy_data[ISY994_ISY]
def _stop_auto_update() -> None: def _stop_auto_update() -> None:
"""Start isy auto update.""" """Stop the isy auto update."""
_LOGGER.debug("ISY Stopping Event Stream and automatic updates") _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) 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.""" """Subscribe to the node and subnode event emitters."""
await super().async_added_to_hass() 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: if self._negative_node is not None:
self._negative_node.control_events.subscribe( self._negative_node.control_events.subscribe(
self._negative_node_control_handler self._async_negative_node_control_handler
) )
def add_heartbeat_device(self, device) -> None: def add_heartbeat_device(self, device) -> None:
@ -267,10 +267,10 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
""" """
self._heartbeat_device = device self._heartbeat_device = device
def _heartbeat(self) -> None: def _async_heartbeat(self) -> None:
"""Send a heartbeat to our heartbeat device, if we have one.""" """Send a heartbeat to our heartbeat device, if we have one."""
if self._heartbeat_device is not None: if self._heartbeat_device is not None:
self._heartbeat_device.heartbeat() self._heartbeat_device.async_heartbeat()
def add_negative_node(self, child) -> None: def add_negative_node(self, child) -> None:
"""Add a negative node to this binary sensor device. """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. # of the sensor until we receive our first ON event.
self._computed_state = None 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.""" """Handle an "On" control event from the "negative" node."""
if event.control == CMD_ON: if event.control == CMD_ON:
_LOGGER.debug( _LOGGER.debug(
@ -300,10 +301,11 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
self.name, self.name,
) )
self._computed_state = False self._computed_state = False
self.schedule_update_ha_state() self.async_write_ha_state()
self._heartbeat() 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. """Handle On and Off control event coming from the primary node.
Depending on device configuration, sometimes only On events Depending on device configuration, sometimes only On events
@ -316,18 +318,19 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
self.name, self.name,
) )
self._computed_state = True self._computed_state = True
self.schedule_update_ha_state() self.async_write_ha_state()
self._heartbeat() self._async_heartbeat()
if event.control == CMD_OFF: if event.control == CMD_OFF:
_LOGGER.debug( _LOGGER.debug(
"Sensor %s turning Off via the Primary node sending a DOF command", "Sensor %s turning Off via the Primary node sending a DOF command",
self.name, self.name,
) )
self._computed_state = False self._computed_state = False
self.schedule_update_ha_state() self.async_write_ha_state()
self._heartbeat() self._async_heartbeat()
def on_update(self, event: object) -> None: @callback
def async_on_update(self, event: object) -> None:
"""Primary node status updates. """Primary node status updates.
We MOSTLY ignore these updates, as we listen directly to the Control 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: if self._status_was_unknown and self._computed_state is None:
self._computed_state = bool(self._node.status) self._computed_state = bool(self._node.status)
self._status_was_unknown = False self._status_was_unknown = False
self.schedule_update_ha_state() self.async_write_ha_state()
self._heartbeat() self._async_heartbeat()
@property @property
def is_on(self) -> bool: 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. The ISY uses both DON and DOF commands (alternating) for a heartbeat.
""" """
if event.control in [CMD_ON, CMD_OFF]: 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. """Mark the device as online, and restart the 25 hour timer.
This gets called when the heartbeat node beats, but also when the 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._computed_state = False
self._restart_timer() self._restart_timer()
self.schedule_update_ha_state() self.async_write_ha_state()
def _restart_timer(self): def _restart_timer(self):
"""Restart the 25 hour timer.""" """Restart the 25 hour timer."""
@ -423,7 +427,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
"""Heartbeat missed; set state to ON to indicate dead battery.""" """Heartbeat missed; set state to ON to indicate dead battery."""
self._computed_state = True self._computed_state = True
self._heartbeat_timer = None self._heartbeat_timer = None
self.schedule_update_ha_state() self.async_write_ha_state()
point_in_time = dt_util.utcnow() + timedelta(hours=25) point_in_time = dt_util.utcnow() + timedelta(hours=25)
_LOGGER.debug( _LOGGER.debug(
@ -436,7 +440,8 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
self.hass, timer_elapsed, point_in_time 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. """Ignore node status updates.
We listen directly to the Control events for this device. We listen directly to the Control events for this device.

View File

@ -203,7 +203,7 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
return None return None
return UOM_TO_STATES[UOM_FAN_MODES].get(fan_mode.value) 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.""" """Set new target temperature."""
target_temp = kwargs.get(ATTR_TEMPERATURE) target_temp = kwargs.get(ATTR_TEMPERATURE)
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
@ -214,27 +214,27 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
if self.hvac_mode == HVAC_MODE_HEAT: if self.hvac_mode == HVAC_MODE_HEAT:
target_temp_low = target_temp target_temp_low = target_temp
if target_temp_low is not None: 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: # Presumptive setting--event stream will correct if cmd fails:
self._target_temp_low = target_temp_low self._target_temp_low = target_temp_low
if target_temp_high is not None: 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: # Presumptive setting--event stream will correct if cmd fails:
self._target_temp_high = target_temp_high 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.""" """Set new target fan mode."""
_LOGGER.debug("Requested fan mode %s", 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: # Presumptive setting--event stream will correct if cmd fails:
self._fan_mode = fan_mode 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.""" """Set new target hvac mode."""
_LOGGER.debug("Requested operation mode %s", 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: # Presumptive setting--event stream will correct if cmd fails:
self._hvac_mode = hvac_mode self._hvac_mode = hvac_mode
self.schedule_update_ha_state() self.async_write_ha_state()

View File

@ -2,6 +2,9 @@
import logging import logging
from urllib.parse import urlparse 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.configuration import Configuration
from pyisy.connection import Connection from pyisy.connection import Connection
import voluptuous as vol 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.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from .const import ( from .const import (
CONF_IGNORE_STRING, CONF_IGNORE_STRING,
@ -57,25 +61,41 @@ async def validate_input(hass: core.HomeAssistant, data):
if host.scheme == "http": if host.scheme == "http":
https = False https = False
port = host.port or 80 port = host.port or 80
session = aiohttp_client.async_create_clientsession(
hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True)
)
elif host.scheme == "https": elif host.scheme == "https":
https = True https = True
port = host.port or 443 port = host.port or 443
session = aiohttp_client.async_get_clientsession(hass)
else: else:
_LOGGER.error("The isy994 host value in configuration is invalid") _LOGGER.error("The isy994 host value in configuration is invalid")
raise InvalidHost raise InvalidHost
# Connect to ISY controller. # Connect to ISY controller.
isy_conf = await hass.async_add_executor_job( isy_conn = Connection(
_fetch_isy_configuration,
host.hostname, host.hostname,
port, port,
user, user,
password, password,
https, use_https=https,
tls_version, tls_ver=tls_version,
host.path, 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"]: if not isy_conf or "name" not in isy_conf or not isy_conf["name"]:
raise CannotConnect 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"]} 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): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Universal Devices ISY994.""" """Handle a config flow for Universal Devices ISY994."""

View File

@ -130,7 +130,7 @@ KEY_ACTIONS = "actions"
KEY_STATUS = "status" KEY_STATUS = "status"
PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH, CLIMATE] 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"] SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"]
@ -184,6 +184,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener"
# Used for discovery # Used for discovery
UDN_UUID_PREFIX = "uuid:" UDN_UUID_PREFIX = "uuid:"
ISY_URL_POSTFIX = "/desc" ISY_URL_POSTFIX = "/desc"
EVENTS_SUFFIX = "_ISYSUB"
# Special Units of Measure # Special Units of Measure
UOM_ISYV4_DEGREES = "degrees" UOM_ISYV4_DEGREES = "degrees"
@ -352,7 +353,7 @@ UOM_FRIENDLY_NAME = {
"22": "%RH", "22": "%RH",
"23": PRESSURE_INHG, "23": PRESSURE_INHG,
"24": SPEED_INCHES_PER_HOUR, "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, "26": TEMP_KELVIN,
"27": "keyword", "27": "keyword",
"28": MASS_KILOGRAMS, "28": MASS_KILOGRAMS,

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from homeassistant.components.light import (
LightEntity, LightEntity,
) )
from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity 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 round(self._node.status * 255.0 / 100.0)
return int(self._node.status) 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.""" """Send the turn off command to the ISY994 light device."""
self._last_brightness = self.brightness 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") _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.""" """Save brightness in the update event from the ISY994 Node."""
if self._node.status not in (0, ISY_VALUE_UNKNOWN): if self._node.status not in (0, ISY_VALUE_UNKNOWN):
self._last_brightness = self._node.status
if self._node.uom == UOM_PERCENTAGE: if self._node.uom == UOM_PERCENTAGE:
self._last_brightness = round(self._node.status * 255.0 / 100.0) self._last_brightness = round(self._node.status * 255.0 / 100.0)
else: else:
self._last_brightness = self._node.status self._last_brightness = self._node.status
super().on_update(event) super().async_on_update(event)
# pylint: disable=arguments-differ # 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.""" """Send the turn on command to the ISY994 light device."""
if self._restore_light_state and brightness is None and self._last_brightness: if self._restore_light_state and brightness is None and self._last_brightness:
brightness = self._last_brightness brightness = self._last_brightness
# Special Case for ISY Z-Wave Devices using % instead of 0-255: # Special Case for ISY Z-Wave Devices using % instead of 0-255:
if brightness is not None and self._node.uom == UOM_PERCENTAGE: if brightness is not None and self._node.uom == UOM_PERCENTAGE:
brightness = round(brightness * 100.0 / 255.0) 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") _LOGGER.debug("Unable to turn on light")
@property @property
@ -125,10 +127,10 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
): ):
self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS] 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.""" """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.""" """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.""" """Support for ISY994 locks."""
from pyisy.constants import ISY_VALUE_UNKNOWN from pyisy.constants import ISY_VALUE_UNKNOWN
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
@ -41,14 +42,14 @@ class ISYLockEntity(ISYNodeEntity, LockEntity):
return None return None
return VALUE_TO_STATE.get(self._node.status) 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.""" """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") _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.""" """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") _LOGGER.error("Unable to lock device")
@ -60,12 +61,12 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
"""Return true if the device is locked.""" """Return true if the device is locked."""
return bool(self._node.status) return bool(self._node.status)
def lock(self, **kwargs) -> None: async def async_lock(self, **kwargs) -> None:
"""Lock the device.""" """Lock the device."""
if not self._actions.run_then(): if not await self._actions.run_then():
_LOGGER.error("Unable to lock device") _LOGGER.error("Unable to lock device")
def unlock(self, **kwargs) -> None: async def async_unlock(self, **kwargs) -> None:
"""Unlock the device.""" """Unlock the device."""
if not self._actions.run_else(): if not await self._actions.run_else():
_LOGGER.error("Unable to unlock device") _LOGGER.error("Unable to unlock device")

View File

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

View File

@ -83,6 +83,10 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
if uom in [UOM_INDEX, UOM_ON_OFF]: if uom in [UOM_INDEX, UOM_ON_OFF]:
return self._node.formatted 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 # Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self._node.prec) value = convert_isy_value_to_hass(value, uom, self._node.prec)
@ -123,7 +127,8 @@ class ISYSensorVariableEntity(ISYEntity, SensorEntity):
return { return {
"init_value": convert_isy_value_to_hass( "init_value": convert_isy_value_to_hass(
self._node.init, "", self._node.prec self._node.init, "", self._node.prec
) ),
"last_edited": self._node.last_edited,
} }
@property @property

View File

@ -27,7 +27,7 @@ from .const import (
ISY994_PROGRAMS, ISY994_PROGRAMS,
ISY994_VARIABLES, ISY994_VARIABLES,
PLATFORMS, PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS, PROGRAM_PLATFORMS,
) )
# Common Services for All Platforms: # Common Services for All Platforms:
@ -183,12 +183,12 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
address, address,
isy.configuration["uuid"], isy.configuration["uuid"],
) )
await hass.async_add_executor_job(isy.query, address) await isy.query(address)
return return
_LOGGER.debug( _LOGGER.debug(
"Requesting system query of ISY %s", isy.configuration["uuid"] "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): async def async_run_network_resource_service_handler(service):
"""Handle a network resource service call.""" """Handle a network resource service call."""
@ -208,10 +208,10 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if name: if name:
command = isy.networking.get_by_name(name) command = isy.networking.get_by_name(name)
if command is not None: if command is not None:
await hass.async_add_executor_job(command.run) await command.run()
return return
_LOGGER.error( _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): async def async_send_program_command_service_handler(service):
@ -231,9 +231,9 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if name: if name:
program = isy.programs.get_by_name(name) program = isy.programs.get_by_name(name)
if program is not None: if program is not None:
await hass.async_add_executor_job(getattr(program, command)) await getattr(program, command)()
return 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): async def async_set_variable_service_handler(service):
"""Handle a set variable service call.""" """Handle a set variable service call."""
@ -254,9 +254,9 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
if address and vtype: if address and vtype:
variable = isy.variables.vobjs[vtype].get(address) variable = isy.variables.vobjs[vtype].get(address)
if variable is not None: if variable is not None:
await hass.async_add_executor_job(variable.set_value, value, init) await variable.set_value(value, init)
return 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: async def async_cleanup_registry_entries(service) -> None:
"""Remove extra entities that are no longer part of the integration.""" """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"): if hasattr(node, "address"):
current_unique_ids.append(f"{uuid}_{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]: for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]:
if hasattr(node, "address"): if hasattr(node, "address"):
current_unique_ids.append(f"{uuid}_{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): async def _async_send_raw_node_command(call: ServiceCall):
await hass.helpers.service.entity_service_call( 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( hass.services.async_register(
@ -367,7 +367,7 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901
async def _async_send_node_command(call: ServiceCall): async def _async_send_node_command(call: ServiceCall):
await hass.helpers.service.entity_service_call( 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( hass.services.async_register(
@ -408,8 +408,8 @@ def async_setup_light_services(hass: HomeAssistant):
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service( 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( 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: selector:
select: select:
options: options:
- 'beep' - "beep"
- 'brighten' - "brighten"
- 'dim' - "dim"
- 'disable' - "disable"
- 'enable' - "enable"
- 'fade_down' - "fade_down"
- 'fade_stop' - "fade_stop"
- 'fade_up' - "fade_up"
- 'fast_off' - "fast_off"
- 'fast_on' - "fast_on"
- 'query' - "query"
set_on_level: set_on_level:
name: Set on level name: Set On Level
description: Send a ISY set_on_level command to a Node. description: Send a ISY set_on_level command to a Node.
target: target:
entity: entity:
@ -188,14 +188,14 @@ send_program_command:
selector: selector:
select: select:
options: options:
- 'disable' - "disable"
- 'disable_run_at_startup' - "disable_run_at_startup"
- 'enable' - "enable"
- 'enable_run_at_startup' - "enable_run_at_startup"
- 'run' - "run"
- 'run_else' - "run_else"
- 'run_then' - "run_then"
- 'stop' - "stop"
isy: isy:
name: 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. 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.""" """Support for ISY994 switches."""
from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
@ -39,14 +40,14 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity):
return None return None
return bool(self._node.status) 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.""" """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") _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.""" """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") _LOGGER.debug("Unable to turn on switch")
@property @property
@ -65,14 +66,14 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity):
"""Get whether the ISY994 switch program is on.""" """Get whether the ISY994 switch program is on."""
return bool(self._node.status) 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.""" """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") _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.""" """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") _LOGGER.error("Unable to turn off switch")
@property @property

View File

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

View File

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

View File

@ -1,10 +1,11 @@
"""Test the Universal Devices ISY994 config flow.""" """Test the Universal Devices ISY994 config flow."""
import re
from unittest.mock import patch from unittest.mock import patch
from pyisy import ISYConnectionError, ISYInvalidAuthError
from homeassistant import config_entries, data_entry_flow, setup from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components import dhcp, ssdp from homeassistant.components import dhcp, ssdp
from homeassistant.components.isy994.config_flow import CannotConnect
from homeassistant.components.isy994.const import ( from homeassistant.components.isy994.const import (
CONF_IGNORE_STRING, CONF_IGNORE_STRING,
CONF_RESTORE_LIGHT_STATE, CONF_RESTORE_LIGHT_STATE,
@ -63,12 +64,30 @@ MOCK_IMPORT_FULL_CONFIG = {
MOCK_DEVICE_NAME = "Name of the device" MOCK_DEVICE_NAME = "Name of the device"
MOCK_UUID = "ce:fb:72:31:b7:b9" MOCK_UUID = "ce:fb:72:31:b7:b9"
MOCK_MAC = "cefb7231b7b9" MOCK_MAC = "cefb7231b7b9"
MOCK_VALIDATED_RESPONSE = {"name": MOCK_DEVICE_NAME, "uuid": MOCK_UUID}
PATCH_CONFIGURATION = "homeassistant.components.isy994.config_flow.Configuration" MOCK_CONFIG_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
PATCH_CONNECTION = "homeassistant.components.isy994.config_flow.Connection" <configuration>
PATCH_ASYNC_SETUP = "homeassistant.components.isy994.async_setup" <app_full_version>5.0.16C</app_full_version>
PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.isy994.async_setup_entry" <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): 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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch( ) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, return_value=True,
) as mock_setup_entry: ) 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( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
MOCK_USER_INPUT, MOCK_USER_INPUT,
@ -129,9 +143,9 @@ async def test_form_invalid_auth(hass: HomeAssistant):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch(PATCH_CONFIGURATION), patch( with patch(
PATCH_CONNECTION, PATCH_CONNECTION,
side_effect=ValueError("PyISY could not connect to the ISY."), side_effect=ISYInvalidAuthError(),
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
@ -142,14 +156,52 @@ async def test_form_invalid_auth(hass: HomeAssistant):
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant): async def test_form_isy_connection_error(hass: HomeAssistant):
"""Test we handle cannot connect error.""" """Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch(PATCH_CONFIGURATION), patch( with patch(
PATCH_CONNECTION, 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( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE):
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
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
MOCK_USER_INPUT, 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: async def test_import_flow_some_fields(hass: HomeAssistant) -> None:
"""Test import config flow with just the basic fields.""" """Test import config flow with just the basic fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION PATCH_ASYNC_SETUP, return_value=True
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch( ), patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, 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( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, 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: async def test_import_flow_with_https(hass: HomeAssistant) -> None:
"""Test import config with https.""" """Test import config with https."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION PATCH_ASYNC_SETUP, return_value=True
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch( ), patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, 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( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, 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: async def test_import_flow_all_fields(hass: HomeAssistant) -> None:
"""Test import config flow with all fields.""" """Test import config flow with all fields."""
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION PATCH_ASYNC_SETUP, return_value=True
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch( ), patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, 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( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, context={"source": SOURCE_IMPORT},
@ -297,17 +335,12 @@ async def test_form_ssdp(hass: HomeAssistant):
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch( ) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, return_value=True,
) as mock_setup_entry: ) 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( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
MOCK_USER_INPUT, MOCK_USER_INPUT,
@ -339,17 +372,12 @@ async def test_form_dhcp(hass: HomeAssistant):
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
with patch(PATCH_CONFIGURATION) as mock_config_class, patch( with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch(
PATCH_CONNECTION
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch( ) as mock_setup, patch(
PATCH_ASYNC_SETUP_ENTRY, PATCH_ASYNC_SETUP_ENTRY,
return_value=True, return_value=True,
) as mock_setup_entry: ) 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( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
MOCK_USER_INPUT, MOCK_USER_INPUT,