Register LCN devices in device registry (#53143)
parent
8d05813c97
commit
b34eb53914
homeassistant/components/lcn
tests
components/lcn
fixtures/lcn
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -25,6 +25,11 @@
|
|||
"name": "Switch_Output1",
|
||||
"address": "s0.m7",
|
||||
"output": "output1"
|
||||
},
|
||||
{
|
||||
"name": "Switch_Group5",
|
||||
"address": "s0.g5",
|
||||
"output": "relay1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue