From b34eb539144630043d52a3a4652f72849005c050 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 27 Oct 2021 23:29:28 +0200 Subject: [PATCH] Register LCN devices in device registry (#53143) --- homeassistant/components/lcn/__init__.py | 64 ++++++-- homeassistant/components/lcn/config_flow.py | 9 ++ homeassistant/components/lcn/helpers.py | 153 +++++++++++++++++++- homeassistant/components/lcn/sensor.py | 6 +- tests/components/lcn/conftest.py | 6 + tests/components/lcn/test_config_flow.py | 3 +- tests/components/lcn/test_init.py | 24 ++- tests/fixtures/lcn/config.json | 5 + tests/fixtures/lcn/config_entry_pchk.json | 16 ++ 9 files changed, 258 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 48a63a50fa9..d019c156f37 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -8,6 +8,8 @@ import pypck from homeassistant import config_entries from homeassistant.const import ( + CONF_ADDRESS, + CONF_DOMAIN, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD, @@ -16,16 +18,27 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType -from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, CONNECTION, DOMAIN, PLATFORMS +from .const import ( + CONF_DIM_MODE, + CONF_DOMAIN_DATA, + CONF_SK_NUM_TRIES, + CONNECTION, + DOMAIN, + PLATFORMS, +) from .helpers import ( + AddressType, DeviceConnectionType, InputType, + async_update_config_entry, generate_unique_id, + get_device_model, import_lcn_config, + register_lcn_address_devices, + register_lcn_host_device, ) from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES @@ -96,12 +109,12 @@ async def async_setup_entry( hass.data[DOMAIN][config_entry.entry_id] = { CONNECTION: lcn_connection, } + # Update config_entry with LCN device serials + await async_update_config_entry(hass, config_entry) - # remove orphans from entity registry which are in ConfigEntry but were removed - # from configuration.yaml - if config_entry.source == config_entries.SOURCE_IMPORT: - entity_registry = await er.async_get_registry(hass) - entity_registry.async_clear_config_entry(config_entry.entry_id) + # register/update devices for host, modules and groups in device registry + register_lcn_host_device(hass, config_entry) + register_lcn_address_devices(hass, config_entry) # forward config_entry to components hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -150,17 +163,38 @@ class LcnEntity(Entity): self._unregister_for_inputs: Callable | None = None self._name: str = config[CONF_NAME] + @property + def address(self) -> AddressType: + """Return LCN address.""" + return ( + self.device_connection.seg_id, + self.device_connection.addr_id, + self.device_connection.is_group, + ) + @property def unique_id(self) -> str: """Return a unique ID.""" - unique_device_id = generate_unique_id( - ( - self.device_connection.seg_id, - self.device_connection.addr_id, - self.device_connection.is_group, - ) + return generate_unique_id( + self.entry_id, self.address, self.config[CONF_RESOURCE] ) - return f"{self.entry_id}-{unique_device_id}-{self.config[CONF_RESOURCE]}" + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" + model = f"LCN {get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])}" + + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": f"{address}.{self.config[CONF_RESOURCE]}", + "model": model, + "manufacturer": "Issendorff", + "via_device": ( + DOMAIN, + generate_unique_id(self.entry_id, self.config[CONF_ADDRESS]), + ), + } @property def should_poll(self) -> bool: diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 905da4d005c..20549ea32a3 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN @@ -93,6 +94,14 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): entry = get_config_entry(self.hass, data) if entry: entry.source = config_entries.SOURCE_IMPORT + + # Cleanup entity and device registry, if we imported from configuration.yaml to + # remove orphans when entities were removed from configuration + entity_registry = er.async_get(self.hass) + entity_registry.async_clear_config_entry(entry.entry_id) + device_registry = dr.async_get(self.hass) + device_registry.async_clear_config_entry(entry.entry_id) + self.hass.config_entries.async_update_entry(entry, data=data) return self.async_abort(reason="existing_configuration_updated") diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b62f8474470..2834cc1940e 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -1,6 +1,8 @@ """Helpers for LCN component.""" from __future__ import annotations +import asyncio +from itertools import chain import re from typing import Tuple, Type, Union, cast @@ -22,19 +24,23 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SENSORS, + CONF_SOURCE, CONF_SWITCHES, CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( + BINSENSOR_PORTS, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, + CONF_OUTPUT, CONF_RESOURCE, CONF_SCENES, CONF_SK_NUM_TRIES, @@ -42,6 +48,13 @@ from .const import ( CONNECTION, DEFAULT_NAME, DOMAIN, + LED_PORTS, + LOGICOP_PORTS, + OUTPUT_PORTS, + S0_INPUTS, + SETPOINTS, + THRESHOLDS, + VARIABLES, ) # typing @@ -92,10 +105,43 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: raise ValueError("Unknown domain") -def generate_unique_id(address: AddressType) -> str: +def get_device_model(domain_name: str, domain_data: ConfigType) -> str: + """Return the model for the specified domain_data.""" + if domain_name in ("switch", "light"): + return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay" + if domain_name in ("binary_sensor", "sensor"): + if domain_data[CONF_SOURCE] in BINSENSOR_PORTS: + return "Binary Sensor" + if domain_data[CONF_SOURCE] in chain( + VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS + ): + return "Variable" + if domain_data[CONF_SOURCE] in LED_PORTS: + return "Led" + if domain_data[CONF_SOURCE] in LOGICOP_PORTS: + return "Logical Operation" + return "Key" + if domain_name == "cover": + return "Motor" + if domain_name == "climate": + return "Regulator" + if domain_name == "scene": + return "Scene" + raise ValueError("Unknown domain") + + +def generate_unique_id( + entry_id: str, + address: AddressType, + resource: str | None = None, +) -> str: """Generate a unique_id from the given parameters.""" + unique_id = entry_id is_group = "g" if address[2] else "m" - return f"{is_group}{address[0]:03d}{address[1]:03d}" + unique_id += f"-{is_group}{address[0]:03d}{address[1]:03d}" + if resource: + unique_id += f"-{resource}".lower() + return unique_id def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: @@ -200,6 +246,109 @@ def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: return list(data.values()) +def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Register LCN host for given config_entry in device registry.""" + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Issendorff", + name=config_entry.title, + model="PCHK", + ) + + +def register_lcn_address_devices( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Register LCN modules and groups defined in config_entry as devices in device registry. + + The name of all given device_connections is collected and the devices + are updated. + """ + device_registry = dr.async_get(hass) + + host_identifiers = (DOMAIN, config_entry.entry_id) + + for device_config in config_entry.data[CONF_DEVICES]: + address = device_config[CONF_ADDRESS] + device_name = device_config[CONF_NAME] + identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} + + if device_config[CONF_ADDRESS][2]: # is group + device_model = f"LCN group (g{address[0]:03d}{address[1]:03d})" + sw_version = None + else: # is module + hardware_type = device_config[CONF_HARDWARE_TYPE] + if hardware_type in pypck.lcn_defs.HARDWARE_DESCRIPTIONS: + hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[hardware_type] + else: + hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[-1] + device_model = f"{hardware_name} (m{address[0]:03d}{address[1]:03d})" + sw_version = f"{device_config[CONF_SOFTWARE_SERIAL]:06X}" + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=identifiers, + via_device=host_identifiers, + manufacturer="Issendorff", + sw_version=sw_version, + name=device_name, + model=device_model, + ) + + +async def async_update_device_config( + device_connection: DeviceConnectionType, device_config: ConfigType +) -> None: + """Fill missing values in device_config with infos from LCN bus.""" + is_group = device_config[CONF_ADDRESS][2] + + # fetch serial info if device is module + if not is_group: # is module + await device_connection.serial_known + if device_config[CONF_HARDWARE_SERIAL] == -1: + device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial + if device_config[CONF_SOFTWARE_SERIAL] == -1: + device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial + if device_config[CONF_HARDWARE_TYPE] == -1: + device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value + + # fetch name if device is module + if device_config[CONF_NAME] != "": + return + + device_name = "" + if not is_group: + device_name = await device_connection.request_name() + if is_group or device_name == "": + module_type = "Group" if is_group else "Module" + device_name = ( + f"{module_type} " + f"{device_config[CONF_ADDRESS][0]:03d}/" + f"{device_config[CONF_ADDRESS][1]:03d}" + ) + device_config[CONF_NAME] = device_name + + +async def async_update_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Fill missing values in config_entry with infos from LCN bus.""" + coros = [] + for device_config in config_entry.data[CONF_DEVICES]: + device_connection = get_device_connection( + hass, device_config[CONF_ADDRESS], config_entry + ) + coros.append(async_update_device_config(device_connection, device_config)) + + await asyncio.gather(*coros) + + # schedule config_entry for save + hass.config_entries.async_update_entry(config_entry) + + def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: """Validate that all connection names are unique. diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 965e9626f66..66321c79a1b 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,6 +1,7 @@ """Support for LCN sensors.""" from __future__ import annotations +from itertools import chain from typing import cast import pypck @@ -38,9 +39,8 @@ def create_lcn_sensor_entity( hass, entity_config[CONF_ADDRESS], config_entry ) - if ( - entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] - in VARIABLES + SETPOINTS + THRESHOLDS + S0_INPUTS + if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in chain( + VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS ): return LcnVariableSensor( entity_config, config_entry.entry_id, device_connection diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index aae4acfa914..81c2fdc68e4 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -21,8 +21,14 @@ class MockModuleConnection(ModuleConnection): status_request_handler = AsyncMock() activate_status_request_handler = AsyncMock() cancel_status_request_handler = AsyncMock() + request_name = AsyncMock(return_value="TestModule") send_command = AsyncMock(return_value=True) + def __init__(self, *args, **kwargs): + """Construct ModuleConnection instance.""" + super().__init__(*args, **kwargs) + self.serials_request_handler.serial_known.set() + class MockGroupConnection(GroupConnection): """Fake a LCN group connection.""" diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 5f83ab27762..49351b023b6 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -76,8 +76,7 @@ async def test_step_import_existing_host(hass): ], ) async def test_step_import_error(hass, error, reason): - """Test for authentication error is handled correctly.""" - + """Test for error in import is handled correctly.""" with patch( "pypck.connection.PchkConnectionManager.async_connect", side_effect=error ): diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 79f0eed4e46..e4fb5beef0d 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -10,7 +10,7 @@ from pypck.connection import ( from homeassistant import config_entries from homeassistant.components.lcn.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import MockPchkConnectionManager, init_integration, setup_component @@ -53,19 +53,31 @@ async def test_async_setup_entry_update(hass, entry): """Test a successful setup entry if entry with same id already exists.""" # setup first entry entry.source = config_entries.SOURCE_IMPORT + entry.add_to_hass(hass) # create dummy entity for LCN platform as an orphan - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) dummy_entity = entity_registry.async_get_or_create( "switch", DOMAIN, "dummy", config_entry=entry ) + + # create dummy device for LCN platform as an orphan + device_registry = dr.async_get(hass) + dummy_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id, 0, 7, False)}, + via_device=(DOMAIN, entry.entry_id), + ) + assert dummy_entity in entity_registry.entities.values() + assert dummy_device in device_registry.devices.values() - # add entity to hass and setup (should cleanup dummy entity) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # setup new entry with same data via import step (should cleanup dummy device) + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data + ) + assert dummy_device not in device_registry.devices.values() assert dummy_entity not in entity_registry.entities.values() diff --git a/tests/fixtures/lcn/config.json b/tests/fixtures/lcn/config.json index 50a1ca05e29..3cbb66b4e31 100644 --- a/tests/fixtures/lcn/config.json +++ b/tests/fixtures/lcn/config.json @@ -25,6 +25,11 @@ "name": "Switch_Output1", "address": "s0.m7", "output": "output1" + }, + { + "name": "Switch_Group5", + "address": "s0.g5", + "output": "relay1" } ] } diff --git a/tests/fixtures/lcn/config_entry_pchk.json b/tests/fixtures/lcn/config_entry_pchk.json index 3058389a95d..a4f78c16b41 100644 --- a/tests/fixtures/lcn/config_entry_pchk.json +++ b/tests/fixtures/lcn/config_entry_pchk.json @@ -13,6 +13,13 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 + }, + { + "address": [0, 5, true], + "name": "", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 } ], "entities": [ @@ -24,6 +31,15 @@ "domain_data": { "output": "OUTPUT1" } + }, + { + "address": [0, 5, true], + "name": "Switch_Group5", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } } ] }