diff --git a/.coveragerc b/.coveragerc index 0830883fbb1..ca3da77447a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -610,6 +610,7 @@ omit = homeassistant/components/isy994/helpers.py homeassistant/components/isy994/light.py homeassistant/components/isy994/lock.py + homeassistant/components/isy994/models.py homeassistant/components/isy994/number.py homeassistant/components/isy994/sensor.py homeassistant/components/isy994/services.py diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index b0c10b776e9..30aa5c90edd 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -43,25 +43,15 @@ from .const import ( ISY_CONF_MODEL, ISY_CONF_NAME, ISY_CONF_NETWORKING, - 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 .models import IsyData from .services import async_setup_services, async_unload_services +from .util import _async_cleanup_registry_entries CONFIG_SCHEMA = vol.Schema( { @@ -135,15 +125,7 @@ async def async_setup_entry( # 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[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_data = hass.data[DOMAIN][entry.entry_id] = IsyData() isy_config = entry.data isy_options = entry.options @@ -212,34 +194,37 @@ async def async_setup_entry( f"Invalid response ISY, device is likely still starting: {err}" ) from err - _categorize_nodes(hass_isy_data, isy.nodes, ignore_identifier, sensor_identifier) - _categorize_programs(hass_isy_data, isy.programs) + _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) + _categorize_programs(isy_data, isy.programs) # Categorize variables call to be removed with variable sensors in 2023.5.0 - _categorize_variables(hass_isy_data, isy.variables, variable_identifier) + _categorize_variables(isy_data, isy.variables, variable_identifier) # Gather ISY Variables to be added. Identifier used to enable by default. - if len(isy.variables.children) > 0: - hass_isy_data[ISY_DEVICES][CONF_VARIABLES] = _create_service_device_info( + if isy.variables.children: + isy_data.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)) + numbers = isy_data.variables[Platform.NUMBER] + for vtype, _, vid in isy.variables.children: + numbers.append(isy.variables[vtype][vid]) if isy.conf[ISY_CONF_NETWORKING]: - hass_isy_data[ISY_DEVICES][CONF_NETWORK] = _create_service_device_info( + isy_data.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[ISY_NET_RES].append(resource) + isy_data.net_resources.append(resource) # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs _LOGGER.info(repr(isy.clock)) - hass_isy_data[ISY_ROOT] = isy + isy_data.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. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Clean-up any old entities that we no longer provide. + _async_cleanup_registry_entries(hass, entry.entry_id) + @callback def _async_stop_auto_update(event: Event) -> None: """Stop the isy auto update on Home Assistant Shutdown.""" @@ -328,9 +313,9 @@ async def async_unload_entry( """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass_isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data = hass.data[DOMAIN][entry.entry_id] - isy: ISY = hass_isy_data[ISY_ROOT] + isy: ISY = isy_data.root _LOGGER.debug("ISY Stopping Event Stream and automatic updates") isy.websocket.stop() @@ -349,7 +334,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] + isy_data = hass.data[DOMAIN][config_entry.entry_id] return not device_entry.identifiers.intersection( - (DOMAIN, unique_id) for unique_id in hass_isy_devices + (DOMAIN, unique_id) for unique_id in isy_data.devices ) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index abd23fea6f7..521bfb41a80 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -32,9 +32,6 @@ from .const import ( BINARY_SENSOR_DEVICE_TYPES_ISY, BINARY_SENSOR_DEVICE_TYPES_ZWAVE, DOMAIN, - ISY_DEVICES, - ISY_NODES, - ISY_PROGRAMS, SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, SUBNODE_DUSK_DAWN, @@ -77,9 +74,9 @@ async def async_setup_entry( ] = [] entity: ISYInsteonBinarySensorEntity | ISYBinarySensorEntity | ISYBinarySensorHeartbeat | ISYBinarySensorProgramEntity - 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]: + isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = isy_data.devices + for node in isy_data.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) @@ -205,7 +202,7 @@ async def async_setup_entry( ) entities.append(entity) - for name, status, _ in hass_isy_data[ISY_PROGRAMS][Platform.BINARY_SENSOR]: + for name, status, _ in isy_data.programs[Platform.BINARY_SENSOR]: entities.append(ISYBinarySensorProgramEntity(name, status)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index 66f7735829f..6a29c83f29e 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -13,14 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_NETWORK, - DOMAIN, - ISY_DEVICES, - ISY_NET_RES, - ISY_ROOT, - ISY_ROOT_NODES, -) +from .const import CONF_NETWORK, DOMAIN async def async_setup_entry( @@ -29,21 +22,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" - hass_isy_data = hass.data[DOMAIN][config_entry.entry_id] - isy: ISY = hass_isy_data[ISY_ROOT] - device_info = hass_isy_data[ISY_DEVICES] + isy_data = hass.data[DOMAIN][config_entry.entry_id] + isy: ISY = isy_data.root + device_info = isy_data.devices entities: list[ ISYNodeQueryButtonEntity | ISYNodeBeepButtonEntity | ISYNetworkResourceButtonEntity ] = [] - for node in hass_isy_data[ISY_ROOT_NODES][Platform.BUTTON]: + for node in isy_data.root_nodes[Platform.BUTTON]: entities.append( ISYNodeQueryButtonEntity( node=node, name="Query", - unique_id=f"{isy.uuid}_{node.address}_query", + unique_id=f"{isy_data.uid_base(node)}_query", entity_category=EntityCategory.DIAGNOSTIC, device_info=device_info[node.address], ) @@ -53,18 +46,18 @@ async def async_setup_entry( ISYNodeBeepButtonEntity( node=node, name="Beep", - unique_id=f"{isy.uuid}_{node.address}_beep", + unique_id=f"{isy_data.uid_base(node)}_beep", entity_category=EntityCategory.DIAGNOSTIC, device_info=device_info[node.address], ) ) - for node in hass_isy_data[ISY_NET_RES]: + for node in isy_data.net_resources: entities.append( ISYNetworkResourceButtonEntity( node=node, name=node.name, - unique_id=f"{isy.uuid}_{CONF_NETWORK}_{node.address}", + unique_id=isy_data.uid_base(node), device_info=device_info[CONF_NETWORK], ) ) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index c0d7a6d8524..83fea57a9fa 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -43,9 +43,7 @@ from .const import ( DOMAIN, HA_FAN_TO_ISY, HA_HVAC_TO_ISY, - ISY_DEVICES, ISY_HVAC_MODES, - ISY_NODES, UOM_FAN_MODES, UOM_HVAC_ACTIONS, UOM_HVAC_MODE_GENERIC, @@ -65,9 +63,9 @@ async def async_setup_entry( """Set up the ISY thermostat platform.""" entities = [] - 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]: + isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = isy_data.devices + for node in isy_data.nodes[Platform.CLIMATE]: entities.append(ISYThermostatEntity(node, devices.get(node.primary_node))) async_add_entities(entities) diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 4485a53c8e8..e4216fee6ef 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -1,6 +1,8 @@ """Constants for the ISY Platform.""" import logging +from pyisy.constants import PROP_ON_LEVEL, PROP_RAMP_RATE + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.climate import ( FAN_AUTO, @@ -85,6 +87,7 @@ NODE_PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, ] +NODE_AUX_PROP_PLATFORMS = [Platform.SENSOR] PROGRAM_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -98,6 +101,7 @@ VARIABLE_PLATFORMS = [Platform.NUMBER, Platform.SENSOR] # Set of all platforms used by integration PLATFORMS = { *NODE_PLATFORMS, + *NODE_AUX_PROP_PLATFORMS, *PROGRAM_PLATFORMS, *ROOT_NODE_PLATFORMS, *VARIABLE_PLATFORMS, @@ -109,14 +113,6 @@ SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"] # (they can turn off, and report their state) ISY_GROUP_PLATFORM = Platform.SWITCH -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" ISY_CONF_NAME = "name" @@ -186,8 +182,6 @@ UOM_INDEX = "25" UOM_ON_OFF = "2" UOM_PERCENTAGE = "51" -SENSOR_AUX = "sensor_aux" - # 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 @@ -313,6 +307,10 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { FILTER_ZWAVE_CAT: ["140"], }, } +NODE_AUX_FILTERS: dict[str, Platform] = { + PROP_ON_LEVEL: Platform.SENSOR, + PROP_RAMP_RATE: Platform.SENSOR, +} UOM_FRIENDLY_NAME = { "1": UnitOfElectricCurrent.AMPERE, diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 6f85856ca0a..97f3c669772 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -16,15 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - _LOGGER, - DOMAIN, - ISY_DEVICES, - ISY_NODES, - ISY_PROGRAMS, - UOM_8_BIT_RANGE, - UOM_BARRIER, -) +from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE, UOM_BARRIER from .entity import ISYNodeEntity, ISYProgramEntity @@ -32,13 +24,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[DOMAIN][entry.entry_id] + isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYCoverEntity | ISYCoverProgramEntity] = [] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] - for node in hass_isy_data[ISY_NODES][Platform.COVER]: + devices: dict[str, DeviceInfo] = isy_data.devices + for node in isy_data.nodes[Platform.COVER]: entities.append(ISYCoverEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.COVER]: + for name, status, actions in isy_data.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 173c2432981..162845be92c 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -11,7 +11,7 @@ from pyisy.constants import ( PROTO_ZWAVE, ) from pyisy.helpers import EventListener, NodeProperty -from pyisy.nodes import Node +from pyisy.nodes import Group, Node from pyisy.programs import Program from pyisy.variables import Variable @@ -30,7 +30,11 @@ class ISYEntity(Entity): _attr_should_poll = False _node: Node | Program | Variable - def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: + def __init__( + self, + node: Node | Group | Variable | Program, + device_info: DeviceInfo | None = None, + ) -> None: """Initialize the insteon device.""" self._node = node self._attr_name = node.name @@ -89,10 +93,7 @@ class ISYNodeEntity(ISYEntity): attr = {} node = self._node # Insteon aux_properties are now their own sensors - if ( - hasattr(self._node, "aux_properties") - and getattr(node, "protocol", None) != PROTO_INSTEON - ): + if hasattr(self._node, "aux_properties") and node.protocol != PROTO_INSTEON: for name, value in self._node.aux_properties.items(): attr_name = COMMAND_FRIENDLY_NAME.get(name, name) attr[attr_name] = str(value.formatted).lower() @@ -128,7 +129,7 @@ class ISYNodeEntity(ISYEntity): async def async_get_zwave_parameter(self, parameter: Any) -> None: """Respond to an entity service command to request a Z-Wave device parameter from the ISY.""" - if not hasattr(self._node, "protocol") or self._node.protocol != PROTO_ZWAVE: + if self._node.protocol != PROTO_ZWAVE: raise HomeAssistantError( "Invalid service call: cannot request Z-Wave Parameter for non-Z-Wave" f" device {self.entity_id}" @@ -139,7 +140,7 @@ class ISYNodeEntity(ISYEntity): self, parameter: Any, value: Any | None, size: int | None ) -> None: """Respond to an entity service command to set a Z-Wave device parameter via the ISY.""" - if not hasattr(self._node, "protocol") or self._node.protocol != PROTO_ZWAVE: + if self._node.protocol != PROTO_ZWAVE: raise HomeAssistantError( "Invalid service call: cannot set Z-Wave Parameter for non-Z-Wave" f" device {self.entity_id}" @@ -155,7 +156,10 @@ class ISYNodeEntity(ISYEntity): class ISYProgramEntity(ISYEntity): """Representation of an ISY program base.""" - def __init__(self, name: str, status: Any | None, actions: Program = None) -> None: + _actions: Program + _status: Program + + def __init__(self, name: str, status: Program, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" super().__init__(status) self._attr_name = name diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 3ce813d51a1..75c033bd9ea 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -18,7 +18,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS +from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity SPEED_RANGE = (1, 255) # off is not included @@ -28,14 +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[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = isy_data.devices entities: list[ISYFanEntity | ISYFanProgramEntity] = [] - for node in hass_isy_data[ISY_NODES][Platform.FAN]: + for node in isy_data.nodes[Platform.FAN]: entities.append(ISYFanEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.FAN]: + for name, status, actions in isy_data.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 fbbaa8f7b10..263100c90eb 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -5,6 +5,11 @@ from typing import cast from pyisy.constants import ( ISY_VALUE_UNKNOWN, + PROP_BUSY, + PROP_COMMS_ERROR, + PROP_ON_LEVEL, + PROP_RAMP_RATE, + PROP_STATUS, PROTO_GROUP, PROTO_INSTEON, PROTO_PROGRAM, @@ -27,18 +32,12 @@ from .const import ( FILTER_STATES, FILTER_UOM, FILTER_ZWAVE_CAT, - ISY_DEVICES, ISY_GROUP_PLATFORM, - ISY_NODES, - ISY_PROGRAMS, - ISY_ROOT_NODES, - ISY_VARIABLES, KEY_ACTIONS, KEY_STATUS, NODE_FILTERS, NODE_PLATFORMS, PROGRAM_PLATFORMS, - SENSOR_AUX, SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, SUBNODE_EZIO2X4_SENSORS, @@ -49,13 +48,19 @@ from .const import ( UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES, ) +from .models import IsyData BINARY_SENSOR_UOMS = ["2", "78"] BINARY_SENSOR_ISY_STATES = ["on", "off"] +ROOT_AUX_CONTROLS = { + PROP_ON_LEVEL, + PROP_RAMP_RATE, +} +SKIP_AUX_PROPS = {PROP_BUSY, PROP_COMMS_ERROR, PROP_STATUS, *ROOT_AUX_CONTROLS} def _check_for_node_def( - hass_isy_data: dict, node: Group | Node, single_platform: Platform | None = None + isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None ) -> bool: """Check if the node matches the node_def_id for any platforms. @@ -71,14 +76,14 @@ def _check_for_node_def( 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[ISY_NODES][platform].append(node) + isy_data.nodes[platform].append(node) return True return False def _check_for_insteon_type( - hass_isy_data: dict, node: Group | Node, single_platform: Platform | None = None + isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None ) -> bool: """Check if the node matches the Insteon type for any platforms. @@ -107,7 +112,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[ISY_NODES][Platform.LIGHT].append(node) + isy_data.nodes[Platform.LIGHT].append(node) return True # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 @@ -115,7 +120,7 @@ def _check_for_insteon_type( SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, ): - hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR].append(node) + isy_data.nodes[Platform.BINARY_SENSOR].append(node) return True # IOLincs which have a sensor and relay on 2 different nodes @@ -124,7 +129,7 @@ def _check_for_insteon_type( and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS) and subnode_id == SUBNODE_IOLINC_RELAY ): - hass_isy_data[ISY_NODES][Platform.SWITCH].append(node) + isy_data.nodes[Platform.SWITCH].append(node) return True # Smartenit EZIO2X4 @@ -133,17 +138,17 @@ def _check_for_insteon_type( and device_type.startswith(TYPE_EZIO2X4) and subnode_id in SUBNODE_EZIO2X4_SENSORS ): - hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR].append(node) + isy_data.nodes[Platform.BINARY_SENSOR].append(node) return True - hass_isy_data[ISY_NODES][platform].append(node) + isy_data.nodes[platform].append(node) return True return False def _check_for_zwave_cat( - hass_isy_data: dict, node: Group | Node, single_platform: Platform | None = None + isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None ) -> bool: """Check if the node matches the ISY Z-Wave Category for any platforms. @@ -164,14 +169,14 @@ def _check_for_zwave_cat( device_type.startswith(t) for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT]) ): - hass_isy_data[ISY_NODES][platform].append(node) + isy_data.nodes[platform].append(node) return True return False def _check_for_uom_id( - hass_isy_data: dict, + isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None, uom_list: list[str] | None = None, @@ -190,23 +195,23 @@ def _check_for_uom_id( if isinstance(node.uom, list): node_uom = node.uom[0] - if uom_list: + if uom_list and single_platform: if node_uom in uom_list: - hass_isy_data[ISY_NODES][single_platform].append(node) + isy_data.nodes[single_platform].append(node) return True return False 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[ISY_NODES][platform].append(node) + isy_data.nodes[platform].append(node) return True return False def _check_for_states_in_uom( - hass_isy_data: dict, + isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None, states_list: list[str] | None = None, @@ -227,28 +232,26 @@ def _check_for_states_in_uom( node_uom = set(map(str.lower, node.uom)) - if states_list: + if states_list and single_platform: if node_uom == set(states_list): - hass_isy_data[ISY_NODES][single_platform].append(node) + isy_data.nodes[single_platform].append(node) return True return False 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[ISY_NODES][platform].append(node) + isy_data.nodes[platform].append(node) return True return False -def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Group | Node) -> bool: +def _is_sensor_a_binary_sensor(isy_data: IsyData, node: Group | Node) -> bool: """Determine if the given sensor node should be a binary_sensor.""" - if _check_for_node_def(hass_isy_data, node, single_platform=Platform.BINARY_SENSOR): + if _check_for_node_def(isy_data, node, single_platform=Platform.BINARY_SENSOR): return True - if _check_for_insteon_type( - hass_isy_data, node, single_platform=Platform.BINARY_SENSOR - ): + if _check_for_insteon_type(isy_data, node, single_platform=Platform.BINARY_SENSOR): return True # For the next two checks, we're providing our own set of uoms that @@ -256,14 +259,14 @@ def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Group | Node) -> bool: # checks in the context of already knowing that this is definitely a # sensor device. if _check_for_uom_id( - hass_isy_data, + isy_data, node, single_platform=Platform.BINARY_SENSOR, uom_list=BINARY_SENSOR_UOMS, ): return True if _check_for_states_in_uom( - hass_isy_data, + isy_data, node, single_platform=Platform.BINARY_SENSOR, states_list=BINARY_SENSOR_ISY_STATES, @@ -309,7 +312,7 @@ def _generate_device_info(node: Node) -> DeviceInfo: def _categorize_nodes( - hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str + isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str ) -> None: """Sort the nodes to their proper platforms.""" for path, node in nodes: @@ -320,44 +323,53 @@ def _categorize_nodes( if hasattr(node, "parent_node") and node.parent_node is None: # 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) + isy_data.devices[node.address] = _generate_device_info(node) + isy_data.root_nodes[Platform.BUTTON].append(node) + # Any parent node can have communication errors: + isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR)) + # Add Ramp Rate and On Levels for Dimmable Load devices + if getattr(node, "is_dimmable", False): + aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties) + for control in aux_controls: + isy_data.aux_properties[Platform.SENSOR].append((node, control)) if node.protocol == PROTO_GROUP: - hass_isy_data[ISY_NODES][ISY_GROUP_PLATFORM].append(node) + isy_data.nodes[ISY_GROUP_PLATFORM].append(node) continue if node.protocol == PROTO_INSTEON: for control in node.aux_properties: - hass_isy_data[ISY_NODES][SENSOR_AUX].append((node, control)) + if control in SKIP_AUX_PROPS: + continue + isy_data.aux_properties[Platform.SENSOR].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): + if _is_sensor_a_binary_sensor(isy_data, node): continue - hass_isy_data[ISY_NODES][Platform.SENSOR].append(node) + isy_data.nodes[Platform.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_isy_data, node): + if _check_for_node_def(isy_data, node): continue - if _check_for_insteon_type(hass_isy_data, node): + if _check_for_insteon_type(isy_data, node): continue - if _check_for_zwave_cat(hass_isy_data, node): + if _check_for_zwave_cat(isy_data, node): continue - if _check_for_uom_id(hass_isy_data, node): + if _check_for_uom_id(isy_data, node): continue - if _check_for_states_in_uom(hass_isy_data, node): + if _check_for_states_in_uom(isy_data, node): continue # Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes. - hass_isy_data[ISY_NODES][Platform.SENSOR].append(node) + isy_data.nodes[Platform.SENSOR].append(node) -def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: +def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: """Categorize the ISY programs.""" for platform in PROGRAM_PLATFORMS: folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}") @@ -393,25 +405,21 @@ def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: continue entity = (entity_folder.name, status, actions) - hass_isy_data[ISY_PROGRAMS][platform].append(entity) + isy_data.programs[platform].append(entity) def _categorize_variables( - hass_isy_data: dict, variables: Variables, identifier: str + isy_data: IsyData, variables: Variables, identifier: str ) -> None: """Gather the ISY Variables to be added as sensors.""" try: - var_to_add = [ - (vtype, vname, vid) + isy_data.variables[Platform.SENSOR] = [ + variables[vtype][vid] for (vtype, vname, vid) in variables.children if identifier in vname ] except KeyError as err: _LOGGER.error("Error adding ISY Variables: %s", err) - return - variable_entities = hass_isy_data[ISY_VARIABLES] - for vtype, vname, vid in var_to_add: - variable_entities[Platform.SENSOR].append((vname, variables[vtype][vid])) def convert_isy_value_to_hass( diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 7df16151fc4..4a590c3eb4b 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -15,14 +15,7 @@ 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, - ISY_DEVICES, - ISY_NODES, - UOM_PERCENTAGE, -) +from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE from .entity import ISYNodeEntity from .services import async_setup_light_services @@ -33,13 +26,13 @@ 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[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = isy_data.devices isy_options = entry.options restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) entities = [] - for node in hass_isy_data[ISY_NODES][Platform.LIGHT]: + for node in isy_data.nodes[Platform.LIGHT]: entities.append( ISYLightEntity(node, restore_light_state, devices.get(node.primary_node)) ) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index 4bc46e8ae37..c5372135bbb 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS +from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity VALUE_TO_STATE = {0: False, 100: True} @@ -22,13 +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[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + isy_data = hass.data[DOMAIN][entry.entry_id] + devices: dict[str, DeviceInfo] = isy_data.devices entities: list[ISYLockEntity | ISYLockProgramEntity] = [] - for node in hass_isy_data[ISY_NODES][Platform.LOCK]: + for node in isy_data.nodes[Platform.LOCK]: entities.append(ISYLockEntity(node, devices.get(node.primary_node))) - for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.LOCK]: + for name, status, actions in isy_data.programs[Platform.LOCK]: entities.append(ISYLockProgramEntity(name, status, actions)) async_add_entities(entities) diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py new file mode 100644 index 00000000000..202bebb32f8 --- /dev/null +++ b/homeassistant/components/isy994/models.py @@ -0,0 +1,96 @@ +"""The ISY/IoX integration data models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from pyisy import ISY +from pyisy.constants import PROTO_INSTEON +from pyisy.networking import NetworkCommand +from pyisy.nodes import Group, Node +from pyisy.programs import Program +from pyisy.variables import Variable + +from homeassistant.const import Platform +from homeassistant.helpers.entity import DeviceInfo + +from .const import ( + CONF_NETWORK, + NODE_AUX_PROP_PLATFORMS, + NODE_PLATFORMS, + PROGRAM_PLATFORMS, + ROOT_NODE_PLATFORMS, + VARIABLE_PLATFORMS, +) + + +@dataclass +class IsyData: + """Data for the ISY/IoX integration.""" + + root: ISY + nodes: dict[Platform, list[Node | Group]] + root_nodes: dict[Platform, list[Node]] + variables: dict[Platform, list[Variable]] + programs: dict[Platform, list[tuple[str, Program, Program]]] + net_resources: list[NetworkCommand] + devices: dict[str, DeviceInfo] + aux_properties: dict[Platform, list[tuple[Node, str]]] + + def __init__(self) -> None: + """Initialize an empty ISY data class.""" + self.nodes = {p: [] for p in NODE_PLATFORMS} + self.root_nodes = {p: [] for p in ROOT_NODE_PLATFORMS} + self.aux_properties = {p: [] for p in NODE_AUX_PROP_PLATFORMS} + self.programs = {p: [] for p in PROGRAM_PLATFORMS} + self.variables = {p: [] for p in VARIABLE_PLATFORMS} + self.net_resources = [] + self.devices = {} + + @property + def uuid(self) -> str: + """Return the ISY UUID identification.""" + return cast(str, self.root.uuid) + + def uid_base(self, node: Node | Group | Variable | Program | NetworkCommand) -> str: + """Return the unique id base string for a given node.""" + if isinstance(node, NetworkCommand): + return f"{self.uuid}_{CONF_NETWORK}_{node.address}" + return f"{self.uuid}_{node.address}" + + @property + def unique_ids(self) -> set[tuple[Platform, str]]: + """Return all the unique ids for a config entry id.""" + current_unique_ids: set[tuple[Platform, str]] = { + (Platform.BUTTON, f"{self.uuid}_query") + } + + # Structure and prefixes here must match what's added in __init__ and helpers + for platform in NODE_PLATFORMS: + for node in self.nodes[platform]: + current_unique_ids.add((platform, self.uid_base(node))) + + for platform in NODE_AUX_PROP_PLATFORMS: + for node, control in self.aux_properties[platform]: + current_unique_ids.add((platform, f"{self.uid_base(node)}_{control}")) + + for platform in PROGRAM_PLATFORMS: + for _, node, _ in self.programs[platform]: + current_unique_ids.add((platform, self.uid_base(node))) + + for platform in VARIABLE_PLATFORMS: + for node in self.variables[platform]: + current_unique_ids.add((platform, self.uid_base(node))) + if platform == Platform.NUMBER: + current_unique_ids.add((platform, f"{self.uid_base(node)}_init")) + + for platform in ROOT_NODE_PLATFORMS: + for node in self.root_nodes[platform]: + current_unique_ids.add((platform, f"{self.uid_base(node)}_query")) + if platform == Platform.BUTTON and node.protocol == PROTO_INSTEON: + current_unique_ids.add((platform, f"{self.uid_base(node)}_beep")) + + for node in self.net_resources: + current_unique_ids.add((Platform.BUTTON, self.uid_base(node))) + + return current_unique_ids diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index eaa3cf186af..3cc4fbe770c 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -from pyisy import ISY from pyisy.helpers import EventListener, NodeProperty from pyisy.variables import Variable @@ -14,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ISY_DEVICES, ISY_ROOT, ISY_VARIABLES +from .const import CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, DOMAIN from .helpers import convert_isy_value_to_hass ISY_MAX_SIZE = (2**32) / 2 @@ -26,19 +25,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" - hass_isy_data = hass.data[DOMAIN][config_entry.entry_id] - isy: ISY = hass_isy_data[ISY_ROOT] - device_info = hass_isy_data[ISY_DEVICES] + isy_data = hass.data[DOMAIN][config_entry.entry_id] + device_info = isy_data.devices entities: list[ISYVariableNumberEntity] = [] + var_id = config_entry.options.get(CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING) - for node, enable_by_default in hass_isy_data[ISY_VARIABLES][Platform.NUMBER]: + for node in isy_data.variables[Platform.NUMBER]: step = 10 ** (-1 * node.prec) min_max = ISY_MAX_SIZE / (10**node.prec) description = NumberEntityDescription( key=node.address, name=node.name, icon="mdi:counter", - entity_registry_enabled_default=enable_by_default, + entity_registry_enabled_default=var_id in node.name, native_unit_of_measurement=None, native_step=step, native_min_value=-min_max, @@ -59,7 +58,7 @@ async def async_setup_entry( entities.append( ISYVariableNumberEntity( node, - unique_id=f"{isy.uuid}_{node.address}", + unique_id=isy_data.uid_base(node), description=description, device_info=device_info[CONF_VARIABLES], ) @@ -67,7 +66,7 @@ async def async_setup_entry( entities.append( ISYVariableNumberEntity( node=node, - unique_id=f"{isy.uuid}_{node.address}_init", + unique_id=f"{isy_data.uid_base(node)}_init", description=description_init, device_info=device_info[CONF_VARIABLES], init_entity=True, diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 925aad176db..097beab4caf 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -19,6 +19,7 @@ from pyisy.constants import ( ) from pyisy.helpers import NodeProperty from pyisy.nodes import Node +from pyisy.variables import Variable from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,10 +35,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( _LOGGER, DOMAIN, - ISY_DEVICES, - ISY_NODES, - ISY_VARIABLES, - SENSOR_AUX, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, UOM_INDEX, @@ -110,20 +107,17 @@ 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[DOMAIN][entry.entry_id] + isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYSensorEntity | ISYSensorVariableEntity] = [] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] + devices: dict[str, DeviceInfo] = isy_data.devices - for node in hass_isy_data[ISY_NODES][Platform.SENSOR]: + for node in isy_data.nodes[Platform.SENSOR]: _LOGGER.debug("Loading %s", node.name) entities.append(ISYSensorEntity(node, devices.get(node.primary_node))) - aux_nodes = set() - for node, control in hass_isy_data[ISY_NODES][SENSOR_AUX]: - aux_nodes.add(node) - if control in SKIP_AUX_PROPERTIES: - continue - _LOGGER.debug("Loading %s %s", node.name, node.aux_properties[control]) + aux_sensors_list = isy_data.aux_properties[Platform.SENSOR] + for node, control in aux_sensors_list: + _LOGGER.debug("Loading %s %s", node.name, COMMAND_FRIENDLY_NAME.get(control)) enabled_default = control not in AUX_DISABLED_BY_DEFAULT_EXACT and not any( control.startswith(match) for match in AUX_DISABLED_BY_DEFAULT_MATCH ) @@ -132,23 +126,13 @@ async def async_setup_entry( node=node, control=control, enabled_default=enabled_default, + unique_id=f"{isy_data.uid_base(node)}_{control}", 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=node, - control=PROP_COMMS_ERROR, - enabled_default=False, - device_info=devices.get(node.primary_node), - ) - ) - - for vname, vobj in hass_isy_data[ISY_VARIABLES][Platform.SENSOR]: - entities.append(ISYSensorVariableEntity(vname, vobj)) + for variable in isy_data.variables[Platform.SENSOR]: + entities.append(ISYSensorVariableEntity(variable)) async_add_entities(entities) @@ -248,6 +232,7 @@ class ISYAuxSensorEntity(ISYSensorEntity): node: Node, control: str, enabled_default: bool, + unique_id: str, device_info: DeviceInfo | None = None, ) -> None: """Initialize the ISY aux sensor.""" @@ -257,12 +242,11 @@ class ISYAuxSensorEntity(ISYSensorEntity): 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) + self._attr_unique_id = unique_id 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.""" @@ -283,10 +267,10 @@ class ISYSensorVariableEntity(ISYEntity, SensorEntity): # Deprecated sensors, will be removed in 2023.5.0 _attr_entity_registry_enabled_default = False - def __init__(self, vname: str, vobj: object) -> None: + def __init__(self, variable_node: Variable) -> None: """Initialize the ISY binary sensor program.""" - super().__init__(vobj) - self._name = vname + super().__init__(variable_node) + self._name = variable_node.name @property def native_value(self) -> float | int | None: diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index dc82a24f092..ac56a8256c1 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -23,15 +23,8 @@ import homeassistant.helpers.entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import entity_service_call -from .const import ( - _LOGGER, - CONF_NETWORK, - DOMAIN, - ISY_CONF_NAME, - ISY_CONF_NETWORKING, - ISY_ROOT, -) -from .util import unique_ids_for_config_entry_id +from .const import _LOGGER, CONF_NETWORK, DOMAIN, ISY_CONF_NAME, ISY_CONF_NETWORKING +from .util import _async_cleanup_registry_entries # Common Services for All Platforms: SERVICE_SYSTEM_QUERY = "system_query" @@ -192,7 +185,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][ISY_ROOT] + isy_data = hass.data[DOMAIN][config_entry_id] + isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue # If an address is provided, make sure we query the correct ISY. @@ -235,7 +229,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][ISY_ROOT] + isy_data = hass.data[DOMAIN][config_entry_id] + isy = isy_data.root if isy_name and isy_name != isy.conf[ISY_CONF_NAME]: continue if isy.networking is None or not isy.conf[ISY_CONF_NETWORKING]: @@ -272,7 +267,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][ISY_ROOT] + isy_data = hass.data[DOMAIN][config_entry_id] + isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue program = None @@ -295,7 +291,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][ISY_ROOT] + isy_data = hass.data[DOMAIN][config_entry_id] + isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue variable = None @@ -323,32 +320,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 @callback 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) - for config_entry_id in hass.data[DOMAIN]: - entries_for_this_config = er.async_entries_for_config_entry( - entity_registry, config_entry_id - ) - 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) - ) - - for entity in extra_entities: - if entity_registry.async_is_registered(entities[entity]): - entity_registry.async_remove(entities[entity]) - - _LOGGER.debug( - ( - "Cleaning up ISY entities: removed %s extra entities for config entry: %s" - ), - len(extra_entities), - len(config_entry_id), - ) + _async_cleanup_registry_entries(hass, 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 224f008818c..4e3b42cb8f0 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN, ISY_DEVICES, ISY_NODES, ISY_PROGRAMS +from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity @@ -20,10 +20,10 @@ 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[DOMAIN][entry.entry_id] + isy_data = hass.data[DOMAIN][entry.entry_id] entities: list[ISYSwitchProgramEntity | ISYSwitchEntity] = [] - devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES] - for node in hass_isy_data[ISY_NODES][Platform.SWITCH]: + devices: dict[str, DeviceInfo] = isy_data.devices + for node in isy_data.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 @@ -31,7 +31,7 @@ async def async_setup_entry( entities.append(ISYSwitchEntity(node, devices.get(primary))) - for name, status, actions in hass_isy_data[ISY_PROGRAMS][Platform.SWITCH]: + for name, status, actions in isy_data.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 242f0db7826..44286111a62 100644 --- a/homeassistant/components/isy994/system_health.py +++ b/homeassistant/components/isy994/system_health.py @@ -10,7 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, ISY_ROOT, ISY_URL_POSTFIX +from .const import DOMAIN, ISY_URL_POSTFIX +from .models import IsyData @callback @@ -28,7 +29,8 @@ 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][ISY_ROOT] + isy_data: IsyData = hass.data[DOMAIN][config_entry_id] + isy: ISY = isy_data.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 39ce8ba2be3..f4846b61aed 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -1,69 +1,34 @@ """ISY utils.""" from __future__ import annotations -from pyisy.constants import PROP_COMMS_ERROR, PROTO_INSTEON +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.entity_registry as er -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from .const import ( - CONF_NETWORK, - DOMAIN, - ISY_NET_RES, - ISY_NODES, - ISY_PROGRAMS, - ISY_ROOT, - ISY_ROOT_NODES, - ISY_VARIABLES, - NODE_PLATFORMS, - PROGRAM_PLATFORMS, - ROOT_NODE_PLATFORMS, - SENSOR_AUX, -) +from .const import _LOGGER, DOMAIN -def unique_ids_for_config_entry_id( - hass: HomeAssistant, config_entry_id: 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] - isy = hass_isy_data[ISY_ROOT] - current_unique_ids: set[tuple[Platform | str, str]] = { - (Platform.BUTTON, f"{isy.uuid}_query") +@callback +def _async_cleanup_registry_entries(hass: HomeAssistant, entry_id: str) -> None: + """Remove extra entities that are no longer part of the integration.""" + entity_registry = er.async_get(hass) + isy_data = hass.data[DOMAIN][entry_id] + + existing_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + entities = { + (entity.domain, entity.unique_id): entity.entity_id + for entity in existing_entries } - # 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}")) + extra_entities = set(entities.keys()).difference(isy_data.unique_ids) + if not extra_entities: + return - 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 entity in extra_entities: + if entity_registry.async_is_registered(entities[entity]): + entity_registry.async_remove(entities[entity]) - for platform in PROGRAM_PLATFORMS: - 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[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 + _LOGGER.debug( + ("Cleaning up ISY entities: removed %s extra entities for config entry %s"), + len(extra_entities), + entry_id, + ) diff --git a/tests/components/isy994/test_system_health.py b/tests/components/isy994/test_system_health.py index d76eafb2aad..c21d3257e1b 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, ISY_ROOT, ISY_URL_POSTFIX +from homeassistant.components.isy994.const import DOMAIN, ISY_URL_POSTFIX from homeassistant.const import CONF_HOST from homeassistant.setup import async_setup_component @@ -31,15 +31,16 @@ async def test_system_health(hass, aioclient_mock): unique_id=MOCK_UUID, ).add_to_hass(hass) - hass.data[DOMAIN] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID][ISY_ROOT] = Mock( - connected=True, - websocket=Mock( - last_heartbeat=MOCK_HEARTBEAT, - status=MOCK_CONNECTED, - ), + isy_data = Mock( + root=Mock( + connected=True, + websocket=Mock( + last_heartbeat=MOCK_HEARTBEAT, + status=MOCK_CONNECTED, + ), + ) ) + hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} info = await get_system_health_info(hass, DOMAIN) @@ -67,15 +68,16 @@ async def test_system_health_failed_connect(hass, aioclient_mock): unique_id=MOCK_UUID, ).add_to_hass(hass) - hass.data[DOMAIN] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID] = {} - hass.data[DOMAIN][MOCK_ENTRY_ID][ISY_ROOT] = Mock( - connected=True, - websocket=Mock( - last_heartbeat=MOCK_HEARTBEAT, - status=MOCK_CONNECTED, - ), + isy_data = Mock( + root=Mock( + connected=True, + websocket=Mock( + last_heartbeat=MOCK_HEARTBEAT, + status=MOCK_CONNECTED, + ), + ) ) + hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} info = await get_system_health_info(hass, DOMAIN)