Consolidate device info and clean-up ISY994 code base (#85657)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
shbatm 2023-01-11 22:07:44 -06:00 committed by GitHub
parent 43cc8a1ebf
commit 255a8362a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 481 additions and 446 deletions

View file

@ -7,7 +7,6 @@ from urllib.parse import urlparse
from aiohttp import CookieJar from aiohttp import CookieJar
import async_timeout import async_timeout
from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError
from pyisy.constants import PROTO_NETWORK_RESOURCE
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -15,6 +14,7 @@ from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_PASSWORD, CONF_PASSWORD,
CONF_USERNAME, CONF_USERNAME,
CONF_VARIABLES,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
Platform, Platform,
) )
@ -22,11 +22,14 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers import aiohttp_client, config_validation as cv
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
_LOGGER, _LOGGER,
CONF_IGNORE_STRING, CONF_IGNORE_STRING,
CONF_NETWORK,
CONF_RESTORE_LIGHT_STATE, CONF_RESTORE_LIGHT_STATE,
CONF_SENSOR_STRING, CONF_SENSOR_STRING,
CONF_TLS_VER, CONF_TLS_VER,
@ -36,28 +39,29 @@ from .const import (
DEFAULT_SENSOR_STRING, DEFAULT_SENSOR_STRING,
DEFAULT_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING,
DOMAIN, DOMAIN,
ISY994_ISY,
ISY994_NODES,
ISY994_PROGRAMS,
ISY994_VARIABLES,
ISY_CONF_FIRMWARE, ISY_CONF_FIRMWARE,
ISY_CONF_MODEL, ISY_CONF_MODEL,
ISY_CONF_NAME, ISY_CONF_NAME,
ISY_CONF_NETWORKING, ISY_CONF_NETWORKING,
ISY_CONF_UUID, ISY_DEVICES,
ISY_CONN_ADDRESS, ISY_NET_RES,
ISY_CONN_PORT, ISY_NODES,
ISY_CONN_TLS, ISY_PROGRAMS,
ISY_ROOT,
ISY_ROOT_NODES,
ISY_VARIABLES,
MANUFACTURER, MANUFACTURER,
NODE_PLATFORMS,
PLATFORMS, PLATFORMS,
PROGRAM_PLATFORMS, PROGRAM_PLATFORMS,
ROOT_NODE_PLATFORMS,
SCHEME_HTTP, SCHEME_HTTP,
SCHEME_HTTPS, SCHEME_HTTPS,
SENSOR_AUX, SENSOR_AUX,
VARIABLE_PLATFORMS,
) )
from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables
from .services import async_setup_services, async_unload_services from .services import async_setup_services, async_unload_services
from .util import unique_ids_for_config_entry_id
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -134,17 +138,12 @@ async def async_setup_entry(
hass.data[DOMAIN][entry.entry_id] = {} hass.data[DOMAIN][entry.entry_id] = {}
hass_isy_data = 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: []} hass_isy_data[ISY_NODES] = {p: [] for p in (NODE_PLATFORMS + [SENSOR_AUX])}
for platform in PLATFORMS: hass_isy_data[ISY_ROOT_NODES] = {p: [] for p in ROOT_NODE_PLATFORMS}
hass_isy_data[ISY994_NODES][platform] = [] 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[ISY994_PROGRAMS] = {} hass_isy_data[ISY_NET_RES] = []
for platform in PROGRAM_PLATFORMS: hass_isy_data[ISY_DEVICES] = {}
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] = []
isy_config = entry.data isy_config = entry.data
isy_options = entry.options 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 call to be removed with variable sensors in 2023.5.0
_categorize_variables(hass_isy_data, isy.variables, variable_identifier) _categorize_variables(hass_isy_data, isy.variables, variable_identifier)
# Gather ISY Variables to be added. Identifier used to enable by default. # Gather ISY Variables to be added. Identifier used to enable by default.
numbers = hass_isy_data[ISY994_VARIABLES][Platform.NUMBER] if len(isy.variables.children) > 0:
for vtype, vname, vid in isy.variables.children: hass_isy_data[ISY_DEVICES][CONF_VARIABLES] = _create_service_device_info(
numbers.append((isy.variables[vtype][vid], variable_identifier in vname)) isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES
if isy.configuration[ISY_CONF_NETWORKING]: )
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: 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 # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs
_LOGGER.info(repr(isy.clock)) _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) _async_get_or_create_isy_device_in_registry(hass, entry, isy)
# Load platforms for the devices in the ISY controller that we support. # 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) 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 @callback
def _async_get_or_create_isy_device_in_registry( def _async_get_or_create_isy_device_in_registry(
hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY
) -> None: ) -> None:
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
url = _async_isy_to_configuration_url(isy)
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, isy.configuration[ISY_CONF_UUID])}, connections={(dr.CONNECTION_NETWORK_MAC, isy.uuid)},
identifiers={(DOMAIN, isy.configuration[ISY_CONF_UUID])}, identifiers={(DOMAIN, isy.uuid)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=isy.configuration[ISY_CONF_NAME], name=isy.conf[ISY_CONF_NAME],
model=isy.configuration[ISY_CONF_MODEL], model=isy.conf[ISY_CONF_MODEL],
sw_version=isy.configuration[ISY_CONF_FIRMWARE], sw_version=isy.conf[ISY_CONF_FIRMWARE],
configuration_url=url, 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] 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") _LOGGER.debug("ISY Stopping Event Stream and automatic updates")
isy.websocket.stop() isy.websocket.stop()
@ -333,7 +349,7 @@ async def async_remove_config_entry_device(
device_entry: dr.DeviceEntry, device_entry: dr.DeviceEntry,
) -> bool: ) -> bool:
"""Remove ISY config entry from a device.""" """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( return not device_entry.identifiers.intersection(
(DOMAIN, unique_id) (DOMAIN, unique_id) for unique_id in hass_isy_devices
for unique_id in unique_ids_for_config_entry_id(hass, config_entry.entry_id)
) )

View file

@ -21,6 +21,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, Platform from homeassistant.const import STATE_ON, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
@ -30,9 +31,10 @@ from .const import (
_LOGGER, _LOGGER,
BINARY_SENSOR_DEVICE_TYPES_ISY, BINARY_SENSOR_DEVICE_TYPES_ISY,
BINARY_SENSOR_DEVICE_TYPES_ZWAVE, BINARY_SENSOR_DEVICE_TYPES_ZWAVE,
DOMAIN as ISY994_DOMAIN, DOMAIN,
ISY994_NODES, ISY_DEVICES,
ISY994_PROGRAMS, ISY_NODES,
ISY_PROGRAMS,
SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_COOL,
SUBNODE_CLIMATE_HEAT, SUBNODE_CLIMATE_HEAT,
SUBNODE_DUSK_DAWN, SUBNODE_DUSK_DAWN,
@ -70,27 +72,33 @@ async def async_setup_entry(
| ISYBinarySensorHeartbeat | ISYBinarySensorHeartbeat
| ISYBinarySensorProgramEntity, | 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 entity: ISYInsteonBinarySensorEntity | ISYBinarySensorEntity | ISYBinarySensorHeartbeat | ISYBinarySensorProgramEntity
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] hass_isy_data = hass.data[DOMAIN][entry.entry_id]
for node in hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR]: devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES]
for node in hass_isy_data[ISY_NODES][Platform.BINARY_SENSOR]:
assert isinstance(node, Node) assert isinstance(node, Node)
device_info = devices.get(node.primary_node)
device_class, device_type = _detect_device_type_and_class(node) device_class, device_type = _detect_device_type_and_class(node)
if node.protocol == PROTO_INSTEON: if node.protocol == PROTO_INSTEON:
if node.parent_node is not None: if node.parent_node is not None:
# We'll process the Insteon child nodes last, to ensure all parent # We'll process the Insteon child nodes last, to ensure all parent
# nodes have been processed # nodes have been processed
child_nodes.append((node, device_class, device_type)) child_nodes.append((node, device_class, device_type, device_info))
continue continue
entity = ISYInsteonBinarySensorEntity(node, device_class) entity = ISYInsteonBinarySensorEntity(
node, device_class, device_info=device_info
)
else: else:
entity = ISYBinarySensorEntity(node, device_class) entity = ISYBinarySensorEntity(node, device_class, device_info=device_info)
entities.append(entity) entities.append(entity)
entities_by_address[node.address] = entity entities_by_address[node.address] = entity
# Handle some special child node cases for Insteon Devices # 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) subnode_id = int(node.address.split(" ")[-1], 16)
# Handle Insteon Thermostats # Handle Insteon Thermostats
if device_type is not None and device_type.startswith(TYPE_CATEGORY_CLIMATE): 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 # As soon as the ISY Event Stream connects if it has a
# valid state, it will be set. # valid state, it will be set.
entity = ISYInsteonBinarySensorEntity( entity = ISYInsteonBinarySensorEntity(
node, BinarySensorDeviceClass.COLD, False node, BinarySensorDeviceClass.COLD, False, device_info=device_info
) )
entities.append(entity) entities.append(entity)
elif subnode_id == SUBNODE_CLIMATE_HEAT: elif subnode_id == SUBNODE_CLIMATE_HEAT:
# Subnode 3 is the "Heat Control" sensor # Subnode 3 is the "Heat Control" sensor
entity = ISYInsteonBinarySensorEntity( entity = ISYInsteonBinarySensorEntity(
node, BinarySensorDeviceClass.HEAT, False node, BinarySensorDeviceClass.HEAT, False, device_info=device_info
) )
entities.append(entity) entities.append(entity)
continue continue
@ -138,7 +146,9 @@ async def async_setup_entry(
assert isinstance(parent_entity, ISYInsteonBinarySensorEntity) assert isinstance(parent_entity, ISYInsteonBinarySensorEntity)
# Subnode 4 is the heartbeat node, which we will # Subnode 4 is the heartbeat node, which we will
# represent as a separate binary_sensor # 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) parent_entity.add_heartbeat_device(entity)
entities.append(entity) entities.append(entity)
continue continue
@ -157,14 +167,17 @@ async def async_setup_entry(
if subnode_id == SUBNODE_DUSK_DAWN: if subnode_id == SUBNODE_DUSK_DAWN:
# Subnode 2 is the Dusk/Dawn sensor # Subnode 2 is the Dusk/Dawn sensor
entity = ISYInsteonBinarySensorEntity( entity = ISYInsteonBinarySensorEntity(
node, BinarySensorDeviceClass.LIGHT node, BinarySensorDeviceClass.LIGHT, device_info=device_info
) )
entities.append(entity) entities.append(entity)
continue continue
if subnode_id == SUBNODE_LOW_BATTERY: if subnode_id == SUBNODE_LOW_BATTERY:
# Subnode 3 is the low battery node # Subnode 3 is the low battery node
entity = ISYInsteonBinarySensorEntity( entity = ISYInsteonBinarySensorEntity(
node, BinarySensorDeviceClass.BATTERY, initial_state node,
BinarySensorDeviceClass.BATTERY,
initial_state,
device_info=device_info,
) )
entities.append(entity) entities.append(entity)
continue continue
@ -172,22 +185,27 @@ async def async_setup_entry(
# Tamper Sub-node for MS II. Sometimes reported as "A" sometimes # Tamper Sub-node for MS II. Sometimes reported as "A" sometimes
# reported as "10", which translate from Hex to 10 and 16 resp. # reported as "10", which translate from Hex to 10 and 16 resp.
entity = ISYInsteonBinarySensorEntity( entity = ISYInsteonBinarySensorEntity(
node, BinarySensorDeviceClass.PROBLEM, initial_state node,
BinarySensorDeviceClass.PROBLEM,
initial_state,
device_info=device_info,
) )
entities.append(entity) entities.append(entity)
continue continue
if subnode_id in SUBNODE_MOTION_DISABLED: if subnode_id in SUBNODE_MOTION_DISABLED:
# Motion Disabled Sub-node for MS II ("D" or "13") # Motion Disabled Sub-node for MS II ("D" or "13")
entity = ISYInsteonBinarySensorEntity(node) entity = ISYInsteonBinarySensorEntity(node, device_info=device_info)
entities.append(entity) entities.append(entity)
continue continue
# We don't yet have any special logic for other sensor # We don't yet have any special logic for other sensor
# types, so add the nodes as individual devices # 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) 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)) entities.append(ISYBinarySensorProgramEntity(name, status))
async_add_entities(entities) async_add_entities(entities)
@ -225,9 +243,10 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
node: Node, node: Node,
force_device_class: BinarySensorDeviceClass | None = None, force_device_class: BinarySensorDeviceClass | None = None,
unknown_state: bool | None = None, unknown_state: bool | None = None,
device_info: DeviceInfo | None = None,
) -> None: ) -> None:
"""Initialize the ISY binary sensor device.""" """Initialize the ISY binary sensor device."""
super().__init__(node) super().__init__(node, device_info=device_info)
self._device_class = force_device_class self._device_class = force_device_class
@property @property
@ -260,9 +279,10 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity):
node: Node, node: Node,
force_device_class: BinarySensorDeviceClass | None = None, force_device_class: BinarySensorDeviceClass | None = None,
unknown_state: bool | None = None, unknown_state: bool | None = None,
device_info: DeviceInfo | None = None,
) -> None: ) -> None:
"""Initialize the ISY binary sensor device.""" """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._negative_node: Node | None = None
self._heartbeat_device: ISYBinarySensorHeartbeat | None = None self._heartbeat_device: ISYBinarySensorHeartbeat | None = None
if self._node.status == ISY_VALUE_UNKNOWN: if self._node.status == ISY_VALUE_UNKNOWN:
@ -399,6 +419,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity)
| ISYBinarySensorEntity | ISYBinarySensorEntity
| ISYBinarySensorHeartbeat | ISYBinarySensorHeartbeat
| ISYBinarySensorProgramEntity, | ISYBinarySensorProgramEntity,
device_info: DeviceInfo | None = None,
) -> None: ) -> None:
"""Initialize the ISY binary sensor device. """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 If the heartbeat is not received in 25 hours then the computed state is
set to ON (Low Battery). set to ON (Low Battery).
""" """
super().__init__(node) super().__init__(node, device_info=device_info)
self._parent_device = parent_device self._parent_device = parent_device
self._heartbeat_timer: CALLBACK_TYPE | None = None self._heartbeat_timer: CALLBACK_TYPE | None = None
self._computed_state: bool | None = None self._computed_state: bool | None = None

View file

@ -2,28 +2,24 @@
from __future__ import annotations from __future__ import annotations
from pyisy import ISY 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 pyisy.nodes import Node
from homeassistant.components.button import ButtonEntity from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import _async_isy_to_configuration_url
from .const import ( from .const import (
DOMAIN as ISY994_DOMAIN, CONF_NETWORK,
ISY994_ISY, DOMAIN,
ISY994_NODES, ISY_DEVICES,
ISY_CONF_FIRMWARE, ISY_NET_RES,
ISY_CONF_MODEL, ISY_ROOT,
ISY_CONF_NAME, ISY_ROOT_NODES,
ISY_CONF_NETWORKING,
ISY_CONF_UUID,
MANUFACTURER,
) )
@ -33,107 +29,104 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up ISY/IoX button from config entry.""" """Set up ISY/IoX button from config entry."""
hass_isy_data = hass.data[ISY994_DOMAIN][config_entry.entry_id] hass_isy_data = hass.data[DOMAIN][config_entry.entry_id]
isy: ISY = hass_isy_data[ISY994_ISY] isy: ISY = hass_isy_data[ISY_ROOT]
uuid = isy.configuration[ISY_CONF_UUID] device_info = hass_isy_data[ISY_DEVICES]
entities: list[ entities: list[
ISYNodeQueryButtonEntity ISYNodeQueryButtonEntity
| ISYNodeBeepButtonEntity | ISYNodeBeepButtonEntity
| ISYNetworkResourceButtonEntity | 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( 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 # 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) async_add_entities(entities)
class ISYNodeQueryButtonEntity(ButtonEntity): class ISYNodeButtonEntity(ButtonEntity):
"""Representation of a device query button entity.""" """Representation of an ISY/IoX device button entity."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.CONFIG
_attr_has_entity_name = True _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.""" """Initialize a query ISY device button entity."""
self._node = node self._node = node
# Entity class attributes # Entity class attributes
self._attr_name = "Query" self._attr_name = name
self._attr_unique_id = f"{base_unique_id}_query" self._attr_entity_category = entity_category
self._attr_device_info = DeviceInfo( self._attr_unique_id = unique_id
identifiers={(ISY994_DOMAIN, base_unique_id)} self._attr_device_info = device_info
)
class ISYNodeQueryButtonEntity(ISYNodeButtonEntity):
"""Representation of a device query button entity."""
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
await self._node.query() await self._node.query()
class ISYNodeBeepButtonEntity(ButtonEntity): class ISYNodeBeepButtonEntity(ISYNodeButtonEntity):
"""Representation of a device beep button entity.""" """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: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
await self._node.beep() await self._node.beep()
class ISYNetworkResourceButtonEntity(ButtonEntity): class ISYNetworkResourceButtonEntity(ISYNodeButtonEntity):
"""Representation of an ISY/IoX Network Resource button entity.""" """Representation of an ISY/IoX Network Resource button entity."""
_attr_should_poll = False _attr_has_entity_name = 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,
)
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""

View file

@ -35,15 +35,17 @@ from homeassistant.const import (
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
_LOGGER, _LOGGER,
DOMAIN as ISY994_DOMAIN, DOMAIN,
HA_FAN_TO_ISY, HA_FAN_TO_ISY,
HA_HVAC_TO_ISY, HA_HVAC_TO_ISY,
ISY994_NODES, ISY_DEVICES,
ISY_HVAC_MODES, ISY_HVAC_MODES,
ISY_NODES,
UOM_FAN_MODES, UOM_FAN_MODES,
UOM_HVAC_ACTIONS, UOM_HVAC_ACTIONS,
UOM_HVAC_MODE_GENERIC, UOM_HVAC_MODE_GENERIC,
@ -63,9 +65,10 @@ async def async_setup_entry(
"""Set up the ISY thermostat platform.""" """Set up the ISY thermostat platform."""
entities = [] entities = []
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id] hass_isy_data = hass.data[DOMAIN][entry.entry_id]
for node in hass_isy_data[ISY994_NODES][Platform.CLIMATE]: devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES]
entities.append(ISYThermostatEntity(node)) for node in hass_isy_data[ISY_NODES][Platform.CLIMATE]:
entities.append(ISYThermostatEntity(node, devices.get(node.primary_node)))
async_add_entities(entities) async_add_entities(entities)
@ -81,9 +84,9 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | 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.""" """Initialize the ISY Thermostat entity."""
super().__init__(node) super().__init__(node, device_info=device_info)
self._uom = self._node.uom self._uom = self._node.uom
if isinstance(self._uom, list): if isinstance(self._uom, list):
self._uom = self._node.uom[0] self._uom = self._node.uom[0]

View file

@ -58,6 +58,7 @@ DOMAIN = "isy994"
MANUFACTURER = "Universal Devices, Inc" MANUFACTURER = "Universal Devices, Inc"
CONF_NETWORK = "network"
CONF_IGNORE_STRING = "ignore_string" CONF_IGNORE_STRING = "ignore_string"
CONF_SENSOR_STRING = "sensor_string" CONF_SENSOR_STRING = "sensor_string"
CONF_VAR_SENSOR_STRING = "variable_sensor_string" CONF_VAR_SENSOR_STRING = "variable_sensor_string"
@ -74,15 +75,13 @@ DEFAULT_VAR_SENSOR_STRING = "HA."
KEY_ACTIONS = "actions" KEY_ACTIONS = "actions"
KEY_STATUS = "status" KEY_STATUS = "status"
PLATFORMS = [ NODE_PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.FAN, Platform.FAN,
Platform.LIGHT, Platform.LIGHT,
Platform.LOCK, Platform.LOCK,
Platform.NUMBER,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
] ]
@ -93,6 +92,16 @@ PROGRAM_PLATFORMS = [
Platform.LOCK, Platform.LOCK,
Platform.SWITCH, 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"] 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) # (they can turn off, and report their state)
ISY_GROUP_PLATFORM = Platform.SWITCH ISY_GROUP_PLATFORM = Platform.SWITCH
ISY994_ISY = "isy" ISY_ROOT = "isy"
ISY994_NODES = "isy994_nodes" ISY_ROOT_NODES = "isy_root_nodes"
ISY994_PROGRAMS = "isy994_programs" ISY_NET_RES = "isy_net_res"
ISY994_VARIABLES = "isy994_variables" ISY_NODES = "isy_nodes"
ISY_PROGRAMS = "isy_programs"
ISY_VARIABLES = "isy_variables"
ISY_DEVICES = "isy_devices"
ISY_CONF_NETWORKING = "Networking Module" ISY_CONF_NETWORKING = "Networking Module"
ISY_CONF_UUID = "uuid" ISY_CONF_UUID = "uuid"
@ -201,14 +213,6 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = {
], # Does a startswith() match; include the dot ], # Does a startswith() match; include the dot
FILTER_ZWAVE_CAT: (["104", "112", "138"] + list(map(str, range(148, 180)))), 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: { Platform.SENSOR: {
# This is just a more-readable way of including MOST uoms between 1-100 # This is just a more-readable way of including MOST uoms between 1-100
# (Remember that range() is non-inclusive of the stop value) # (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_INSTEON_TYPE: ["4.8", TYPE_CATEGORY_CLIMATE],
FILTER_ZWAVE_CAT: ["140"], 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 = { UOM_FRIENDLY_NAME = {

View file

@ -13,13 +13,15 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
_LOGGER, _LOGGER,
DOMAIN as ISY994_DOMAIN, DOMAIN,
ISY994_NODES, ISY_DEVICES,
ISY994_PROGRAMS, ISY_NODES,
ISY_PROGRAMS,
UOM_8_BIT_RANGE, UOM_8_BIT_RANGE,
UOM_BARRIER, UOM_BARRIER,
) )
@ -30,12 +32,13 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY cover platform.""" """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] = [] entities: list[ISYCoverEntity | ISYCoverProgramEntity] = []
for node in hass_isy_data[ISY994_NODES][Platform.COVER]: devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES]
entities.append(ISYCoverEntity(node)) 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)) entities.append(ISYCoverProgramEntity(name, status, actions))
async_add_entities(entities) async_add_entities(entities)

View file

@ -7,32 +7,37 @@ from pyisy.constants import (
COMMAND_FRIENDLY_NAME, COMMAND_FRIENDLY_NAME,
EMPTY_TIME, EMPTY_TIME,
EVENT_PROPS_IGNORED, EVENT_PROPS_IGNORED,
PROTO_GROUP,
PROTO_INSTEON, PROTO_INSTEON,
PROTO_ZWAVE, PROTO_ZWAVE,
) )
from pyisy.helpers import EventListener, NodeProperty from pyisy.helpers import EventListener, NodeProperty
from pyisy.nodes import Node from pyisy.nodes import Node
from pyisy.programs import Program 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.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity import DeviceInfo, Entity
from . import _async_isy_to_configuration_url from .const import DOMAIN
from .const import DOMAIN, ISY_CONF_UUID
class ISYEntity(Entity): class ISYEntity(Entity):
"""Representation of an ISY device.""" """Representation of an ISY device."""
_name: str | None = None _attr_has_entity_name = False
_attr_should_poll = 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.""" """Initialize the insteon device."""
self._node = node 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._attrs: dict[str, Any] = {}
self._change_handler: EventListener | None = None self._change_handler: EventListener | None = None
self._control_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) 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): class ISYNodeEntity(ISYEntity):
"""Representation of a ISY Nodebase (Node/Group) entity.""" """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: def __init__(self, name: str, status: Any | None, actions: Program = None) -> None:
"""Initialize the ISY program-based entity.""" """Initialize the ISY program-based entity."""
super().__init__(status) super().__init__(status)
self._name = name self._attr_name = name
self._actions = actions self._actions = actions
@property @property

View file

@ -10,6 +10,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range, int_states_in_range,
@ -17,7 +18,7 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage, 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 from .entity import ISYNodeEntity, ISYProgramEntity
SPEED_RANGE = (1, 255) # off is not included 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 hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY fan platform.""" """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] = [] entities: list[ISYFanEntity | ISYFanProgramEntity] = []
for node in hass_isy_data[ISY994_NODES][Platform.FAN]: for node in hass_isy_data[ISY_NODES][Platform.FAN]:
entities.append(ISYFanEntity(node)) 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)) entities.append(ISYFanProgramEntity(name, status, actions))
async_add_entities(entities) async_add_entities(entities)

View file

@ -15,24 +15,28 @@ from pyisy.nodes import Group, Node, Nodes
from pyisy.programs import Programs from pyisy.programs import Programs
from pyisy.variables import Variables 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 ( from .const import (
_LOGGER, _LOGGER,
DEFAULT_PROGRAM_STRING, DEFAULT_PROGRAM_STRING,
DOMAIN,
FILTER_INSTEON_TYPE, FILTER_INSTEON_TYPE,
FILTER_NODE_DEF_ID, FILTER_NODE_DEF_ID,
FILTER_STATES, FILTER_STATES,
FILTER_UOM, FILTER_UOM,
FILTER_ZWAVE_CAT, FILTER_ZWAVE_CAT,
ISY994_NODES, ISY_DEVICES,
ISY994_PROGRAMS,
ISY994_VARIABLES,
ISY_GROUP_PLATFORM, ISY_GROUP_PLATFORM,
ISY_NODES,
ISY_PROGRAMS,
ISY_ROOT_NODES,
ISY_VARIABLES,
KEY_ACTIONS, KEY_ACTIONS,
KEY_STATUS, KEY_STATUS,
NODE_FILTERS, NODE_FILTERS,
PLATFORMS, NODE_PLATFORMS,
PROGRAM_PLATFORMS, PROGRAM_PLATFORMS,
SENSOR_AUX, SENSOR_AUX,
SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_COOL,
@ -64,10 +68,10 @@ def _check_for_node_def(
node_def_id = node.node_def_id 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: for platform in platforms:
if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]: 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 True
return False return False
@ -89,7 +93,7 @@ def _check_for_insteon_type(
return False return False
device_type = node.type 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: for platform in platforms:
if any( if any(
device_type.startswith(t) device_type.startswith(t)
@ -103,7 +107,7 @@ def _check_for_insteon_type(
# FanLinc, which has a light module as one of its nodes. # FanLinc, which has a light module as one of its nodes.
if platform == Platform.FAN and subnode_id == SUBNODE_FANLINC_LIGHT: 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 return True
# Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 # 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_COOL,
SUBNODE_CLIMATE_HEAT, 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 return True
# IOLincs which have a sensor and relay on 2 different nodes # 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 device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
and subnode_id == SUBNODE_IOLINC_RELAY 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 return True
# Smartenit EZIO2X4 # Smartenit EZIO2X4
@ -129,10 +133,10 @@ def _check_for_insteon_type(
and device_type.startswith(TYPE_EZIO2X4) and device_type.startswith(TYPE_EZIO2X4)
and subnode_id in SUBNODE_EZIO2X4_SENSORS 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 return True
hass_isy_data[ISY994_NODES][platform].append(node) hass_isy_data[ISY_NODES][platform].append(node)
return True return True
return False return False
@ -154,13 +158,13 @@ def _check_for_zwave_cat(
return False return False
device_type = node.zwave_props.category 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: for platform in platforms:
if any( if any(
device_type.startswith(t) device_type.startswith(t)
for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT]) for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT])
): ):
hass_isy_data[ISY994_NODES][platform].append(node) hass_isy_data[ISY_NODES][platform].append(node)
return True return True
return False return False
@ -188,14 +192,14 @@ def _check_for_uom_id(
if uom_list: if uom_list:
if node_uom in 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 True
return False 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: for platform in platforms:
if node_uom in NODE_FILTERS[platform][FILTER_UOM]: 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 True
return False return False
@ -225,14 +229,14 @@ def _check_for_states_in_uom(
if states_list: if states_list:
if node_uom == set(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 True
return False 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: for platform in platforms:
if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]): 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 True
return False return False
@ -269,6 +273,41 @@ def _is_sensor_a_binary_sensor(hass_isy_data: dict, node: Group | Node) -> bool:
return False 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( def _categorize_nodes(
hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str hass_isy_data: dict, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
) -> None: ) -> None:
@ -280,23 +319,24 @@ def _categorize_nodes(
continue continue
if hasattr(node, "parent_node") and node.parent_node is None: if hasattr(node, "parent_node") and node.parent_node is None:
# This is a physical device / parent node, add a query button # This is a physical device / parent node
hass_isy_data[ISY994_NODES][Platform.BUTTON].append(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: 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 continue
if node.protocol == PROTO_INSTEON: if node.protocol == PROTO_INSTEON:
for control in node.aux_properties: 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: if sensor_identifier in path or sensor_identifier in node.name:
# User has specified to treat this as a sensor. First we need to # User has specified to treat this as a sensor. First we need to
# determine if it should be a binary_sensor. # determine if it should be a binary_sensor.
if _is_sensor_a_binary_sensor(hass_isy_data, node): if _is_sensor_a_binary_sensor(hass_isy_data, node):
continue continue
hass_isy_data[ISY994_NODES][Platform.SENSOR].append(node) hass_isy_data[ISY_NODES][Platform.SENSOR].append(node)
continue continue
# We have a bunch of different methods for determining the device type, # We have a bunch of different methods for determining the device type,
@ -314,7 +354,7 @@ def _categorize_nodes(
continue continue
# Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes. # 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: 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 continue
entity = (entity_folder.name, status, actions) 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( def _categorize_variables(
@ -369,7 +409,7 @@ def _categorize_variables(
except KeyError as err: except KeyError as err:
_LOGGER.error("Error adding ISY Variables: %s", err) _LOGGER.error("Error adding ISY Variables: %s", err)
return return
variable_entities = hass_isy_data[ISY994_VARIABLES] variable_entities = hass_isy_data[ISY_VARIABLES]
for vtype, vname, vid in var_to_add: for vtype, vname, vid in var_to_add:
variable_entities[Platform.SENSOR].append((vname, variables[vtype][vid])) variable_entities[Platform.SENSOR].append((vname, variables[vtype][vid]))

View file

@ -11,14 +11,16 @@ from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from .const import ( from .const import (
_LOGGER, _LOGGER,
CONF_RESTORE_LIGHT_STATE, CONF_RESTORE_LIGHT_STATE,
DOMAIN as ISY994_DOMAIN, DOMAIN,
ISY994_NODES, ISY_DEVICES,
ISY_NODES,
UOM_PERCENTAGE, UOM_PERCENTAGE,
) )
from .entity import ISYNodeEntity from .entity import ISYNodeEntity
@ -31,13 +33,16 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY light platform.""" """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 isy_options = entry.options
restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False)
entities = [] entities = []
for node in hass_isy_data[ISY994_NODES][Platform.LIGHT]: for node in hass_isy_data[ISY_NODES][Platform.LIGHT]:
entities.append(ISYLightEntity(node, restore_light_state)) entities.append(
ISYLightEntity(node, restore_light_state, devices.get(node.primary_node))
)
async_add_entities(entities) async_add_entities(entities)
async_setup_light_services(hass) async_setup_light_services(hass)
@ -49,9 +54,14 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
_attr_color_mode = ColorMode.BRIGHTNESS _attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {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.""" """Initialize the ISY light device."""
super().__init__(node) super().__init__(node, device_info=device_info)
self._last_brightness: int | None = None self._last_brightness: int | None = None
self._restore_light_state = restore_light_state self._restore_light_state = restore_light_state

View file

@ -9,9 +9,10 @@ from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 from .entity import ISYNodeEntity, ISYProgramEntity
VALUE_TO_STATE = {0: False, 100: True} VALUE_TO_STATE = {0: False, 100: True}
@ -21,12 +22,13 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY lock platform.""" """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] = [] entities: list[ISYLockEntity | ISYLockProgramEntity] = []
for node in hass_isy_data[ISY994_NODES][Platform.LOCK]: for node in hass_isy_data[ISY_NODES][Platform.LOCK]:
entities.append(ISYLockEntity(node)) 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)) entities.append(ISYLockProgramEntity(name, status, actions))
async_add_entities(entities) async_add_entities(entities)

View file

@ -3,7 +3,7 @@
"name": "Universal Devices ISY/IoX", "name": "Universal Devices ISY/IoX",
"integration_type": "hub", "integration_type": "hub",
"documentation": "https://www.home-assistant.io/integrations/isy994", "documentation": "https://www.home-assistant.io/integrations/isy994",
"requirements": ["pyisy==3.0.12"], "requirements": ["pyisy==3.1.3"],
"codeowners": ["@bdraco", "@shbatm"], "codeowners": ["@bdraco", "@shbatm"],
"config_flow": true, "config_flow": true,
"ssdp": [ "ssdp": [

View file

@ -9,23 +9,12 @@ from pyisy.variables import Variable
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import _async_isy_to_configuration_url from .const import DOMAIN, ISY_DEVICES, ISY_ROOT, ISY_VARIABLES
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 .helpers import convert_isy_value_to_hass from .helpers import convert_isy_value_to_hass
ISY_MAX_SIZE = (2**32) / 2 ISY_MAX_SIZE = (2**32) / 2
@ -37,12 +26,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up ISY/IoX number entities from config entry.""" """Set up ISY/IoX number entities from config entry."""
hass_isy_data = hass.data[ISY994_DOMAIN][config_entry.entry_id] hass_isy_data = hass.data[DOMAIN][config_entry.entry_id]
isy: ISY = hass_isy_data[ISY994_ISY] isy: ISY = hass_isy_data[ISY_ROOT]
uuid = isy.configuration[ISY_CONF_UUID] device_info = hass_isy_data[ISY_DEVICES]
entities: list[ISYVariableNumberEntity] = [] 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) step = 10 ** (-1 * node.prec)
min_max = ISY_MAX_SIZE / (10**node.prec) min_max = ISY_MAX_SIZE / (10**node.prec)
description = NumberEntityDescription( description = NumberEntityDescription(
@ -70,15 +59,17 @@ async def async_setup_entry(
entities.append( entities.append(
ISYVariableNumberEntity( ISYVariableNumberEntity(
node, node,
unique_id=f"{uuid}_{node.address}", unique_id=f"{isy.uuid}_{node.address}",
description=description, description=description,
device_info=device_info[CONF_VARIABLES],
) )
) )
entities.append( entities.append(
ISYVariableNumberEntity( ISYVariableNumberEntity(
node=node, node=node,
unique_id=f"{uuid}_{node.address}_init", unique_id=f"{isy.uuid}_{node.address}_init",
description=description_init, description=description_init,
device_info=device_info[CONF_VARIABLES],
init_entity=True, init_entity=True,
) )
) )
@ -89,7 +80,7 @@ async def async_setup_entry(
class ISYVariableNumberEntity(NumberEntity): class ISYVariableNumberEntity(NumberEntity):
"""Representation of an ISY variable as a number entity device.""" """Representation of an ISY variable as a number entity device."""
_attr_has_entity_name = True _attr_has_entity_name = False
_attr_should_poll = False _attr_should_poll = False
_init_entity: bool _init_entity: bool
_node: Variable _node: Variable
@ -100,37 +91,19 @@ class ISYVariableNumberEntity(NumberEntity):
node: Variable, node: Variable,
unique_id: str, unique_id: str,
description: NumberEntityDescription, description: NumberEntityDescription,
device_info: DeviceInfo,
init_entity: bool = False, init_entity: bool = False,
) -> None: ) -> None:
"""Initialize the ISY variable number.""" """Initialize the ISY variable number."""
self._node = node self._node = node
self._name = description.name
self.entity_description = description self.entity_description = description
self._change_handler: EventListener | None = None self._change_handler: EventListener | None = None
# Two entities are created for each variable, one for current value and one for initial. # Two entities are created for each variable, one for current value and one for initial.
# Initial value entities are disabled by default # Initial value entities are disabled by default
self._init_entity = init_entity self._init_entity = init_entity
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_device_info = device_info
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,
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Subscribe to the node change events.""" """Subscribe to the node change events."""

View file

@ -28,15 +28,15 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform, UnitOfTemperature from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
_LOGGER, _LOGGER,
DOMAIN as ISY994_DOMAIN, DOMAIN,
ISY994_NODES, ISY_DEVICES,
ISY994_VARIABLES, ISY_NODES,
ISY_CONF_UUID, ISY_VARIABLES,
SENSOR_AUX, SENSOR_AUX,
UOM_DOUBLE_TEMP, UOM_DOUBLE_TEMP,
UOM_FRIENDLY_NAME, UOM_FRIENDLY_NAME,
@ -110,15 +110,16 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY sensor platform.""" """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] = [] 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) _LOGGER.debug("Loading %s", node.name)
entities.append(ISYSensorEntity(node)) entities.append(ISYSensorEntity(node, devices.get(node.primary_node)))
aux_nodes = set() 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) aux_nodes.add(node)
if control in SKIP_AUX_PROPERTIES: if control in SKIP_AUX_PROPERTIES:
continue continue
@ -126,13 +127,27 @@ async def async_setup_entry(
enabled_default = control not in AUX_DISABLED_BY_DEFAULT_EXACT and not any( enabled_default = control not in AUX_DISABLED_BY_DEFAULT_EXACT and not any(
control.startswith(match) for match in AUX_DISABLED_BY_DEFAULT_MATCH 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: for node in aux_nodes:
# Any node in SENSOR_AUX can potentially have communication errors # 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)) entities.append(ISYSensorVariableEntity(vname, vobj))
async_add_entities(entities) async_add_entities(entities)
@ -228,15 +243,26 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
class ISYAuxSensorEntity(ISYSensorEntity): class ISYAuxSensorEntity(ISYSensorEntity):
"""Representation of an ISY aux sensor device.""" """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.""" """Initialize the ISY aux sensor."""
super().__init__(node) super().__init__(node, device_info=device_info)
self._control = control self._control = control
self._attr_entity_registry_enabled_default = enabled_default self._attr_entity_registry_enabled_default = enabled_default
self._attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control) self._attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control)
self._attr_device_class = ISY_CONTROL_TO_DEVICE_CLASS.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_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 @property
def target(self) -> Node | NodeProperty | None: def target(self) -> Node | NodeProperty | None:
"""Return target for the sensor.""" """Return target for the sensor."""
@ -250,25 +276,11 @@ class ISYAuxSensorEntity(ISYSensorEntity):
"""Return the target value.""" """Return the target value."""
return None if self.target is None else self.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): class ISYSensorVariableEntity(ISYEntity, SensorEntity):
"""Representation of an ISY variable as a sensor device.""" """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 _attr_entity_registry_enabled_default = False
def __init__(self, vname: str, vobj: object) -> None: def __init__(self, vname: str, vobj: object) -> None:

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any 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 import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
@ -25,11 +25,11 @@ from homeassistant.helpers.service import entity_service_call
from .const import ( from .const import (
_LOGGER, _LOGGER,
CONF_NETWORK,
DOMAIN, DOMAIN,
ISY994_ISY,
ISY_CONF_NAME, ISY_CONF_NAME,
ISY_CONF_NETWORKING, ISY_CONF_NETWORKING,
ISY_CONF_UUID, ISY_ROOT,
) )
from .util import unique_ids_for_config_entry_id 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) isy_name = service.data.get(CONF_ISY)
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
for config_entry_id in hass.data[DOMAIN]: for config_entry_id in hass.data[DOMAIN]:
isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT]
if isy_name and isy_name != isy.configuration["name"]: if isy_name and isy_name != isy.conf["name"]:
continue continue
# If an address is provided, make sure we query the correct ISY. # If an address is provided, make sure we query the correct ISY.
# Otherwise, query the whole system on all ISY's connected. # 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( _LOGGER.debug(
"Requesting query of device %s on ISY %s", "Requesting query of device %s on ISY %s",
address, address,
isy.configuration[ISY_CONF_UUID], isy.uuid,
) )
await isy.query(address) await isy.query(address)
async_log_deprecated_service_call( 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( alternate_target=entity_registry.async_get_entity_id(
Platform.BUTTON, Platform.BUTTON,
DOMAIN, DOMAIN,
f"{isy.configuration[ISY_CONF_UUID]}_{address}_query", f"{isy.uuid}_{address}_query",
), ),
breaks_in_ha_version="2023.5.0", breaks_in_ha_version="2023.5.0",
) )
return return
_LOGGER.debug( _LOGGER.debug("Requesting system query of ISY %s", isy.uuid)
"Requesting system query of ISY %s", isy.configuration[ISY_CONF_UUID]
)
await isy.query() await isy.query()
async_log_deprecated_service_call( async_log_deprecated_service_call(
hass, hass,
call=service, call=service,
alternate_service="button.press", alternate_service="button.press",
alternate_target=entity_registry.async_get_entity_id( 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", 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) isy_name = service.data.get(CONF_ISY)
for config_entry_id in hass.data[DOMAIN]: for config_entry_id in hass.data[DOMAIN]:
isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT]
if isy_name and isy_name != isy.configuration[ISY_CONF_NAME]: if isy_name and isy_name != isy.conf[ISY_CONF_NAME]:
continue 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 continue
command = None command = None
if address: if address:
@ -257,7 +255,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
alternate_target=entity_registry.async_get_entity_id( alternate_target=entity_registry.async_get_entity_id(
Platform.BUTTON, Platform.BUTTON,
DOMAIN, DOMAIN,
f"{isy.configuration[ISY_CONF_UUID]}_{PROTO_NETWORK_RESOURCE}_{address}", f"{isy.uuid}_{CONF_NETWORK}_{address}",
), ),
breaks_in_ha_version="2023.5.0", 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) isy_name = service.data.get(CONF_ISY)
for config_entry_id in hass.data[DOMAIN]: for config_entry_id in hass.data[DOMAIN]:
isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT]
if isy_name and isy_name != isy.configuration["name"]: if isy_name and isy_name != isy.conf["name"]:
continue continue
program = None program = None
if address: if address:
@ -297,8 +295,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
isy_name = service.data.get(CONF_ISY) isy_name = service.data.get(CONF_ISY)
for config_entry_id in hass.data[DOMAIN]: for config_entry_id in hass.data[DOMAIN]:
isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] isy = hass.data[DOMAIN][config_entry_id][ISY_ROOT]
if isy_name and isy_name != isy.configuration["name"]: if isy_name and isy_name != isy.conf["name"]:
continue continue
variable = None variable = None
if name: if name:
@ -315,7 +313,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
alternate_target=entity_registry.async_get_entity_id( alternate_target=entity_registry.async_get_entity_id(
Platform.NUMBER, Platform.NUMBER,
DOMAIN, 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", 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: def async_cleanup_registry_entries(service: ServiceCall) -> None:
"""Remove extra entities that are no longer part of the integration.""" """Remove extra entities that are no longer part of the integration."""
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
config_ids = []
current_unique_ids: set[str] = set()
for config_entry_id in hass.data[DOMAIN]: for config_entry_id in hass.data[DOMAIN]:
entries_for_this_config = er.async_entries_for_config_entry( entries_for_this_config = er.async_entries_for_config_entry(
entity_registry, config_entry_id entity_registry, config_entry_id
) )
config_ids.extend( entities = {
[ (entity.domain, entity.unique_id): entity.entity_id
(entity.unique_id, entity.entity_id) for entity in entries_for_this_config
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 = [ for entity in extra_entities:
entity_id if entity_registry.async_is_registered(entities[entity]):
for unique_id, entity_id in config_ids entity_registry.async_remove(entities[entity])
if unique_id not in current_unique_ids
]
for entity_id in extra_entities: _LOGGER.debug(
if entity_registry.async_is_registered(entity_id): (
entity_registry.async_remove(entity_id) "Cleaning up ISY entities: removed %s extra entities for config entry: %s"
),
_LOGGER.debug( len(extra_entities),
( len(config_entry_id),
"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),
)
async def async_reload_config_entries(service: ServiceCall) -> None: async def async_reload_config_entries(service: ServiceCall) -> None:
"""Trigger a reload of all ISY config entries.""" """Trigger a reload of all ISY config entries."""

View file

@ -9,9 +9,10 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 from .entity import ISYNodeEntity, ISYProgramEntity
@ -19,12 +20,18 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the ISY switch platform.""" """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] = [] entities: list[ISYSwitchProgramEntity | ISYSwitchEntity] = []
for node in hass_isy_data[ISY994_NODES][Platform.SWITCH]: devices: dict[str, DeviceInfo] = hass_isy_data[ISY_DEVICES]
entities.append(ISYSwitchEntity(node)) 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)) entities.append(ISYSwitchProgramEntity(name, status, actions))
async_add_entities(entities) async_add_entities(entities)

View file

@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback 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 @callback
@ -28,7 +28,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
config_entry_id = next( config_entry_id = next(
iter(hass.data[DOMAIN]) iter(hass.data[DOMAIN])
) # Only first ISY is supported for now ) # 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) entry = hass.config_entries.async_get_entry(config_entry_id)
assert isinstance(entry, ConfigEntry) assert isinstance(entry, ConfigEntry)

View file

@ -1,40 +1,69 @@
"""ISY utils.""" """ISY utils."""
from __future__ import annotations from __future__ import annotations
from pyisy.constants import PROP_COMMS_ERROR, PROTO_INSTEON
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import ( from .const import (
CONF_NETWORK,
DOMAIN, DOMAIN,
ISY994_ISY, ISY_NET_RES,
ISY994_NODES, ISY_NODES,
ISY994_PROGRAMS, ISY_PROGRAMS,
ISY994_VARIABLES, ISY_ROOT,
ISY_CONF_UUID, ISY_ROOT_NODES,
PLATFORMS, ISY_VARIABLES,
NODE_PLATFORMS,
PROGRAM_PLATFORMS, PROGRAM_PLATFORMS,
ROOT_NODE_PLATFORMS,
SENSOR_AUX,
) )
def unique_ids_for_config_entry_id( def unique_ids_for_config_entry_id(
hass: HomeAssistant, config_entry_id: str hass: HomeAssistant, config_entry_id: str
) -> set[str]: ) -> set[tuple[Platform | str, str]]:
"""Find all the unique ids for a config entry id.""" """Find all the unique ids for a config entry id."""
hass_isy_data = hass.data[DOMAIN][config_entry_id] hass_isy_data = hass.data[DOMAIN][config_entry_id]
uuid = hass_isy_data[ISY994_ISY].configuration[ISY_CONF_UUID] isy = hass_isy_data[ISY_ROOT]
current_unique_ids: set[str] = {uuid} current_unique_ids: set[tuple[Platform | str, str]] = {
(Platform.BUTTON, f"{isy.uuid}_query")
}
for platform in PLATFORMS: # Structure and prefixes here must match what's added in __init__ and helpers
for node in hass_isy_data[ISY994_NODES][platform]: for platform in NODE_PLATFORMS:
if hasattr(node, "address"): for node in hass_isy_data[ISY_NODES][platform]:
current_unique_ids.add(f"{uuid}_{node.address}") 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 platform in PROGRAM_PLATFORMS:
for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: for _, node, _ in hass_isy_data[ISY_PROGRAMS][platform]:
if hasattr(node, "address"): current_unique_ids.add((platform, f"{isy.uuid}_{node.address}"))
current_unique_ids.add(f"{uuid}_{node.address}")
for node in hass_isy_data[ISY994_VARIABLES]: for node, _ in hass_isy_data[ISY_VARIABLES][Platform.NUMBER]:
if hasattr(node, "address"): current_unique_ids.add((Platform.NUMBER, f"{isy.uuid}_{node.address}"))
current_unique_ids.add(f"{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 return current_unique_ids

View file

@ -1696,7 +1696,7 @@ pyirishrail==0.0.2
pyiss==1.0.1 pyiss==1.0.1
# homeassistant.components.isy994 # homeassistant.components.isy994
pyisy==3.0.12 pyisy==3.1.3
# homeassistant.components.itach # homeassistant.components.itach
pyitachip2ir==0.0.7 pyitachip2ir==0.0.7

View file

@ -1215,7 +1215,7 @@ pyiqvia==2022.04.0
pyiss==1.0.1 pyiss==1.0.1
# homeassistant.components.isy994 # homeassistant.components.isy994
pyisy==3.0.12 pyisy==3.1.3
# homeassistant.components.kaleidescape # homeassistant.components.kaleidescape
pykaleidescape==1.0.1 pykaleidescape==1.0.1

View file

@ -4,7 +4,7 @@ from unittest.mock import Mock
from aiohttp import ClientError 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.const import CONF_HOST
from homeassistant.setup import async_setup_component 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] = {}
hass.data[DOMAIN][MOCK_ENTRY_ID] = {} 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, connected=True,
websocket=Mock( websocket=Mock(
last_heartbeat=MOCK_HEARTBEAT, last_heartbeat=MOCK_HEARTBEAT,
@ -69,7 +69,7 @@ async def test_system_health_failed_connect(hass, aioclient_mock):
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}
hass.data[DOMAIN][MOCK_ENTRY_ID] = {} 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, connected=True,
websocket=Mock( websocket=Mock(
last_heartbeat=MOCK_HEARTBEAT, last_heartbeat=MOCK_HEARTBEAT,