mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
Add config flow, use async loading, and restore brightness option to ISY994 (#35413)
* ISY994 Add Config and Options Flow Add tests for config flow Fix test Update Tests * Fix merge errors * Update homeassistant/components/isy994/strings.json Co-authored-by: J. Nick Koston <nick@koston.org> * Apply suggestions from code review Co-authored-by: J. Nick Koston <nick@koston.org> * Fix patching in tests to not actually start Home Assistant Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
d61bde6ae6
commit
d7f736ed6c
@ -1,31 +1,34 @@
|
||||
"""Support the ISY-994 controllers."""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyisy import ISY
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_TLS_VER,
|
||||
DEFAULT_IGNORE_STRING,
|
||||
DEFAULT_RESTORE_LIGHT_STATE,
|
||||
DEFAULT_SENSOR_STRING,
|
||||
DOMAIN,
|
||||
ISY994_ISY,
|
||||
ISY994_NODES,
|
||||
ISY994_PROGRAMS,
|
||||
SUPPORTED_PLATFORMS,
|
||||
SUPPORTED_PROGRAM_PLATFORMS,
|
||||
UNDO_UPDATE_LISTENER,
|
||||
)
|
||||
from .helpers import _categorize_nodes, _categorize_programs
|
||||
|
||||
@ -43,6 +46,9 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(
|
||||
CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING
|
||||
): cv.string,
|
||||
vol.Required(
|
||||
CONF_RESTORE_LIGHT_STATE, default=DEFAULT_RESTORE_LIGHT_STATE
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
},
|
||||
@ -50,24 +56,71 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the ISY 994 platform."""
|
||||
hass.data[ISY994_NODES] = {}
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the isy994 integration from YAML."""
|
||||
isy_config: Optional[ConfigType] = config.get(DOMAIN)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if not isy_config:
|
||||
return True
|
||||
|
||||
# Only import if we haven't before.
|
||||
config_entry = _async_find_matching_config_entry(hass)
|
||||
if not config_entry:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=dict(isy_config),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
# Update the entry based on the YAML configuration, in case it changed.
|
||||
hass.config_entries.async_update_entry(config_entry, data=dict(isy_config))
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def _async_find_matching_config_entry(hass):
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.source == config_entries.SOURCE_IMPORT:
|
||||
return entry
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the ISY 994 integration."""
|
||||
# As there currently is no way to import options from yaml
|
||||
# when setting up a config entry, we fallback to adding
|
||||
# the options to the config entry and pull them out here if
|
||||
# they are missing from the options
|
||||
_async_import_options_from_data_if_missing(hass, entry)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {}
|
||||
hass_isy_data = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
hass_isy_data[ISY994_NODES] = {}
|
||||
for platform in SUPPORTED_PLATFORMS:
|
||||
hass.data[ISY994_NODES][platform] = []
|
||||
hass_isy_data[ISY994_NODES][platform] = []
|
||||
|
||||
hass.data[ISY994_PROGRAMS] = {}
|
||||
hass_isy_data[ISY994_PROGRAMS] = {}
|
||||
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
||||
hass.data[ISY994_PROGRAMS][platform] = []
|
||||
hass_isy_data[ISY994_PROGRAMS][platform] = []
|
||||
|
||||
isy_config = config.get(DOMAIN)
|
||||
isy_config = entry.data
|
||||
isy_options = entry.options
|
||||
|
||||
user = isy_config.get(CONF_USERNAME)
|
||||
password = isy_config.get(CONF_PASSWORD)
|
||||
# Required
|
||||
user = isy_config[CONF_USERNAME]
|
||||
password = isy_config[CONF_PASSWORD]
|
||||
host = urlparse(isy_config[CONF_HOST])
|
||||
|
||||
# Optional
|
||||
tls_version = isy_config.get(CONF_TLS_VER)
|
||||
host = urlparse(isy_config.get(CONF_HOST))
|
||||
ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
|
||||
sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
|
||||
ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
|
||||
sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
|
||||
|
||||
if host.scheme == "http":
|
||||
https = False
|
||||
@ -80,31 +133,103 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return False
|
||||
|
||||
# Connect to ISY controller.
|
||||
isy = ISY(
|
||||
host.hostname,
|
||||
port,
|
||||
username=user,
|
||||
password=password,
|
||||
use_https=https,
|
||||
tls_ver=tls_version,
|
||||
log=_LOGGER,
|
||||
isy = await hass.async_add_executor_job(
|
||||
partial(
|
||||
ISY,
|
||||
host.hostname,
|
||||
port,
|
||||
username=user,
|
||||
password=password,
|
||||
use_https=https,
|
||||
tls_ver=tls_version,
|
||||
log=_LOGGER,
|
||||
webroot=host.path,
|
||||
)
|
||||
)
|
||||
if not isy.connected:
|
||||
return False
|
||||
|
||||
_categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
|
||||
_categorize_programs(hass, isy.programs)
|
||||
_categorize_nodes(hass_isy_data, isy.nodes, ignore_identifier, sensor_identifier)
|
||||
_categorize_programs(hass_isy_data, isy.programs)
|
||||
|
||||
def stop(event: object) -> None:
|
||||
"""Stop ISY auto updates."""
|
||||
isy.auto_update = False
|
||||
# Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs
|
||||
_LOGGER.info(repr(isy.clock))
|
||||
|
||||
# Listen for HA stop to disconnect.
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||
hass_isy_data[ISY994_ISY] = isy
|
||||
|
||||
# Load platforms for the devices in the ISY controller that we support.
|
||||
for platform in SUPPORTED_PLATFORMS:
|
||||
discovery.load_platform(hass, platform, DOMAIN, {}, config)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
)
|
||||
|
||||
def _start_auto_update() -> None:
|
||||
"""Start isy auto update."""
|
||||
_LOGGER.debug("ISY Starting Event Stream and automatic updates.")
|
||||
isy.auto_update = True
|
||||
|
||||
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
|
||||
|
||||
isy.auto_update = True
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_import_options_from_data_if_missing(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
options = dict(entry.options)
|
||||
modified = False
|
||||
for importable_option in [
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
]:
|
||||
if importable_option not in entry.options and importable_option in entry.data:
|
||||
options[importable_option] = entry.data[importable_option]
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
hass.config_entries.async_update_entry(entry, options=options)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in SUPPORTED_PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
hass_isy_data = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
isy = hass_isy_data[ISY994_ISY]
|
||||
|
||||
def _stop_auto_update() -> None:
|
||||
"""Start isy auto update."""
|
||||
_LOGGER.debug("ISY Stopping Event Stream and automatic updates.")
|
||||
isy.auto_update = False
|
||||
|
||||
await hass.async_add_executor_job(_stop_auto_update)
|
||||
|
||||
hass_isy_data[UNDO_UPDATE_LISTENER]()
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
@ -23,20 +23,24 @@ from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
BINARY_SENSOR_DEVICE_TYPES_ISY,
|
||||
BINARY_SENSOR_DEVICE_TYPES_ZWAVE,
|
||||
DOMAIN as ISY994_DOMAIN,
|
||||
ISY994_NODES,
|
||||
ISY994_PROGRAMS,
|
||||
TYPE_CATEGORY_CLIMATE,
|
||||
)
|
||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
DEVICE_PARENT_REQUIRED = [
|
||||
DEVICE_CLASS_OPENING,
|
||||
@ -56,15 +60,18 @@ SUBNODE_MOTION_DISABLED = (13, 19) # Int->13 or Hex->0xD depending on firmware
|
||||
TYPE_INSTEON_MOTION = ("16.1.", "16.22.")
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 binary sensor platform."""
|
||||
devices = []
|
||||
devices_by_address = {}
|
||||
child_nodes = []
|
||||
|
||||
for node in hass.data[ISY994_NODES][BINARY_SENSOR]:
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
for node in hass_isy_data[ISY994_NODES][BINARY_SENSOR]:
|
||||
device_class, device_type = _detect_device_type_and_class(node)
|
||||
if node.protocol == PROTO_INSTEON:
|
||||
if node.parent_node is not None:
|
||||
@ -162,10 +169,11 @@ def setup_platform(
|
||||
device = ISYBinarySensorEntity(node, device_class)
|
||||
devices.append(device)
|
||||
|
||||
for name, status, _ in hass.data[ISY994_PROGRAMS][BINARY_SENSOR]:
|
||||
for name, status, _ in hass_isy_data[ISY994_PROGRAMS][BINARY_SENSOR]:
|
||||
devices.append(ISYBinarySensorProgramEntity(name, status))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, BINARY_SENSOR, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
def _detect_device_type_and_class(node: Union[Group, Node]) -> (str, str):
|
||||
@ -235,7 +243,7 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
|
||||
|
||||
Often times, a single device is represented by multiple nodes in the ISY,
|
||||
allowing for different nuances in how those devices report their on and
|
||||
off events. This class turns those multiple nodes in to a single Home
|
||||
off events. This class turns those multiple nodes into a single Home
|
||||
Assistant entity and handles both ways that ISY binary sensors can work.
|
||||
"""
|
||||
|
||||
|
180
homeassistant/components/isy994/config_flow.py
Normal file
180
homeassistant/components/isy994/config_flow.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Config flow for Universal Devices ISY994 integration."""
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyisy.configuration import Configuration
|
||||
from pyisy.connection import Connection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import (
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_TLS_VER,
|
||||
DEFAULT_IGNORE_STRING,
|
||||
DEFAULT_RESTORE_LIGHT_STATE,
|
||||
DEFAULT_SENSOR_STRING,
|
||||
DEFAULT_TLS_VERSION,
|
||||
)
|
||||
from .const import DOMAIN # pylint:disable=unused-import
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
user = data[CONF_USERNAME]
|
||||
password = data[CONF_PASSWORD]
|
||||
host = urlparse(data[CONF_HOST])
|
||||
tls_version = data.get(CONF_TLS_VER)
|
||||
|
||||
if host.scheme == "http":
|
||||
https = False
|
||||
port = host.port or 80
|
||||
elif host.scheme == "https":
|
||||
https = True
|
||||
port = host.port or 443
|
||||
else:
|
||||
_LOGGER.error("isy994 host value in configuration is invalid")
|
||||
raise InvalidHost
|
||||
|
||||
# Connect to ISY controller.
|
||||
isy_conf = await hass.async_add_executor_job(
|
||||
_fetch_isy_configuration,
|
||||
host.hostname,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
https,
|
||||
tls_version,
|
||||
host.path,
|
||||
)
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
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,
|
||||
log=_LOGGER,
|
||||
webroot=webroot,
|
||||
)
|
||||
except ValueError as err:
|
||||
raise InvalidAuth(err.args[0])
|
||||
|
||||
return Configuration(log=_LOGGER, xml=isy_conn.get_config())
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Universal Devices ISY994."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
info = None
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidHost:
|
||||
errors["base"] = "invalid_host"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if "base" not in errors:
|
||||
await self.async_set_unique_id(info["uuid"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle a option flow for isy994."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
options = self.config_entry.options
|
||||
restore_light_state = options.get(
|
||||
CONF_RESTORE_LIGHT_STATE, DEFAULT_RESTORE_LIGHT_STATE
|
||||
)
|
||||
ignore_string = options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
|
||||
sensor_string = options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
|
||||
|
||||
options_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_IGNORE_STRING, default=ignore_string): str,
|
||||
vol.Optional(CONF_SENSOR_STRING, default=sensor_string): str,
|
||||
vol.Required(
|
||||
CONF_RESTORE_LIGHT_STATE, default=restore_light_state
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=options_schema)
|
||||
|
||||
|
||||
class InvalidHost(exceptions.HomeAssistantError):
|
||||
"""Error to indicate the host value is invalid."""
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
@ -98,9 +98,11 @@ MANUFACTURER = "Universal Devices, Inc"
|
||||
CONF_IGNORE_STRING = "ignore_string"
|
||||
CONF_SENSOR_STRING = "sensor_string"
|
||||
CONF_TLS_VER = "tls"
|
||||
CONF_RESTORE_LIGHT_STATE = "restore_light_state"
|
||||
|
||||
DEFAULT_IGNORE_STRING = "{IGNORE ME}"
|
||||
DEFAULT_SENSOR_STRING = "sensor"
|
||||
DEFAULT_RESTORE_LIGHT_STATE = False
|
||||
DEFAULT_TLS_VERSION = 1.1
|
||||
DEFAULT_PROGRAM_STRING = "HA."
|
||||
|
||||
@ -136,12 +138,16 @@ TYPE_CATEGORY_POOL_CTL = "6."
|
||||
TYPE_CATEGORY_SENSOR_ACTUATORS = "7."
|
||||
TYPE_CATEGORY_ENERGY_MGMT = "9."
|
||||
TYPE_CATEGORY_COVER = "14."
|
||||
TYPE_CATEOGRY_LOCK = "15."
|
||||
TYPE_CATEGORY_LOCK = "15."
|
||||
TYPE_CATEGORY_SAFETY = "16."
|
||||
TYPE_CATEGORY_X10 = "113."
|
||||
|
||||
UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
|
||||
# Do not use the Home Assistant consts for the states here - we're matching exact API
|
||||
# responses, not using them for Home Assistant states
|
||||
# Insteon Types: https://www.universal-devices.com/developers/wsdk/5.0.4/1_fam.xml
|
||||
# Z-Wave Categories: https://www.universal-devices.com/developers/wsdk/5.0.4/4_fam.xml
|
||||
NODE_FILTERS = {
|
||||
BINARY_SENSOR: {
|
||||
FILTER_UOM: [],
|
||||
@ -191,7 +197,7 @@ NODE_FILTERS = {
|
||||
FILTER_UOM: ["11"],
|
||||
FILTER_STATES: ["locked", "unlocked"],
|
||||
FILTER_NODE_DEF_ID: ["DoorLock"],
|
||||
FILTER_INSTEON_TYPE: [TYPE_CATEOGRY_LOCK, "4.64."],
|
||||
FILTER_INSTEON_TYPE: [TYPE_CATEGORY_LOCK, "4.64."],
|
||||
FILTER_ZWAVE_CAT: ["111"],
|
||||
},
|
||||
FAN: {
|
||||
|
@ -4,26 +4,37 @@ from typing import Callable
|
||||
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||
|
||||
from homeassistant.components.cover import DOMAIN as COVER, CoverEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||
from .const import _LOGGER, UOM_TO_STATES
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
DOMAIN as ISY994_DOMAIN,
|
||||
ISY994_NODES,
|
||||
ISY994_PROGRAMS,
|
||||
UOM_TO_STATES,
|
||||
)
|
||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 cover platform."""
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
devices = []
|
||||
for node in hass.data[ISY994_NODES][COVER]:
|
||||
for node in hass_isy_data[ISY994_NODES][COVER]:
|
||||
devices.append(ISYCoverEntity(node))
|
||||
|
||||
for name, status, actions in hass.data[ISY994_PROGRAMS][COVER]:
|
||||
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][COVER]:
|
||||
devices.append(ISYCoverProgramEntity(name, status, actions))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, COVER, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYCoverEntity(ISYNodeEntity, CoverEntity):
|
||||
|
@ -56,6 +56,13 @@ class ISYEntity(Entity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Get the unique identifier of the device."""
|
||||
if hasattr(self._node, "address"):
|
||||
return f"{self._node.isy.configuration['uuid']}_{self._node.address}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def old_unique_id(self) -> str:
|
||||
"""Get the old unique identifier of the device."""
|
||||
if hasattr(self._node, "address"):
|
||||
return self._node.address
|
||||
return None
|
||||
|
@ -10,11 +10,12 @@ from homeassistant.components.fan import (
|
||||
SUPPORT_SET_SPEED,
|
||||
FanEntity,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
|
||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
VALUE_TO_STATE = {
|
||||
0: SPEED_OFF,
|
||||
@ -30,19 +31,23 @@ for key in VALUE_TO_STATE:
|
||||
STATE_TO_VALUE[VALUE_TO_STATE[key]] = key
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 fan platform."""
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
devices = []
|
||||
|
||||
for node in hass.data[ISY994_NODES][FAN]:
|
||||
for node in hass_isy_data[ISY994_NODES][FAN]:
|
||||
devices.append(ISYFanEntity(node))
|
||||
|
||||
for name, status, actions in hass.data[ISY994_PROGRAMS][FAN]:
|
||||
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][FAN]:
|
||||
devices.append(ISYFanProgramEntity(name, status, actions))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, FAN, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYFanEntity(ISYNodeEntity, FanEntity):
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Sorting helpers for ISY994 device classifications."""
|
||||
from typing import Union
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from pyisy.constants import (
|
||||
PROTO_GROUP,
|
||||
@ -16,11 +16,13 @@ from homeassistant.components.fan import DOMAIN as FAN
|
||||
from homeassistant.components.light import DOMAIN as LIGHT
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH
|
||||
from homeassistant.helpers.entity_registry import async_get_registry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
DEFAULT_PROGRAM_STRING,
|
||||
DOMAIN,
|
||||
FILTER_INSTEON_TYPE,
|
||||
FILTER_NODE_DEF_ID,
|
||||
FILTER_STATES,
|
||||
@ -48,7 +50,7 @@ SUBNODE_IOLINC_RELAY = 2
|
||||
|
||||
|
||||
def _check_for_node_def(
|
||||
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
|
||||
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
|
||||
) -> bool:
|
||||
"""Check if the node matches the node_def_id for any platforms.
|
||||
|
||||
@ -64,14 +66,14 @@ def _check_for_node_def(
|
||||
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
|
||||
for platform in platforms:
|
||||
if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]:
|
||||
hass.data[ISY994_NODES][platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][platform].append(node)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_for_insteon_type(
|
||||
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
|
||||
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
|
||||
) -> bool:
|
||||
"""Check if the node matches the Insteon type for any platforms.
|
||||
|
||||
@ -102,7 +104,7 @@ def _check_for_insteon_type(
|
||||
|
||||
# FanLinc, which has a light module as one of its nodes.
|
||||
if platform == FAN and subnode_id == SUBNODE_FANLINC_LIGHT:
|
||||
hass.data[ISY994_NODES][LIGHT].append(node)
|
||||
hass_isy_data[ISY994_NODES][LIGHT].append(node)
|
||||
return True
|
||||
|
||||
# IOLincs which have a sensor and relay on 2 different nodes
|
||||
@ -111,7 +113,7 @@ def _check_for_insteon_type(
|
||||
and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
|
||||
and subnode_id == SUBNODE_IOLINC_RELAY
|
||||
):
|
||||
hass.data[ISY994_NODES][SWITCH].append(node)
|
||||
hass_isy_data[ISY994_NODES][SWITCH].append(node)
|
||||
return True
|
||||
|
||||
# Smartenit EZIO2X4
|
||||
@ -120,17 +122,17 @@ def _check_for_insteon_type(
|
||||
and device_type.startswith(TYPE_EZIO2X4)
|
||||
and subnode_id in SUBNODE_EZIO2X4_SENSORS
|
||||
):
|
||||
hass.data[ISY994_NODES][BINARY_SENSOR].append(node)
|
||||
hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node)
|
||||
return True
|
||||
|
||||
hass.data[ISY994_NODES][platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][platform].append(node)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_for_zwave_cat(
|
||||
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
|
||||
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
|
||||
) -> bool:
|
||||
"""Check if the node matches the ISY Z-Wave Category for any platforms.
|
||||
|
||||
@ -154,14 +156,14 @@ def _check_for_zwave_cat(
|
||||
]
|
||||
):
|
||||
|
||||
hass.data[ISY994_NODES][platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][platform].append(node)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_for_uom_id(
|
||||
hass: HomeAssistantType,
|
||||
hass_isy_data: dict,
|
||||
node: Union[Group, Node],
|
||||
single_platform: str = None,
|
||||
uom_list: list = None,
|
||||
@ -182,21 +184,21 @@ def _check_for_uom_id(
|
||||
|
||||
if uom_list:
|
||||
if node_uom in uom_list:
|
||||
hass.data[ISY994_NODES][single_platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][single_platform].append(node)
|
||||
return True
|
||||
return False
|
||||
|
||||
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
|
||||
for platform in platforms:
|
||||
if node_uom in NODE_FILTERS[platform][FILTER_UOM]:
|
||||
hass.data[ISY994_NODES][platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][platform].append(node)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_for_states_in_uom(
|
||||
hass: HomeAssistantType,
|
||||
hass_isy_data: dict,
|
||||
node: Union[Group, Node],
|
||||
single_platform: str = None,
|
||||
states_list: list = None,
|
||||
@ -219,26 +221,24 @@ def _check_for_states_in_uom(
|
||||
|
||||
if states_list:
|
||||
if node_uom == set(states_list):
|
||||
hass.data[ISY994_NODES][single_platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][single_platform].append(node)
|
||||
return True
|
||||
return False
|
||||
|
||||
platforms = SUPPORTED_PLATFORMS if not single_platform else [single_platform]
|
||||
for platform in platforms:
|
||||
if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]):
|
||||
hass.data[ISY994_NODES][platform].append(node)
|
||||
hass_isy_data[ISY994_NODES][platform].append(node)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_sensor_a_binary_sensor(
|
||||
hass: HomeAssistantType, node: Union[Group, Node]
|
||||
) -> bool:
|
||||
def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Union[Group, Node]) -> bool:
|
||||
"""Determine if the given sensor node should be a binary_sensor."""
|
||||
if _check_for_node_def(hass, node, single_platform=BINARY_SENSOR):
|
||||
if _check_for_node_def(hass_isy_data, node, single_platform=BINARY_SENSOR):
|
||||
return True
|
||||
if _check_for_insteon_type(hass, node, single_platform=BINARY_SENSOR):
|
||||
if _check_for_insteon_type(hass_isy_data, node, single_platform=BINARY_SENSOR):
|
||||
return True
|
||||
|
||||
# For the next two checks, we're providing our own set of uoms that
|
||||
@ -246,11 +246,14 @@ def _is_sensor_a_binary_sensor(
|
||||
# checks in the context of already knowing that this is definitely a
|
||||
# sensor device.
|
||||
if _check_for_uom_id(
|
||||
hass, node, single_platform=BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS
|
||||
hass_isy_data, node, single_platform=BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS
|
||||
):
|
||||
return True
|
||||
if _check_for_states_in_uom(
|
||||
hass, node, single_platform=BINARY_SENSOR, states_list=BINARY_SENSOR_ISY_STATES
|
||||
hass_isy_data,
|
||||
node,
|
||||
single_platform=BINARY_SENSOR,
|
||||
states_list=BINARY_SENSOR_ISY_STATES,
|
||||
):
|
||||
return True
|
||||
|
||||
@ -258,10 +261,7 @@ def _is_sensor_a_binary_sensor(
|
||||
|
||||
|
||||
def _categorize_nodes(
|
||||
hass: HomeAssistantType,
|
||||
nodes: Nodes,
|
||||
ignore_identifier: str,
|
||||
sensor_identifier: str,
|
||||
hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
|
||||
) -> None:
|
||||
"""Sort the nodes to their proper platforms."""
|
||||
for (path, node) in nodes:
|
||||
@ -271,37 +271,36 @@ def _categorize_nodes(
|
||||
continue
|
||||
|
||||
if hasattr(node, "protocol") and node.protocol == PROTO_GROUP:
|
||||
hass.data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
|
||||
hass_isy_data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
|
||||
continue
|
||||
|
||||
if sensor_identifier in path or sensor_identifier in node.name:
|
||||
# User has specified to treat this as a sensor. First we need to
|
||||
# determine if it should be a binary_sensor.
|
||||
if _is_sensor_a_binary_sensor(hass, node):
|
||||
if _is_sensor_a_binary_sensor(hass_isy_data, node):
|
||||
continue
|
||||
|
||||
hass.data[ISY994_NODES][SENSOR].append(node)
|
||||
hass_isy_data[ISY994_NODES][SENSOR].append(node)
|
||||
continue
|
||||
|
||||
# We have a bunch of different methods for determining the device type,
|
||||
# each of which works with different ISY firmware versions or device
|
||||
# family. The order here is important, from most reliable to least.
|
||||
if _check_for_node_def(hass, node):
|
||||
if _check_for_node_def(hass_isy_data, node):
|
||||
continue
|
||||
if _check_for_insteon_type(hass, node):
|
||||
if _check_for_insteon_type(hass_isy_data, node):
|
||||
continue
|
||||
if _check_for_zwave_cat(hass, node):
|
||||
if _check_for_zwave_cat(hass_isy_data, node):
|
||||
continue
|
||||
if _check_for_uom_id(hass, node):
|
||||
if _check_for_uom_id(hass_isy_data, node):
|
||||
continue
|
||||
if _check_for_states_in_uom(hass, node):
|
||||
if _check_for_states_in_uom(hass_isy_data, node):
|
||||
continue
|
||||
|
||||
# Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes.
|
||||
hass.data[ISY994_NODES][SENSOR].append(node)
|
||||
hass_isy_data[ISY994_NODES][SENSOR].append(node)
|
||||
|
||||
|
||||
def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
|
||||
def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None:
|
||||
"""Categorize the ISY994 programs."""
|
||||
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
||||
folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
|
||||
@ -334,4 +333,36 @@ def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
|
||||
continue
|
||||
|
||||
entity = (entity_folder.name, status, actions)
|
||||
hass.data[ISY994_PROGRAMS][platform].append(entity)
|
||||
hass_isy_data[ISY994_PROGRAMS][platform].append(entity)
|
||||
|
||||
|
||||
async def migrate_old_unique_ids(
|
||||
hass: HomeAssistantType, platform: str, devices: Optional[List[Any]]
|
||||
) -> None:
|
||||
"""Migrate to new controller-specific unique ids."""
|
||||
registry = await async_get_registry(hass)
|
||||
|
||||
for device in devices:
|
||||
old_entity_id = registry.async_get_entity_id(
|
||||
platform, DOMAIN, device.old_unique_id
|
||||
)
|
||||
if old_entity_id is not None:
|
||||
_LOGGER.debug(
|
||||
"Migrating unique_id from [%s] to [%s]",
|
||||
device.old_unique_id,
|
||||
device.unique_id,
|
||||
)
|
||||
registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id)
|
||||
|
||||
old_entity_id_2 = registry.async_get_entity_id(
|
||||
platform, DOMAIN, device.unique_id.replace(":", "")
|
||||
)
|
||||
if old_entity_id_2 is not None:
|
||||
_LOGGER.debug(
|
||||
"Migrating unique_id from [%s] to [%s]",
|
||||
device.unique_id.replace(":", ""),
|
||||
device.unique_id,
|
||||
)
|
||||
registry.async_update_entity(
|
||||
old_entity_id_2, new_unique_id=device.unique_id
|
||||
)
|
||||
|
@ -8,35 +8,49 @@ from homeassistant.components.light import (
|
||||
SUPPORT_BRIGHTNESS,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES
|
||||
from .const import _LOGGER
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
DOMAIN as ISY994_DOMAIN,
|
||||
ISY994_NODES,
|
||||
)
|
||||
from .entity import ISYNodeEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
ATTR_LAST_BRIGHTNESS = "last_brightness"
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 light platform."""
|
||||
devices = []
|
||||
for node in hass.data[ISY994_NODES][LIGHT]:
|
||||
devices.append(ISYLightEntity(node))
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
isy_options = entry.options
|
||||
restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False)
|
||||
|
||||
add_entities(devices)
|
||||
devices = []
|
||||
for node in hass_isy_data[ISY994_NODES][LIGHT]:
|
||||
devices.append(ISYLightEntity(node, restore_light_state))
|
||||
|
||||
await migrate_old_unique_ids(hass, LIGHT, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of an ISY994 light device."""
|
||||
|
||||
def __init__(self, node) -> None:
|
||||
def __init__(self, node, restore_light_state) -> None:
|
||||
"""Initialize the ISY994 light device."""
|
||||
super().__init__(node)
|
||||
self._last_brightness = None
|
||||
self._restore_light_state = restore_light_state
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
@ -65,16 +79,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
# pylint: disable=arguments-differ
|
||||
def turn_on(self, brightness=None, **kwargs) -> None:
|
||||
"""Send the turn on command to the ISY994 light device."""
|
||||
if brightness is None and self._last_brightness:
|
||||
if self._restore_light_state and brightness is None and self._last_brightness:
|
||||
brightness = self._last_brightness
|
||||
if not self._node.turn_on(val=brightness):
|
||||
_LOGGER.debug("Unable to turn on light")
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_BRIGHTNESS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self) -> Dict:
|
||||
"""Return the light attributes."""
|
||||
@ -82,6 +91,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
attribs[ATTR_LAST_BRIGHTNESS] = self._last_brightness
|
||||
return attribs
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_BRIGHTNESS
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last_brightness on restart."""
|
||||
await super().async_added_to_hass()
|
||||
|
@ -4,28 +4,33 @@ from typing import Callable
|
||||
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||
|
||||
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
|
||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
VALUE_TO_STATE = {0: STATE_UNLOCKED, 100: STATE_LOCKED}
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 lock platform."""
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
devices = []
|
||||
for node in hass.data[ISY994_NODES][LOCK]:
|
||||
for node in hass_isy_data[ISY994_NODES][LOCK]:
|
||||
devices.append(ISYLockEntity(node))
|
||||
|
||||
for name, status, actions in hass.data[ISY994_PROGRAMS][LOCK]:
|
||||
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][LOCK]:
|
||||
devices.append(ISYLockProgramEntity(name, status, actions))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, LOCK, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||
|
@ -3,5 +3,6 @@
|
||||
"name": "Universal Devices ISY994",
|
||||
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
||||
"requirements": ["pyisy==2.0.2"],
|
||||
"codeowners": ["@bdraco", "@shbatm"]
|
||||
"codeowners": ["@bdraco", "@shbatm"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
@ -4,25 +4,36 @@ from typing import Callable
|
||||
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES
|
||||
from .const import _LOGGER, UOM_FRIENDLY_NAME, UOM_TO_STATES
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
DOMAIN as ISY994_DOMAIN,
|
||||
ISY994_NODES,
|
||||
UOM_FRIENDLY_NAME,
|
||||
UOM_TO_STATES,
|
||||
)
|
||||
from .entity import ISYNodeEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 sensor platform."""
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
devices = []
|
||||
|
||||
for node in hass.data[ISY994_NODES][SENSOR]:
|
||||
for node in hass_isy_data[ISY994_NODES][SENSOR]:
|
||||
_LOGGER.debug("Loading %s", node.name)
|
||||
devices.append(ISYSensorEntity(node))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, SENSOR, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYSensorEntity(ISYNodeEntity):
|
||||
|
39
homeassistant/components/isy994/strings.json
Normal file
39
homeassistant/components/isy994/strings.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"title": "Universal Devices ISY994",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"host": "URL",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"tls": "The TLS version of the ISY controller."
|
||||
},
|
||||
"description": "The host entry must be in full URL format, e.g., http://192.168.10.100:80",
|
||||
"title": "Connect to your ISY994"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_host": "The host entry was not in full URL format, e.g., http://192.168.10.100:80"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "ISY994 Options",
|
||||
"description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
|
||||
"data": {
|
||||
"sensor_string": "Node Sensor String",
|
||||
"ignore_string": "Ignore String",
|
||||
"restore_light_state": "Restore Light Brightness"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -4,26 +4,31 @@ from typing import Callable
|
||||
from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS
|
||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||
from .helpers import migrate_old_unique_ids
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None
|
||||
):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[list], None],
|
||||
) -> bool:
|
||||
"""Set up the ISY994 switch platform."""
|
||||
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
|
||||
devices = []
|
||||
for node in hass.data[ISY994_NODES][SWITCH]:
|
||||
for node in hass_isy_data[ISY994_NODES][SWITCH]:
|
||||
devices.append(ISYSwitchEntity(node))
|
||||
|
||||
for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]:
|
||||
for name, status, actions in hass_isy_data[ISY994_PROGRAMS][SWITCH]:
|
||||
devices.append(ISYSwitchProgramEntity(name, status, actions))
|
||||
|
||||
add_entities(devices)
|
||||
await migrate_old_unique_ids(hass, SWITCH, devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class ISYSwitchEntity(ISYNodeEntity, SwitchEntity):
|
||||
|
39
homeassistant/components/isy994/translations/en.json
Normal file
39
homeassistant/components/isy994/translations/en.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"title": "Universal Devices ISY994",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "Username",
|
||||
"host": "URL",
|
||||
"password": "Password",
|
||||
"tls": "The TLS version of the ISY controller."
|
||||
},
|
||||
"description": "The host entry must be in full URL format, e.g., http://192.168.10.100:80",
|
||||
"title": "Connect to your ISY994"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"unknown": "Unexpected error",
|
||||
"cannot_connect": "Failed to connect, please try again",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"invalid_host": "The host entry was not in full URL format, e.g., http://192.168.10.100:80"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "ISY994 Options",
|
||||
"description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.",
|
||||
"data": {
|
||||
"sensor_string": "Node Sensor String",
|
||||
"ignore_string": "Ignore String",
|
||||
"restore_light_state": "Restore Light Brightness"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -68,6 +68,7 @@ FLOWS = [
|
||||
"ipp",
|
||||
"iqvia",
|
||||
"islamic_prayer_times",
|
||||
"isy994",
|
||||
"izone",
|
||||
"juicenet",
|
||||
"konnected",
|
||||
|
@ -572,6 +572,9 @@ pyipp==0.10.1
|
||||
# homeassistant.components.iqvia
|
||||
pyiqvia==0.2.1
|
||||
|
||||
# homeassistant.components.isy994
|
||||
pyisy==2.0.2
|
||||
|
||||
# homeassistant.components.kira
|
||||
pykira==0.1.1
|
||||
|
||||
|
1
tests/components/isy994/__init__.py
Normal file
1
tests/components/isy994/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Universal Devices ISY994 integration."""
|
206
tests/components/isy994/test_config_flow.py
Normal file
206
tests/components/isy994/test_config_flow.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Test the Universal Devices ISY994 config flow."""
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow, setup
|
||||
from homeassistant.components.isy994.config_flow import CannotConnect
|
||||
from homeassistant.components.isy994.const import (
|
||||
CONF_IGNORE_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE,
|
||||
CONF_SENSOR_STRING,
|
||||
CONF_TLS_VER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from tests.async_mock import patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_HOSTNAME = "1.1.1.1"
|
||||
MOCK_USERNAME = "test-username"
|
||||
MOCK_PASSWORD = "test-password"
|
||||
|
||||
# Don't use the integration defaults here to make sure they're being set correctly.
|
||||
MOCK_TLS_VERSION = 1.2
|
||||
MOCK_IGNORE_STRING = "{IGNOREME}"
|
||||
MOCK_RESTORE_LIGHT_STATE = True
|
||||
MOCK_SENSOR_STRING = "IMASENSOR"
|
||||
|
||||
MOCK_USER_INPUT = {
|
||||
"host": f"http://{MOCK_HOSTNAME}",
|
||||
"username": MOCK_USERNAME,
|
||||
"password": MOCK_PASSWORD,
|
||||
"tls": MOCK_TLS_VERSION,
|
||||
}
|
||||
MOCK_IMPORT_BASIC_CONFIG = {
|
||||
CONF_HOST: f"http://{MOCK_HOSTNAME}",
|
||||
CONF_USERNAME: MOCK_USERNAME,
|
||||
CONF_PASSWORD: MOCK_PASSWORD,
|
||||
}
|
||||
MOCK_IMPORT_FULL_CONFIG = {
|
||||
CONF_HOST: f"http://{MOCK_HOSTNAME}",
|
||||
CONF_USERNAME: MOCK_USERNAME,
|
||||
CONF_PASSWORD: MOCK_PASSWORD,
|
||||
CONF_IGNORE_STRING: MOCK_IGNORE_STRING,
|
||||
CONF_RESTORE_LIGHT_STATE: MOCK_RESTORE_LIGHT_STATE,
|
||||
CONF_SENSOR_STRING: MOCK_SENSOR_STRING,
|
||||
CONF_TLS_VER: MOCK_TLS_VERSION,
|
||||
}
|
||||
|
||||
MOCK_DEVICE_NAME = "Name of the device"
|
||||
MOCK_UUID = "CE:FB:72:31:B7:B9"
|
||||
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"
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistantType):
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
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(
|
||||
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 = "<xml></xml>"
|
||||
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_USER_INPUT,
|
||||
)
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})"
|
||||
assert result2["result"].unique_id == MOCK_UUID
|
||||
assert result2["data"] == MOCK_USER_INPUT
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_host(hass: HomeAssistantType):
|
||||
"""Test we handle invalid host."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": MOCK_HOSTNAME, # Test with missing protocol (http://)
|
||||
"username": MOCK_USERNAME,
|
||||
"password": MOCK_PASSWORD,
|
||||
"tls": MOCK_TLS_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "invalid_host"}
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistantType):
|
||||
"""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(
|
||||
PATCH_CONNECTION, side_effect=ValueError("PyISY could not connect to the ISY."),
|
||||
):
|
||||
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": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistantType):
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
with patch(PATCH_CONFIGURATION), patch(
|
||||
PATCH_CONNECTION, side_effect=CannotConnect,
|
||||
):
|
||||
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_existing_config_entry(hass: HomeAssistantType):
|
||||
"""Test if config entry already exists."""
|
||||
MockConfigEntry(domain=DOMAIN, unique_id=MOCK_UUID).add_to_hass(hass)
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
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 = "<xml></xml>"
|
||||
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_USER_INPUT,
|
||||
)
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
|
||||
|
||||
async def test_import_flow_some_fields(hass: HomeAssistantType) -> 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(
|
||||
PATCH_ASYNC_SETUP_ENTRY, return_value=True,
|
||||
):
|
||||
isy_conn = mock_connection_class.return_value
|
||||
isy_conn.get_config.return_value = "<xml></xml>"
|
||||
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_BASIC_CONFIG,
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}"
|
||||
assert result["data"][CONF_USERNAME] == MOCK_USERNAME
|
||||
assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD
|
||||
|
||||
|
||||
async def test_import_flow_all_fields(hass: HomeAssistantType) -> 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(
|
||||
PATCH_ASYNC_SETUP_ENTRY, return_value=True,
|
||||
):
|
||||
isy_conn = mock_connection_class.return_value
|
||||
isy_conn.get_config.return_value = "<xml></xml>"
|
||||
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_FULL_CONFIG,
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}"
|
||||
assert result["data"][CONF_USERNAME] == MOCK_USERNAME
|
||||
assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD
|
||||
assert result["data"][CONF_IGNORE_STRING] == MOCK_IGNORE_STRING
|
||||
assert result["data"][CONF_RESTORE_LIGHT_STATE] == MOCK_RESTORE_LIGHT_STATE
|
||||
assert result["data"][CONF_SENSOR_STRING] == MOCK_SENSOR_STRING
|
||||
assert result["data"][CONF_TLS_VER] == MOCK_TLS_VERSION
|
Loading…
x
Reference in New Issue
Block a user