From 255a8362a1e6ef928be2ec056d17e67fde13d573 Mon Sep 17 00:00:00 2001 From: shbatm Date: Wed, 11 Jan 2023 22:07:44 -0600 Subject: [PATCH] Consolidate device info and clean-up ISY994 code base (#85657) Co-authored-by: J. Nick Koston --- homeassistant/components/isy994/__init__.py | 106 +++++++------ .../components/isy994/binary_sensor.py | 65 +++++--- homeassistant/components/isy994/button.py | 145 +++++++++--------- homeassistant/components/isy994/climate.py | 17 +- homeassistant/components/isy994/const.py | 42 +++-- homeassistant/components/isy994/cover.py | 17 +- homeassistant/components/isy994/entity.py | 85 ++-------- homeassistant/components/isy994/fan.py | 12 +- homeassistant/components/isy994/helpers.py | 98 ++++++++---- homeassistant/components/isy994/light.py | 24 ++- homeassistant/components/isy994/lock.py | 12 +- homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/number.py | 53 ++----- homeassistant/components/isy994/sensor.py | 70 +++++---- homeassistant/components/isy994/services.py | 81 +++++----- homeassistant/components/isy994/switch.py | 17 +- .../components/isy994/system_health.py | 4 +- homeassistant/components/isy994/util.py | 67 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/isy994/test_system_health.py | 6 +- 21 files changed, 481 insertions(+), 446 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index a8b3d4e239e..b0c10b776e9 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -7,7 +7,6 @@ from urllib.parse import urlparse from aiohttp import CookieJar import async_timeout from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError -from pyisy.constants import PROTO_NETWORK_RESOURCE import voluptuous as vol from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + CONF_VARIABLES, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -22,11 +22,14 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, CONF_IGNORE_STRING, + CONF_NETWORK, CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, CONF_TLS_VER, @@ -36,28 +39,29 @@ from .const import ( DEFAULT_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, DOMAIN, - ISY994_ISY, - ISY994_NODES, - ISY994_PROGRAMS, - ISY994_VARIABLES, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, ISY_CONF_NAME, ISY_CONF_NETWORKING, - ISY_CONF_UUID, - ISY_CONN_ADDRESS, - ISY_CONN_PORT, - ISY_CONN_TLS, + ISY_DEVICES, + ISY_NET_RES, + ISY_NODES, + ISY_PROGRAMS, + ISY_ROOT, + ISY_ROOT_NODES, + ISY_VARIABLES, MANUFACTURER, + NODE_PLATFORMS, PLATFORMS, PROGRAM_PLATFORMS, + ROOT_NODE_PLATFORMS, SCHEME_HTTP, SCHEME_HTTPS, SENSOR_AUX, + VARIABLE_PLATFORMS, ) from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .services import async_setup_services, async_unload_services -from .util import unique_ids_for_config_entry_id CONFIG_SCHEMA = vol.Schema( { @@ -134,17 +138,12 @@ async def async_setup_entry( hass.data[DOMAIN][entry.entry_id] = {} hass_isy_data = hass.data[DOMAIN][entry.entry_id] - hass_isy_data[ISY994_NODES] = {SENSOR_AUX: [], PROTO_NETWORK_RESOURCE: []} - for platform in PLATFORMS: - hass_isy_data[ISY994_NODES][platform] = [] - - hass_isy_data[ISY994_PROGRAMS] = {} - for platform in PROGRAM_PLATFORMS: - hass_isy_data[ISY994_PROGRAMS][platform] = [] - - hass_isy_data[ISY994_VARIABLES] = {} - hass_isy_data[ISY994_VARIABLES][Platform.NUMBER] = [] - hass_isy_data[ISY994_VARIABLES][Platform.SENSOR] = [] + hass_isy_data[ISY_NODES] = {p: [] for p in (NODE_PLATFORMS + [SENSOR_AUX])} + hass_isy_data[ISY_ROOT_NODES] = {p: [] for p in ROOT_NODE_PLATFORMS} + hass_isy_data[ISY_PROGRAMS] = {p: [] for p in PROGRAM_PLATFORMS} + hass_isy_data[ISY_VARIABLES] = {p: [] for p in VARIABLE_PLATFORMS} + hass_isy_data[ISY_NET_RES] = [] + hass_isy_data[ISY_DEVICES] = {} isy_config = entry.data isy_options = entry.options @@ -218,17 +217,24 @@ async def async_setup_entry( # Categorize variables call to be removed with variable sensors in 2023.5.0 _categorize_variables(hass_isy_data, isy.variables, variable_identifier) # Gather ISY Variables to be added. Identifier used to enable by default. - numbers = hass_isy_data[ISY994_VARIABLES][Platform.NUMBER] - for vtype, vname, vid in isy.variables.children: - numbers.append((isy.variables[vtype][vid], variable_identifier in vname)) - if isy.configuration[ISY_CONF_NETWORKING]: + if len(isy.variables.children) > 0: + hass_isy_data[ISY_DEVICES][CONF_VARIABLES] = _create_service_device_info( + isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES + ) + numbers = hass_isy_data[ISY_VARIABLES][Platform.NUMBER] + for vtype, vname, vid in isy.variables.children: + numbers.append((isy.variables[vtype][vid], variable_identifier in vname)) + if isy.conf[ISY_CONF_NETWORKING]: + hass_isy_data[ISY_DEVICES][CONF_NETWORK] = _create_service_device_info( + isy, name=ISY_CONF_NETWORKING, unique_id=CONF_NETWORK + ) for resource in isy.networking.nobjs: - hass_isy_data[ISY994_NODES][PROTO_NETWORK_RESOURCE].append(resource) + hass_isy_data[ISY_NET_RES].append(resource) # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs _LOGGER.info(repr(isy.clock)) - hass_isy_data[ISY994_ISY] = isy + hass_isy_data[ISY_ROOT] = isy _async_get_or_create_isy_device_in_registry(hass, entry, isy) # Load platforms for the devices in the ISY controller that we support. @@ -280,29 +286,39 @@ def _async_import_options_from_data_if_missing( hass.config_entries.async_update_entry(entry, options=options) -@callback -def _async_isy_to_configuration_url(isy: ISY) -> str: - """Extract the configuration url from the isy.""" - connection_info = isy.conn.connection_info - proto = SCHEME_HTTPS if ISY_CONN_TLS in connection_info else SCHEME_HTTP - return f"{proto}://{connection_info[ISY_CONN_ADDRESS]}:{connection_info[ISY_CONN_PORT]}" - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY ) -> None: device_registry = dr.async_get(hass) - url = _async_isy_to_configuration_url(isy) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, isy.configuration[ISY_CONF_UUID])}, - identifiers={(DOMAIN, isy.configuration[ISY_CONF_UUID])}, + connections={(dr.CONNECTION_NETWORK_MAC, isy.uuid)}, + identifiers={(DOMAIN, isy.uuid)}, manufacturer=MANUFACTURER, - name=isy.configuration[ISY_CONF_NAME], - model=isy.configuration[ISY_CONF_MODEL], - sw_version=isy.configuration[ISY_CONF_FIRMWARE], - configuration_url=url, + name=isy.conf[ISY_CONF_NAME], + model=isy.conf[ISY_CONF_MODEL], + sw_version=isy.conf[ISY_CONF_FIRMWARE], + configuration_url=isy.conn.url, + ) + + +def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceInfo: + """Create device info for ISY service devices.""" + return DeviceInfo( + identifiers={ + ( + DOMAIN, + f"{isy.uuid}_{unique_id}", + ) + }, + manufacturer=MANUFACTURER, + name=f"{isy.conf[ISY_CONF_NAME]} {name}", + model=isy.conf[ISY_CONF_MODEL], + sw_version=isy.conf[ISY_CONF_FIRMWARE], + configuration_url=isy.conn.url, + via_device=(DOMAIN, isy.uuid), + entry_type=DeviceEntryType.SERVICE, ) @@ -314,7 +330,7 @@ async def async_unload_entry( hass_isy_data = hass.data[DOMAIN][entry.entry_id] - isy: ISY = hass_isy_data[ISY994_ISY] + isy: ISY = hass_isy_data[ISY_ROOT] _LOGGER.debug("ISY Stopping Event Stream and automatic updates") isy.websocket.stop() @@ -333,7 +349,7 @@ async def async_remove_config_entry_device( device_entry: dr.DeviceEntry, ) -> bool: """Remove ISY config entry from a device.""" + hass_isy_devices = hass.data[DOMAIN][config_entry.entry_id][ISY_DEVICES] return not device_entry.identifiers.intersection( - (DOMAIN, unique_id) - for unique_id in unique_ids_for_config_entry_id(hass, config_entry.entry_id) + (DOMAIN, unique_id) for unique_id in hass_isy_devices ) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 828688c3429..abd23fea6f7 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -21,6 +21,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity @@ -30,9 +31,10 @@ from .const import ( _LOGGER, BINARY_SENSOR_DEVICE_TYPES_ISY, BINARY_SENSOR_DEVICE_TYPES_ZWAVE, - DOMAIN as ISY994_DOMAIN, - ISY994_NODES, - ISY994_PROGRAMS, + DOMAIN, + ISY_DEVICES, + ISY_NODES, + ISY_PROGRAMS, SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, SUBNODE_DUSK_DAWN, @@ -70,27 +72,33 @@ async def async_setup_entry( | ISYBinarySensorHeartbeat | ISYBinarySensorProgramEntity, ] = {} - child_nodes: list[tuple[Node, BinarySensorDeviceClass | None, str | None]] = [] + child_nodes: list[ + tuple[Node, BinarySensorDeviceClass | None, str | None, DeviceInfo | None] + ] = [] entity: ISYInsteonBinarySensorEntity | ISYBinarySensorEntity | ISYBinarySensorHeartbeat | ISYBinarySensorProgramEntity - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] - for node in hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR]: + hass_isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + for node in hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR]: assert isinstance(node, Node) + device_info = devices.get(node.primary_node) device_class, device_type = _detect_device_type_and_class(node) if node.protocol == PROTO_INSTEON: if node.parent_node is not None: # We'll process the Insteon child nodes last, to ensure all parent # nodes have been processed - child_nodes.append((node, device_class, device_type)) + child_nodes.append((node, device_class, device_type, device_info)) continue - entity = ISYInsteonBinarySensorEntity(node, device_class) + entity = ISYInsteonBinarySensorEntity( + node, device_class, device_info=device_info + ) else: - entity = ISYBinarySensorEntity(node, device_class) + entity = ISYBinarySensorEntity(node, device_class, device_info=device_info) entities.append(entity) entities_by_address[node.address] = entity # Handle some special child node cases for Insteon Devices - for (node, device_class, device_type) in child_nodes: + for (node, device_class, device_type, device_info) in child_nodes: subnode_id = int(node.address.split(" ")[-1], 16) # Handle Insteon Thermostats if device_type is not None and device_type.startswith(TYPE_CATEGORY_CLIMATE): @@ -101,13 +109,13 @@ async def async_setup_entry( # As soon as the ISY Event Stream connects if it has a # valid state, it will be set. entity = ISYInsteonBinarySensorEntity( - node, BinarySensorDeviceClass.COLD, False + node, BinarySensorDeviceClass.COLD, False, device_info=device_info ) entities.append(entity) elif subnode_id == SUBNODE_CLIMATE_HEAT: # Subnode 3 is the "Heat Control" sensor entity = ISYInsteonBinarySensorEntity( - node, BinarySensorDeviceClass.HEAT, False + node, BinarySensorDeviceClass.HEAT, False, device_info=device_info ) entities.append(entity) continue @@ -138,7 +146,9 @@ async def async_setup_entry( assert isinstance(parent_entity, ISYInsteonBinarySensorEntity) # Subnode 4 is the heartbeat node, which we will # represent as a separate binary_sensor - entity = ISYBinarySensorHeartbeat(node, parent_entity) + entity = ISYBinarySensorHeartbeat( + node, parent_entity, device_info=device_info + ) parent_entity.add_heartbeat_device(entity) entities.append(entity) continue @@ -157,14 +167,17 @@ async def async_setup_entry( if subnode_id == SUBNODE_DUSK_DAWN: # Subnode 2 is the Dusk/Dawn sensor entity = ISYInsteonBinarySensorEntity( - node, BinarySensorDeviceClass.LIGHT + node, BinarySensorDeviceClass.LIGHT, device_info=device_info ) entities.append(entity) continue if subnode_id == SUBNODE_LOW_BATTERY: # Subnode 3 is the low battery node entity = ISYInsteonBinarySensorEntity( - node, BinarySensorDeviceClass.BATTERY, initial_state + node, + BinarySensorDeviceClass.BATTERY, + initial_state, + device_info=device_info, ) entities.append(entity) continue @@ -172,22 +185,27 @@ async def async_setup_entry( # Tamper Sub-node for MS II. Sometimes reported as "A" sometimes # reported as "10", which translate from Hex to 10 and 16 resp. entity = ISYInsteonBinarySensorEntity( - node, BinarySensorDeviceClass.PROBLEM, initial_state + node, + BinarySensorDeviceClass.PROBLEM, + initial_state, + device_info=device_info, ) entities.append(entity) continue if subnode_id in SUBNODE_MOTION_DISABLED: # Motion Disabled Sub-node for MS II ("D" or "13") - entity = ISYInsteonBinarySensorEntity(node) + entity = ISYInsteonBinarySensorEntity(node, device_info=device_info) entities.append(entity) continue # We don't yet have any special logic for other sensor # types, so add the nodes as individual devices - entity = ISYBinarySensorEntity(node, device_class) + entity = ISYBinarySensorEntity( + node, force_device_class=device_class, device_info=device_info + ) entities.append(entity) - for name, status, _ in hass_isy_data[ISY994_PROGRAMS][Platform.BINARY_SENSOR]: + for name, status, _ in hass_isy_data[ISY_PROGRAMS][Platform.BINARY_SENSOR]: entities.append(ISYBinarySensorProgramEntity(name, status)) async_add_entities(entities) @@ -225,9 +243,10 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): node: Node, force_device_class: BinarySensorDeviceClass | None = None, unknown_state: bool | None = None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the ISY binary sensor device.""" - super().__init__(node) + super().__init__(node, device_info=device_info) self._device_class = force_device_class @property @@ -260,9 +279,10 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): node: Node, force_device_class: BinarySensorDeviceClass | None = None, unknown_state: bool | None = None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the ISY binary sensor device.""" - super().__init__(node, force_device_class) + super().__init__(node, force_device_class, device_info=device_info) self._negative_node: Node | None = None self._heartbeat_device: ISYBinarySensorHeartbeat | None = None if self._node.status == ISY_VALUE_UNKNOWN: @@ -399,6 +419,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) | ISYBinarySensorEntity | ISYBinarySensorHeartbeat | ISYBinarySensorProgramEntity, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the ISY binary sensor device. @@ -409,7 +430,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) If the heartbeat is not received in 25 hours then the computed state is set to ON (Low Battery). """ - super().__init__(node) + super().__init__(node, device_info=device_info) self._parent_device = parent_device self._heartbeat_timer: CALLBACK_TYPE | None = None self._computed_state: bool | None = None diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index 7dbddafda24..66f7735829f 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -2,28 +2,24 @@ from __future__ import annotations from pyisy import ISY -from pyisy.constants import PROTO_INSTEON, PROTO_NETWORK_RESOURCE +from pyisy.constants import PROTO_INSTEON +from pyisy.networking import NetworkCommand from pyisy.nodes import Node from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import _async_isy_to_configuration_url from .const import ( - DOMAIN as ISY994_DOMAIN, - ISY994_ISY, - ISY994_NODES, - ISY_CONF_FIRMWARE, - ISY_CONF_MODEL, - ISY_CONF_NAME, - ISY_CONF_NETWORKING, - ISY_CONF_UUID, - MANUFACTURER, + CONF_NETWORK, + DOMAIN, + ISY_DEVICES, + ISY_NET_RES, + ISY_ROOT, + ISY_ROOT_NODES, ) @@ -33,107 +29,104 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" - hass_isy_data = hass.data[ISY994_DOMAIN][config_entry.entry_id] - isy: ISY = hass_isy_data[ISY994_ISY] - uuid = isy.configuration[ISY_CONF_UUID] + hass_isy_data = hass.data[DOMAIN][config_entry.entry_id] + isy: ISY = hass_isy_data[ISY_ROOT] + device_info = hass_isy_data[ISY_DEVICES] entities: list[ ISYNodeQueryButtonEntity | ISYNodeBeepButtonEntity | ISYNetworkResourceButtonEntity ] = [] - nodes: dict = hass_isy_data[ISY994_NODES] - for node in nodes[Platform.BUTTON]: - entities.append(ISYNodeQueryButtonEntity(node, f"{uuid}_{node.address}")) - if node.protocol == PROTO_INSTEON: - entities.append(ISYNodeBeepButtonEntity(node, f"{uuid}_{node.address}")) - for node in nodes[PROTO_NETWORK_RESOURCE]: + for node in hass_isy_data[ISY_ROOT_NODES][Platform.BUTTON]: entities.append( - ISYNetworkResourceButtonEntity(node, f"{uuid}_{PROTO_NETWORK_RESOURCE}") + ISYNodeQueryButtonEntity( + node=node, + name="Query", + unique_id=f"{isy.uuid}_{node.address}_query", + entity_category=EntityCategory.DIAGNOSTIC, + device_info=device_info[node.address], + ) + ) + if node.protocol == PROTO_INSTEON: + entities.append( + ISYNodeBeepButtonEntity( + node=node, + name="Beep", + unique_id=f"{isy.uuid}_{node.address}_beep", + entity_category=EntityCategory.DIAGNOSTIC, + device_info=device_info[node.address], + ) + ) + + for node in hass_isy_data[ISY_NET_RES]: + entities.append( + ISYNetworkResourceButtonEntity( + node=node, + name=node.name, + unique_id=f"{isy.uuid}_{CONF_NETWORK}_{node.address}", + device_info=device_info[CONF_NETWORK], + ) ) # Add entity to query full system - entities.append(ISYNodeQueryButtonEntity(isy, uuid)) + entities.append( + ISYNodeQueryButtonEntity( + node=isy, + name="Query", + unique_id=isy.uuid, + device_info=DeviceInfo(identifiers={(DOMAIN, isy.uuid)}), + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) async_add_entities(entities) -class ISYNodeQueryButtonEntity(ButtonEntity): - """Representation of a device query button entity.""" +class ISYNodeButtonEntity(ButtonEntity): + """Representation of an ISY/IoX device button entity.""" _attr_should_poll = False - _attr_entity_category = EntityCategory.CONFIG _attr_has_entity_name = True - def __init__(self, node: Node | ISY, base_unique_id: str) -> None: + def __init__( + self, + node: Node | ISY | NetworkCommand, + name: str, + unique_id: str, + device_info: DeviceInfo, + entity_category: EntityCategory | None = None, + ) -> None: """Initialize a query ISY device button entity.""" self._node = node # Entity class attributes - self._attr_name = "Query" - self._attr_unique_id = f"{base_unique_id}_query" - self._attr_device_info = DeviceInfo( - identifiers={(ISY994_DOMAIN, base_unique_id)} - ) + self._attr_name = name + self._attr_entity_category = entity_category + self._attr_unique_id = unique_id + self._attr_device_info = device_info + + +class ISYNodeQueryButtonEntity(ISYNodeButtonEntity): + """Representation of a device query button entity.""" async def async_press(self) -> None: """Press the button.""" await self._node.query() -class ISYNodeBeepButtonEntity(ButtonEntity): +class ISYNodeBeepButtonEntity(ISYNodeButtonEntity): """Representation of a device beep button entity.""" - _attr_should_poll = False - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True - - def __init__(self, node: Node, base_unique_id: str) -> None: - """Initialize a beep Insteon device button entity.""" - self._node = node - - # Entity class attributes - self._attr_name = "Beep" - self._attr_unique_id = f"{base_unique_id}_beep" - self._attr_device_info = DeviceInfo( - identifiers={(ISY994_DOMAIN, base_unique_id)} - ) - async def async_press(self) -> None: """Press the button.""" await self._node.beep() -class ISYNetworkResourceButtonEntity(ButtonEntity): +class ISYNetworkResourceButtonEntity(ISYNodeButtonEntity): """Representation of an ISY/IoX Network Resource button entity.""" - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__(self, node: Node, base_unique_id: str) -> None: - """Initialize an ISY network resource button entity.""" - self._node = node - - # Entity class attributes - self._attr_name = node.name - self._attr_unique_id = f"{base_unique_id}_{node.address}" - url = _async_isy_to_configuration_url(node.isy) - config = node.isy.configuration - self._attr_device_info = DeviceInfo( - identifiers={ - ( - ISY994_DOMAIN, - f"{config[ISY_CONF_UUID]}_{PROTO_NETWORK_RESOURCE}", - ) - }, - manufacturer=MANUFACTURER, - name=f"{config[ISY_CONF_NAME]} {ISY_CONF_NETWORKING}", - model=config[ISY_CONF_MODEL], - sw_version=config[ISY_CONF_FIRMWARE], - configuration_url=url, - via_device=(ISY994_DOMAIN, config[ISY_CONF_UUID]), - entry_type=DeviceEntryType.SERVICE, - ) + _attr_has_entity_name = False async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 612d411245f..c0d7a6d8524 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -35,15 +35,17 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( _LOGGER, - DOMAIN as ISY994_DOMAIN, + DOMAIN, HA_FAN_TO_ISY, HA_HVAC_TO_ISY, - ISY994_NODES, + ISY_DEVICES, ISY_HVAC_MODES, + ISY_NODES, UOM_FAN_MODES, UOM_HVAC_ACTIONS, UOM_HVAC_MODE_GENERIC, @@ -63,9 +65,10 @@ async def async_setup_entry( """Set up the ISY thermostat platform.""" entities = [] - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] - for node in hass_isy_data[ISY994_NODES][Platform.CLIMATE]: - entities.append(ISYThermostatEntity(node)) + hass_isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + for node in hass_isy_data[ISY_NODES][Platform.CLIMATE]: + entities.append(ISYThermostatEntity(node, devices.get(node.primary_node))) async_add_entities(entities) @@ -81,9 +84,9 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - def __init__(self, node: Node) -> None: + def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" - super().__init__(node) + super().__init__(node, device_info=device_info) self._uom = self._node.uom if isinstance(self._uom, list): self._uom = self._node.uom[0] diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 3df11f078ea..4485a53c8e8 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -58,6 +58,7 @@ DOMAIN = "isy994" MANUFACTURER = "Universal Devices, Inc" +CONF_NETWORK = "network" CONF_IGNORE_STRING = "ignore_string" CONF_SENSOR_STRING = "sensor_string" CONF_VAR_SENSOR_STRING = "variable_sensor_string" @@ -74,15 +75,13 @@ DEFAULT_VAR_SENSOR_STRING = "HA." KEY_ACTIONS = "actions" KEY_STATUS = "status" -PLATFORMS = [ +NODE_PLATFORMS = [ Platform.BINARY_SENSOR, - Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.LOCK, - Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] @@ -93,6 +92,16 @@ PROGRAM_PLATFORMS = [ Platform.LOCK, Platform.SWITCH, ] +ROOT_NODE_PLATFORMS = [Platform.BUTTON] +VARIABLE_PLATFORMS = [Platform.NUMBER, Platform.SENSOR] + +# Set of all platforms used by integration +PLATFORMS = { + *NODE_PLATFORMS, + *PROGRAM_PLATFORMS, + *ROOT_NODE_PLATFORMS, + *VARIABLE_PLATFORMS, +} SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"] @@ -100,10 +109,13 @@ SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"] # (they can turn off, and report their state) ISY_GROUP_PLATFORM = Platform.SWITCH -ISY994_ISY = "isy" -ISY994_NODES = "isy994_nodes" -ISY994_PROGRAMS = "isy994_programs" -ISY994_VARIABLES = "isy994_variables" +ISY_ROOT = "isy" +ISY_ROOT_NODES = "isy_root_nodes" +ISY_NET_RES = "isy_net_res" +ISY_NODES = "isy_nodes" +ISY_PROGRAMS = "isy_programs" +ISY_VARIABLES = "isy_variables" +ISY_DEVICES = "isy_devices" ISY_CONF_NETWORKING = "Networking Module" ISY_CONF_UUID = "uuid" @@ -201,14 +213,6 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { ], # Does a startswith() match; include the dot FILTER_ZWAVE_CAT: (["104", "112", "138"] + list(map(str, range(148, 180)))), }, - Platform.BUTTON: { - # No devices automatically sorted as buttons at this time. Query buttons added elsewhere. - FILTER_UOM: [], - FILTER_STATES: [], - FILTER_NODE_DEF_ID: [], - FILTER_INSTEON_TYPE: [], - FILTER_ZWAVE_CAT: [], - }, Platform.SENSOR: { # This is just a more-readable way of including MOST uoms between 1-100 # (Remember that range() is non-inclusive of the stop value) @@ -308,14 +312,6 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { FILTER_INSTEON_TYPE: ["4.8", TYPE_CATEGORY_CLIMATE], FILTER_ZWAVE_CAT: ["140"], }, - Platform.NUMBER: { - # No devices automatically sorted as numbers at this time. - FILTER_UOM: [], - FILTER_STATES: [], - FILTER_NODE_DEF_ID: [], - FILTER_INSTEON_TYPE: [], - FILTER_ZWAVE_CAT: [], - }, } UOM_FRIENDLY_NAME = { diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index de97581dfef..6f85856ca0a 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -13,13 +13,15 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( _LOGGER, - DOMAIN as ISY994_DOMAIN, - ISY994_NODES, - ISY994_PROGRAMS, + DOMAIN, + ISY_DEVICES, + ISY_NODES, + ISY_PROGRAMS, UOM_8_BIT_RANGE, UOM_BARRIER, ) @@ -30,12 +32,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY cover platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYCoverEntity | ISYCoverProgramEntity] = [] - for node in hass_isy_data[ISY994_NODES][Platform.COVER]: - entities.append(ISYCoverEntity(node)) + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + for node in hass_isy_data[ISY_NODES][Platform.COVER]: + entities.append(ISYCoverEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY994_PROGRAMS][Platform.COVER]: + for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.COVER]: entities.append(ISYCoverProgramEntity(name, status, actions)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 1dc60ef9664..173c2432981 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -7,32 +7,37 @@ from pyisy.constants import ( COMMAND_FRIENDLY_NAME, EMPTY_TIME, EVENT_PROPS_IGNORED, - PROTO_GROUP, PROTO_INSTEON, PROTO_ZWAVE, ) from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node from pyisy.programs import Program +from pyisy.variables import Variable -from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, 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 DeviceInfo, Entity -from . import _async_isy_to_configuration_url -from .const import DOMAIN, ISY_CONF_UUID +from .const import DOMAIN class ISYEntity(Entity): """Representation of an ISY device.""" - _name: str | None = None + _attr_has_entity_name = False _attr_should_poll = False + _node: Node | Program | Variable - def __init__(self, node: Node) -> None: + def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the insteon device.""" self._node = node + self._attr_name = node.name + if device_info is None: + device_info = DeviceInfo(identifiers={(DOMAIN, node.isy.uuid)}) + self._attr_device_info = device_info + self._attr_unique_id = f"{node.isy.uuid}_{node.address}" self._attrs: dict[str, Any] = {} self._change_handler: EventListener | None = None self._control_handler: EventListener | None = None @@ -69,72 +74,6 @@ class ISYEntity(Entity): self.hass.bus.async_fire("isy994_control", event_data) - @property - def device_info(self) -> DeviceInfo | None: - """Return the device_info of the device.""" - isy = self._node.isy - uuid = isy.configuration[ISY_CONF_UUID] - node = self._node - url = _async_isy_to_configuration_url(isy) - - basename = self._name or str(self._node.name) - - if node.protocol == PROTO_GROUP and len(node.controllers) == 1: - # If Group has only 1 Controller, link to that device instead of the hub - node = isy.nodes.get_by_id(node.controllers[0]) - basename = node.name - - if hasattr(node, "parent_node"): # Verify this is a Node class - if node.parent_node is not None: - # This is not the parent node, get the parent node. - node = node.parent_node - basename = node.name - else: - # Default to the hub device if parent node is not a physical device - return DeviceInfo(identifiers={(DOMAIN, uuid)}) - - device_info = DeviceInfo( - identifiers={(DOMAIN, f"{uuid}_{node.address}")}, - manufacturer=node.protocol, - name=f"{basename} ({(str(node.address).rpartition(' ')[0] or node.address)})", - via_device=(DOMAIN, uuid), - configuration_url=url, - suggested_area=node.folder, - ) - - # ISYv5 Device Types can provide model and manufacturer - model: str = "Unknown" - if node.node_def_id is not None: - model = str(node.node_def_id) - - # Numerical Device Type - if node.type is not None: - model += f" ({node.type})" - - # Get extra information for Z-Wave Devices - if node.protocol == PROTO_ZWAVE: - device_info[ATTR_MANUFACTURER] = f"Z-Wave MfrID:{node.zwave_props.mfr_id}" - model += ( - f" Type:{node.zwave_props.devtype_gen} " - f"ProductTypeID:{node.zwave_props.prod_type_id} " - f"ProductID:{node.zwave_props.product_id}" - ) - device_info[ATTR_MODEL] = model - - return device_info - - @property - def unique_id(self) -> str | None: - """Get the unique identifier of the device.""" - if hasattr(self._node, "address"): - return f"{self._node.isy.configuration[ISY_CONF_UUID]}_{self._node.address}" - return None - - @property - def name(self) -> str: - """Get the name of the device.""" - return self._name or str(self._node.name) - class ISYNodeEntity(ISYEntity): """Representation of a ISY Nodebase (Node/Group) entity.""" @@ -219,7 +158,7 @@ class ISYProgramEntity(ISYEntity): def __init__(self, name: str, status: Any | None, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" super().__init__(status) - self._name = name + self._attr_name = name self._actions = actions @property diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 1ff7d9f7961..3ce813d51a1 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -17,7 +18,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS +from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS from .entity import ISYNodeEntity, ISYProgramEntity SPEED_RANGE = (1, 255) # off is not included @@ -27,13 +28,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY fan platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] entities: list[ISYFanEntity | ISYFanProgramEntity] = [] - for node in hass_isy_data[ISY994_NODES][Platform.FAN]: - entities.append(ISYFanEntity(node)) + for node in hass_isy_data[ISY_NODES][Platform.FAN]: + entities.append(ISYFanEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY994_PROGRAMS][Platform.FAN]: + for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.FAN]: entities.append(ISYFanProgramEntity(name, status, actions)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 42c0c60120f..fbbaa8f7b10 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -15,24 +15,28 @@ from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs from pyisy.variables import Variables -from homeassistant.const import Platform +from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform +from homeassistant.helpers.entity import DeviceInfo from .const import ( _LOGGER, DEFAULT_PROGRAM_STRING, + DOMAIN, FILTER_INSTEON_TYPE, FILTER_NODE_DEF_ID, FILTER_STATES, FILTER_UOM, FILTER_ZWAVE_CAT, - ISY994_NODES, - ISY994_PROGRAMS, - ISY994_VARIABLES, + ISY_DEVICES, ISY_GROUP_PLATFORM, + ISY_NODES, + ISY_PROGRAMS, + ISY_ROOT_NODES, + ISY_VARIABLES, KEY_ACTIONS, KEY_STATUS, NODE_FILTERS, - PLATFORMS, + NODE_PLATFORMS, PROGRAM_PLATFORMS, SENSOR_AUX, SUBNODE_CLIMATE_COOL, @@ -64,10 +68,10 @@ def _check_for_node_def( node_def_id = node.node_def_id - platforms = PLATFORMS if not single_platform else [single_platform] + platforms = NODE_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_isy_data[ISY994_NODES][platform].append(node) + hass_isy_data[ISY_NODES][platform].append(node) return True return False @@ -89,7 +93,7 @@ def _check_for_insteon_type( return False device_type = node.type - platforms = PLATFORMS if not single_platform else [single_platform] + platforms = NODE_PLATFORMS if not single_platform else [single_platform] for platform in platforms: if any( device_type.startswith(t) @@ -103,7 +107,7 @@ def _check_for_insteon_type( # FanLinc, which has a light module as one of its nodes. if platform == Platform.FAN and subnode_id == SUBNODE_FANLINC_LIGHT: - hass_isy_data[ISY994_NODES][Platform.LIGHT].append(node) + hass_isy_data[ISY_NODES][Platform.LIGHT].append(node) return True # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 @@ -111,7 +115,7 @@ def _check_for_insteon_type( SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, ): - hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR].append(node) + hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR].append(node) return True # IOLincs which have a sensor and relay on 2 different nodes @@ -120,7 +124,7 @@ def _check_for_insteon_type( and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS) and subnode_id == SUBNODE_IOLINC_RELAY ): - hass_isy_data[ISY994_NODES][Platform.SWITCH].append(node) + hass_isy_data[ISY_NODES][Platform.SWITCH].append(node) return True # Smartenit EZIO2X4 @@ -129,10 +133,10 @@ def _check_for_insteon_type( and device_type.startswith(TYPE_EZIO2X4) and subnode_id in SUBNODE_EZIO2X4_SENSORS ): - hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR].append(node) + hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR].append(node) return True - hass_isy_data[ISY994_NODES][platform].append(node) + hass_isy_data[ISY_NODES][platform].append(node) return True return False @@ -154,13 +158,13 @@ def _check_for_zwave_cat( return False device_type = node.zwave_props.category - platforms = PLATFORMS if not single_platform else [single_platform] + platforms = NODE_PLATFORMS if not single_platform else [single_platform] for platform in platforms: if any( device_type.startswith(t) for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT]) ): - hass_isy_data[ISY994_NODES][platform].append(node) + hass_isy_data[ISY_NODES][platform].append(node) return True return False @@ -188,14 +192,14 @@ def _check_for_uom_id( if uom_list: if node_uom in uom_list: - hass_isy_data[ISY994_NODES][single_platform].append(node) + hass_isy_data[ISY_NODES][single_platform].append(node) return True return False - platforms = PLATFORMS if not single_platform else [single_platform] + platforms = NODE_PLATFORMS if not single_platform else [single_platform] for platform in platforms: if node_uom in NODE_FILTERS[platform][FILTER_UOM]: - hass_isy_data[ISY994_NODES][platform].append(node) + hass_isy_data[ISY_NODES][platform].append(node) return True return False @@ -225,14 +229,14 @@ def _check_for_states_in_uom( if states_list: if node_uom == set(states_list): - hass_isy_data[ISY994_NODES][single_platform].append(node) + hass_isy_data[ISY_NODES][single_platform].append(node) return True return False - platforms = PLATFORMS if not single_platform else [single_platform] + platforms = NODE_PLATFORMS if not single_platform else [single_platform] for platform in platforms: if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]): - hass_isy_data[ISY994_NODES][platform].append(node) + hass_isy_data[ISY_NODES][platform].append(node) return True return False @@ -269,6 +273,41 @@ def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Group | Node) -> bool: return False +def _generate_device_info(node: Node) -> DeviceInfo: + """Generate the device info for a root node device.""" + isy = node.isy + basename = node.name + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")}, + manufacturer=node.protocol, + name=f"{basename} ({(str(node.address).rpartition(' ')[0] or node.address)})", + via_device=(DOMAIN, isy.uuid), + configuration_url=isy.conn.url, + suggested_area=node.folder, + ) + + # ISYv5 Device Types can provide model and manufacturer + model: str = "Unknown" + if node.node_def_id is not None: + model = str(node.node_def_id) + + # Numerical Device Type + if node.type is not None: + model += f" ({node.type})" + + # Get extra information for Z-Wave Devices + if node.protocol == PROTO_ZWAVE: + device_info[ATTR_MANUFACTURER] = f"Z-Wave MfrID:{node.zwave_props.mfr_id}" + model += ( + f" Type:{node.zwave_props.devtype_gen} " + f"ProductTypeID:{node.zwave_props.prod_type_id} " + f"ProductID:{node.zwave_props.product_id}" + ) + device_info[ATTR_MODEL] = model + + return device_info + + def _categorize_nodes( hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str ) -> None: @@ -280,23 +319,24 @@ def _categorize_nodes( continue if hasattr(node, "parent_node") and node.parent_node is None: - # This is a physical device / parent node, add a query button - hass_isy_data[ISY994_NODES][Platform.BUTTON].append(node) + # This is a physical device / parent node + hass_isy_data[ISY_DEVICES][node.address] = _generate_device_info(node) + hass_isy_data[ISY_ROOT_NODES][Platform.BUTTON].append(node) if node.protocol == PROTO_GROUP: - hass_isy_data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node) + hass_isy_data[ISY_NODES][ISY_GROUP_PLATFORM].append(node) continue if node.protocol == PROTO_INSTEON: for control in node.aux_properties: - hass_isy_data[ISY994_NODES][SENSOR_AUX].append((node, control)) + hass_isy_data[ISY_NODES][SENSOR_AUX].append((node, control)) 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_isy_data, node): continue - hass_isy_data[ISY994_NODES][Platform.SENSOR].append(node) + hass_isy_data[ISY_NODES][Platform.SENSOR].append(node) continue # We have a bunch of different methods for determining the device type, @@ -314,7 +354,7 @@ def _categorize_nodes( continue # Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes. - hass_isy_data[ISY994_NODES][Platform.SENSOR].append(node) + hass_isy_data[ISY_NODES][Platform.SENSOR].append(node) def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: @@ -353,7 +393,7 @@ def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: continue entity = (entity_folder.name, status, actions) - hass_isy_data[ISY994_PROGRAMS][platform].append(entity) + hass_isy_data[ISY_PROGRAMS][platform].append(entity) def _categorize_variables( @@ -369,7 +409,7 @@ def _categorize_variables( except KeyError as err: _LOGGER.error("Error adding ISY Variables: %s", err) return - variable_entities = hass_isy_data[ISY994_VARIABLES] + variable_entities = hass_isy_data[ISY_VARIABLES] for vtype, vname, vid in var_to_add: variable_entities[Platform.SENSOR].append((vname, variables[vtype][vid])) diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index ed13b8c94f4..7df16151fc4 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -11,14 +11,16 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( _LOGGER, CONF_RESTORE_LIGHT_STATE, - DOMAIN as ISY994_DOMAIN, - ISY994_NODES, + DOMAIN, + ISY_DEVICES, + ISY_NODES, UOM_PERCENTAGE, ) from .entity import ISYNodeEntity @@ -31,13 +33,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY light platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] isy_options = entry.options restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) entities = [] - for node in hass_isy_data[ISY994_NODES][Platform.LIGHT]: - entities.append(ISYLightEntity(node, restore_light_state)) + for node in hass_isy_data[ISY_NODES][Platform.LIGHT]: + entities.append( + ISYLightEntity(node, restore_light_state, devices.get(node.primary_node)) + ) async_add_entities(entities) async_setup_light_services(hass) @@ -49,9 +54,14 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, node: Node, restore_light_state: bool) -> None: + def __init__( + self, + node: Node, + restore_light_state: bool, + device_info: DeviceInfo | None = None, + ) -> None: """Initialize the ISY light device.""" - super().__init__(node) + super().__init__(node, device_info=device_info) self._last_brightness: int | None = None self._restore_light_state = restore_light_state diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index 8fa271a50d0..4bc46e8ae37 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -9,9 +9,10 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS +from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS from .entity import ISYNodeEntity, ISYProgramEntity VALUE_TO_STATE = {0: False, 100: True} @@ -21,12 +22,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY lock platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] entities: list[ISYLockEntity | ISYLockProgramEntity] = [] - for node in hass_isy_data[ISY994_NODES][Platform.LOCK]: - entities.append(ISYLockEntity(node)) + for node in hass_isy_data[ISY_NODES][Platform.LOCK]: + entities.append(ISYLockEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY994_PROGRAMS][Platform.LOCK]: + for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.LOCK]: entities.append(ISYLockProgramEntity(name, status, actions)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 4cab67c7d96..49aa6186377 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -3,7 +3,7 @@ "name": "Universal Devices ISY/IoX", "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.0.12"], + "requirements": ["pyisy==3.1.3"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index 064b6c6e60a..eaa3cf186af 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -9,23 +9,12 @@ from pyisy.variables import Variable from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_VARIABLES, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import _async_isy_to_configuration_url -from .const import ( - DOMAIN as ISY994_DOMAIN, - ISY994_ISY, - ISY994_VARIABLES, - ISY_CONF_FIRMWARE, - ISY_CONF_MODEL, - ISY_CONF_NAME, - ISY_CONF_UUID, - MANUFACTURER, -) +from .const import DOMAIN, ISY_DEVICES, ISY_ROOT, ISY_VARIABLES from .helpers import convert_isy_value_to_hass ISY_MAX_SIZE = (2**32) / 2 @@ -37,12 +26,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" - hass_isy_data = hass.data[ISY994_DOMAIN][config_entry.entry_id] - isy: ISY = hass_isy_data[ISY994_ISY] - uuid = isy.configuration[ISY_CONF_UUID] + hass_isy_data = hass.data[DOMAIN][config_entry.entry_id] + isy: ISY = hass_isy_data[ISY_ROOT] + device_info = hass_isy_data[ISY_DEVICES] entities: list[ISYVariableNumberEntity] = [] - for node, enable_by_default in hass_isy_data[ISY994_VARIABLES][Platform.NUMBER]: + for node, enable_by_default in hass_isy_data[ISY_VARIABLES][Platform.NUMBER]: step = 10 ** (-1 * node.prec) min_max = ISY_MAX_SIZE / (10**node.prec) description = NumberEntityDescription( @@ -70,15 +59,17 @@ async def async_setup_entry( entities.append( ISYVariableNumberEntity( node, - unique_id=f"{uuid}_{node.address}", + unique_id=f"{isy.uuid}_{node.address}", description=description, + device_info=device_info[CONF_VARIABLES], ) ) entities.append( ISYVariableNumberEntity( node=node, - unique_id=f"{uuid}_{node.address}_init", + unique_id=f"{isy.uuid}_{node.address}_init", description=description_init, + device_info=device_info[CONF_VARIABLES], init_entity=True, ) ) @@ -89,7 +80,7 @@ async def async_setup_entry( class ISYVariableNumberEntity(NumberEntity): """Representation of an ISY variable as a number entity device.""" - _attr_has_entity_name = True + _attr_has_entity_name = False _attr_should_poll = False _init_entity: bool _node: Variable @@ -100,37 +91,19 @@ class ISYVariableNumberEntity(NumberEntity): node: Variable, unique_id: str, description: NumberEntityDescription, + device_info: DeviceInfo, init_entity: bool = False, ) -> None: """Initialize the ISY variable number.""" self._node = node - self._name = description.name self.entity_description = description self._change_handler: EventListener | None = None # Two entities are created for each variable, one for current value and one for initial. # Initial value entities are disabled by default self._init_entity = init_entity - self._attr_unique_id = unique_id - - url = _async_isy_to_configuration_url(node.isy) - config = node.isy.configuration - self._attr_device_info = DeviceInfo( - identifiers={ - ( - ISY994_DOMAIN, - f"{config[ISY_CONF_UUID]}_variables", - ) - }, - manufacturer=MANUFACTURER, - name=f"{config[ISY_CONF_NAME]} Variables", - model=config[ISY_CONF_MODEL], - sw_version=config[ISY_CONF_FIRMWARE], - configuration_url=url, - via_device=(ISY994_DOMAIN, config[ISY_CONF_UUID]), - entry_type=DeviceEntryType.SERVICE, - ) + self._attr_device_info = device_info async def async_added_to_hass(self) -> None: """Subscribe to the node change events.""" diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 635b456da25..925aad176db 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -28,15 +28,15 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( _LOGGER, - DOMAIN as ISY994_DOMAIN, - ISY994_NODES, - ISY994_VARIABLES, - ISY_CONF_UUID, + DOMAIN, + ISY_DEVICES, + ISY_NODES, + ISY_VARIABLES, SENSOR_AUX, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, @@ -110,15 +110,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY sensor platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYSensorEntity | ISYSensorVariableEntity] = [] + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] - for node in hass_isy_data[ISY994_NODES][Platform.SENSOR]: + for node in hass_isy_data[ISY_NODES][Platform.SENSOR]: _LOGGER.debug("Loading %s", node.name) - entities.append(ISYSensorEntity(node)) + entities.append(ISYSensorEntity(node, devices.get(node.primary_node))) aux_nodes = set() - for node, control in hass_isy_data[ISY994_NODES][SENSOR_AUX]: + for node, control in hass_isy_data[ISY_NODES][SENSOR_AUX]: aux_nodes.add(node) if control in SKIP_AUX_PROPERTIES: continue @@ -126,13 +127,27 @@ async def async_setup_entry( enabled_default = control not in AUX_DISABLED_BY_DEFAULT_EXACT and not any( control.startswith(match) for match in AUX_DISABLED_BY_DEFAULT_MATCH ) - entities.append(ISYAuxSensorEntity(node, control, enabled_default)) + entities.append( + ISYAuxSensorEntity( + node=node, + control=control, + enabled_default=enabled_default, + device_info=devices.get(node.primary_node), + ) + ) for node in aux_nodes: # Any node in SENSOR_AUX can potentially have communication errors - entities.append(ISYAuxSensorEntity(node, PROP_COMMS_ERROR, False)) + entities.append( + ISYAuxSensorEntity( + node=node, + control=PROP_COMMS_ERROR, + enabled_default=False, + device_info=devices.get(node.primary_node), + ) + ) - for vname, vobj in hass_isy_data[ISY994_VARIABLES][Platform.SENSOR]: + for vname, vobj in hass_isy_data[ISY_VARIABLES][Platform.SENSOR]: entities.append(ISYSensorVariableEntity(vname, vobj)) async_add_entities(entities) @@ -228,15 +243,26 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): class ISYAuxSensorEntity(ISYSensorEntity): """Representation of an ISY aux sensor device.""" - def __init__(self, node: Node, control: str, enabled_default: bool) -> None: + def __init__( + self, + node: Node, + control: str, + enabled_default: bool, + device_info: DeviceInfo | None = None, + ) -> None: """Initialize the ISY aux sensor.""" - super().__init__(node) + super().__init__(node, device_info=device_info) self._control = control self._attr_entity_registry_enabled_default = enabled_default self._attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control) self._attr_device_class = ISY_CONTROL_TO_DEVICE_CLASS.get(control) self._attr_state_class = ISY_CONTROL_TO_STATE_CLASS.get(control) + name = COMMAND_FRIENDLY_NAME.get(self._control, self._control) + self._attr_name = f"{node.name} {name.replace('_', ' ').title()}" + + self._attr_unique_id = f"{node.isy.uuid}_{node.address}_{control}" + @property def target(self) -> Node | NodeProperty | None: """Return target for the sensor.""" @@ -250,25 +276,11 @@ class ISYAuxSensorEntity(ISYSensorEntity): """Return the target value.""" return None if self.target is None else self.target.value - @property - def unique_id(self) -> str | None: - """Get the unique identifier of the device and aux sensor.""" - if not hasattr(self._node, "address"): - return None - return f"{self._node.isy.configuration[ISY_CONF_UUID]}_{self._node.address}_{self._control}" - - @property - def name(self) -> str: - """Get the name of the device and aux sensor.""" - base_name = self._name or str(self._node.name) - name = COMMAND_FRIENDLY_NAME.get(self._control, self._control) - return f"{base_name} {name.replace('_', ' ').title()}" - class ISYSensorVariableEntity(ISYEntity, SensorEntity): """Representation of an ISY variable as a sensor device.""" - # Depreceted sensors, will be removed in 2023.5.0 + # Deprecated sensors, will be removed in 2023.5.0 _attr_entity_registry_enabled_default = False def __init__(self, vname: str, vobj: object) -> None: diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index bd49478905f..dc82a24f092 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from pyisy.constants import COMMAND_FRIENDLY_NAME, PROTO_NETWORK_RESOURCE +from pyisy.constants import COMMAND_FRIENDLY_NAME import voluptuous as vol from homeassistant.const import ( @@ -25,11 +25,11 @@ from homeassistant.helpers.service import entity_service_call from .const import ( _LOGGER, + CONF_NETWORK, DOMAIN, - ISY994_ISY, ISY_CONF_NAME, ISY_CONF_NETWORKING, - ISY_CONF_UUID, + ISY_ROOT, ) from .util import unique_ids_for_config_entry_id @@ -192,8 +192,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy_name = service.data.get(CONF_ISY) entity_registry = er.async_get(hass) for config_entry_id in hass.data[DOMAIN]: - isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] - if isy_name and isy_name != isy.configuration["name"]: + isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT] + if isy_name and isy_name != isy.conf["name"]: continue # If an address is provided, make sure we query the correct ISY. # Otherwise, query the whole system on all ISY's connected. @@ -201,7 +201,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 _LOGGER.debug( "Requesting query of device %s on ISY %s", address, - isy.configuration[ISY_CONF_UUID], + isy.uuid, ) await isy.query(address) async_log_deprecated_service_call( @@ -211,21 +211,19 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 alternate_target=entity_registry.async_get_entity_id( Platform.BUTTON, DOMAIN, - f"{isy.configuration[ISY_CONF_UUID]}_{address}_query", + f"{isy.uuid}_{address}_query", ), breaks_in_ha_version="2023.5.0", ) return - _LOGGER.debug( - "Requesting system query of ISY %s", isy.configuration[ISY_CONF_UUID] - ) + _LOGGER.debug("Requesting system query of ISY %s", isy.uuid) await isy.query() async_log_deprecated_service_call( hass, call=service, alternate_service="button.press", alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, DOMAIN, f"{isy.configuration[ISY_CONF_UUID]}_query" + Platform.BUTTON, DOMAIN, f"{isy.uuid}_query" ), breaks_in_ha_version="2023.5.0", ) @@ -237,10 +235,10 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] - if isy_name and isy_name != isy.configuration[ISY_CONF_NAME]: + isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT] + if isy_name and isy_name != isy.conf[ISY_CONF_NAME]: continue - if isy.networking is None or not isy.configuration[ISY_CONF_NETWORKING]: + if isy.networking is None or not isy.conf[ISY_CONF_NETWORKING]: continue command = None if address: @@ -257,7 +255,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 alternate_target=entity_registry.async_get_entity_id( Platform.BUTTON, DOMAIN, - f"{isy.configuration[ISY_CONF_UUID]}_{PROTO_NETWORK_RESOURCE}_{address}", + f"{isy.uuid}_{CONF_NETWORK}_{address}", ), breaks_in_ha_version="2023.5.0", ) @@ -274,8 +272,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] - if isy_name and isy_name != isy.configuration["name"]: + isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT] + if isy_name and isy_name != isy.conf["name"]: continue program = None if address: @@ -297,8 +295,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] - if isy_name and isy_name != isy.configuration["name"]: + isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT] + if isy_name and isy_name != isy.conf["name"]: continue variable = None if name: @@ -315,7 +313,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 alternate_target=entity_registry.async_get_entity_id( Platform.NUMBER, DOMAIN, - f"{isy.configuration[ISY_CONF_UUID]}_{address}{'_init' if init else ''}", + f"{isy.uuid}_{address}{'_init' if init else ''}", ), breaks_in_ha_version="2023.5.0", ) @@ -326,40 +324,31 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 def async_cleanup_registry_entries(service: ServiceCall) -> None: """Remove extra entities that are no longer part of the integration.""" entity_registry = er.async_get(hass) - config_ids = [] - current_unique_ids: set[str] = set() for config_entry_id in hass.data[DOMAIN]: entries_for_this_config = er.async_entries_for_config_entry( entity_registry, config_entry_id ) - config_ids.extend( - [ - (entity.unique_id, entity.entity_id) - for entity in entries_for_this_config - ] + entities = { + (entity.domain, entity.unique_id): entity.entity_id + for entity in entries_for_this_config + } + + extra_entities = set(entities.keys()).difference( + unique_ids_for_config_entry_id(hass, config_entry_id) ) - current_unique_ids |= unique_ids_for_config_entry_id(hass, config_entry_id) - extra_entities = [ - entity_id - for unique_id, entity_id in config_ids - if unique_id not in current_unique_ids - ] + for entity in extra_entities: + if entity_registry.async_is_registered(entities[entity]): + entity_registry.async_remove(entities[entity]) - for entity_id in extra_entities: - if entity_registry.async_is_registered(entity_id): - entity_registry.async_remove(entity_id) - - _LOGGER.debug( - ( - "Cleaning up ISY Entities and devices: Config Entries: %s, Current" - " Entries: %s, Extra Entries Removed: %s" - ), - len(config_ids), - len(current_unique_ids), - len(extra_entities), - ) + _LOGGER.debug( + ( + "Cleaning up ISY entities: removed %s extra entities for config entry: %s" + ), + len(extra_entities), + len(config_entry_id), + ) async def async_reload_config_entries(service: ServiceCall) -> None: """Trigger a reload of all ISY config entries.""" diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 10c23c3c434..224f008818c 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -9,9 +9,10 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN as ISY994_DOMAIN, ISY994_NODES, ISY994_PROGRAMS +from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS from .entity import ISYNodeEntity, ISYProgramEntity @@ -19,12 +20,18 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY switch platform.""" - hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] + hass_isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYSwitchProgramEntity | ISYSwitchEntity] = [] - for node in hass_isy_data[ISY994_NODES][Platform.SWITCH]: - entities.append(ISYSwitchEntity(node)) + devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + for node in hass_isy_data[ISY_NODES][Platform.SWITCH]: + primary = node.primary_node + if node.protocol == PROTO_GROUP and len(node.controllers) == 1: + # If Group has only 1 Controller, link to that device instead of the hub + primary = node.isy.nodes.get_by_id(node.controllers[0]).primary_node - for name, status, actions in hass_isy_data[ISY994_PROGRAMS][Platform.SWITCH]: + entities.append(ISYSwitchEntity(node, devices.get(primary))) + + for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.SWITCH]: entities.append(ISYSwitchProgramEntity(name, status, actions)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/system_health.py b/homeassistant/components/isy994/system_health.py index a8497ba0b27..242f0db7826 100644 --- a/homeassistant/components/isy994/system_health.py +++ b/homeassistant/components/isy994/system_health.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, ISY994_ISY, ISY_URL_POSTFIX +from .const import DOMAIN, ISY_ROOT, ISY_URL_POSTFIX @callback @@ -28,7 +28,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: config_entry_id = next( iter(hass.data[DOMAIN]) ) # Only first ISY is supported for now - isy: ISY = hass.data[DOMAIN][config_entry_id][ISY994_ISY] + isy: ISY = hass.data[DOMAIN][config_entry_id][ISY_ROOT] entry = hass.config_entries.async_get_entry(config_entry_id) assert isinstance(entry, ConfigEntry) diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index 45bf10cc1bd..39ce8ba2be3 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -1,40 +1,69 @@ """ISY utils.""" from __future__ import annotations +from pyisy.constants import PROP_COMMS_ERROR, PROTO_INSTEON + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import ( + CONF_NETWORK, DOMAIN, - ISY994_ISY, - ISY994_NODES, - ISY994_PROGRAMS, - ISY994_VARIABLES, - ISY_CONF_UUID, - PLATFORMS, + ISY_NET_RES, + ISY_NODES, + ISY_PROGRAMS, + ISY_ROOT, + ISY_ROOT_NODES, + ISY_VARIABLES, + NODE_PLATFORMS, PROGRAM_PLATFORMS, + ROOT_NODE_PLATFORMS, + SENSOR_AUX, ) def unique_ids_for_config_entry_id( hass: HomeAssistant, config_entry_id: str -) -> set[str]: +) -> set[tuple[Platform | str, str]]: """Find all the unique ids for a config entry id.""" hass_isy_data = hass.data[DOMAIN][config_entry_id] - uuid = hass_isy_data[ISY994_ISY].configuration[ISY_CONF_UUID] - current_unique_ids: set[str] = {uuid} + isy = hass_isy_data[ISY_ROOT] + current_unique_ids: set[tuple[Platform | str, str]] = { + (Platform.BUTTON, f"{isy.uuid}_query") + } - for platform in PLATFORMS: - for node in hass_isy_data[ISY994_NODES][platform]: - if hasattr(node, "address"): - current_unique_ids.add(f"{uuid}_{node.address}") + # Structure and prefixes here must match what's added in __init__ and helpers + for platform in NODE_PLATFORMS: + for node in hass_isy_data[ISY_NODES][platform]: + current_unique_ids.add((platform, f"{isy.uuid}_{node.address}")) + + for node, control in hass_isy_data[ISY_NODES][SENSOR_AUX]: + current_unique_ids.add( + (Platform.SENSOR, f"{isy.uuid}_{node.address}_{control}") + ) + current_unique_ids.add( + (Platform.SENSOR, f"{isy.uuid}_{node.address}_{PROP_COMMS_ERROR}") + ) for platform in PROGRAM_PLATFORMS: - for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: - if hasattr(node, "address"): - current_unique_ids.add(f"{uuid}_{node.address}") + for _, node, _ in hass_isy_data[ISY_PROGRAMS][platform]: + current_unique_ids.add((platform, f"{isy.uuid}_{node.address}")) - for node in hass_isy_data[ISY994_VARIABLES]: - if hasattr(node, "address"): - current_unique_ids.add(f"{uuid}_{node.address}") + for node, _ in hass_isy_data[ISY_VARIABLES][Platform.NUMBER]: + current_unique_ids.add((Platform.NUMBER, f"{isy.uuid}_{node.address}")) + current_unique_ids.add((Platform.NUMBER, f"{isy.uuid}_{node.address}_init")) + for _, node in hass_isy_data[ISY_VARIABLES][Platform.SENSOR]: + current_unique_ids.add((Platform.SENSOR, f"{isy.uuid}_{node.address}")) + + for platform in ROOT_NODE_PLATFORMS: + for node in hass_isy_data[ISY_ROOT_NODES][platform]: + current_unique_ids.add((platform, f"{isy.uuid}_{node.address}_query")) + if platform == Platform.BUTTON and node.protocol == PROTO_INSTEON: + current_unique_ids.add((platform, f"{isy.uuid}_{node.address}_beep")) + + for node in hass_isy_data[ISY_NET_RES]: + current_unique_ids.add( + (Platform.BUTTON, f"{isy.uuid}_{CONF_NETWORK}_{node.address}") + ) return current_unique_ids diff --git a/requirements_all.txt b/requirements_all.txt index f68ed0cf697..048402c36d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1696,7 +1696,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.12 +pyisy==3.1.3 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fce85207534..17eeef266f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1215,7 +1215,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.12 +pyisy==3.1.3 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/tests/components/isy994/test_system_health.py b/tests/components/isy994/test_system_health.py index e2c38375bb4..d76eafb2aad 100644 --- a/tests/components/isy994/test_system_health.py +++ b/tests/components/isy994/test_system_health.py @@ -4,7 +4,7 @@ from unittest.mock import Mock from aiohttp import ClientError -from homeassistant.components.isy994.const import DOMAIN, ISY994_ISY, ISY_URL_POSTFIX +from homeassistant.components.isy994.const import DOMAIN, ISY_ROOT, ISY_URL_POSTFIX from homeassistant.const import CONF_HOST from homeassistant.setup import async_setup_component @@ -33,7 +33,7 @@ async def test_system_health(hass, aioclient_mock): hass.data[DOMAIN] = {} hass.data[DOMAIN][MOCK_ENTRY_ID] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID][ISY994_ISY] = Mock( + hass.data[DOMAIN][MOCK_ENTRY_ID][ISY_ROOT] = Mock( connected=True, websocket=Mock( last_heartbeat=MOCK_HEARTBEAT, @@ -69,7 +69,7 @@ async def test_system_health_failed_connect(hass, aioclient_mock): hass.data[DOMAIN] = {} hass.data[DOMAIN][MOCK_ENTRY_ID] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID][ISY994_ISY] = Mock( + hass.data[DOMAIN][MOCK_ENTRY_ID][ISY_ROOT] = Mock( connected=True, websocket=Mock( last_heartbeat=MOCK_HEARTBEAT,