Register LCN devices in device registry ()

pull/57827/head^2
Andre Lengwenus 2021-10-27 23:29:28 +02:00 committed by GitHub
parent 8d05813c97
commit b34eb53914
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 258 additions and 28 deletions

View File

@ -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:

View File

@ -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")

View File

@ -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.

View File

@ -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

View File

@ -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."""

View File

@ -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
):

View File

@ -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()

View File

@ -25,6 +25,11 @@
"name": "Switch_Output1",
"address": "s0.m7",
"output": "output1"
},
{
"name": "Switch_Group5",
"address": "s0.g5",
"output": "relay1"
}
]
}

View File

@ -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"
}
}
]
}