From c276cfc37192f5f1e3ce1cc06b512d8b6f65c6ae Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 21 Aug 2024 11:33:47 +0200 Subject: [PATCH] Add custom panel for LCN configuration (#108664) * Add LCN panel using lcn-frontend module * Move panel from sidebar to integration configuration * Change OptionFlow to reconfigure step * Change OptionFlow to reconfigure step * Remove deprecation warning * Fix docstring * Add tests for lcn websockets * Remove deepcopy * Bump lcn-frontend to 0.1.3 * Add tests for lcn websockets * Remove websocket command lcn/hosts * Websocket scan tests cover modules not stored in config_entry * Add comment to mock of hass.http * Add a decorater to ensure the config_entry exists and return it * Use entry_id instead of host_id * Bump lcn-frontend to 0.1.5 * Use auto_id for websocket client send_json * Create issues on yaml import errors * Remove low level key deprecation warnings * Method renaming * Change issue id in issue creation * Update tests for issue creation --- homeassistant/components/lcn/__init__.py | 5 + homeassistant/components/lcn/binary_sensor.py | 11 +- homeassistant/components/lcn/climate.py | 5 + homeassistant/components/lcn/config_flow.py | 176 +++++-- homeassistant/components/lcn/const.py | 1 + homeassistant/components/lcn/cover.py | 11 +- homeassistant/components/lcn/helpers.py | 10 + homeassistant/components/lcn/light.py | 5 + homeassistant/components/lcn/manifest.json | 4 +- homeassistant/components/lcn/scene.py | 5 + homeassistant/components/lcn/schemas.py | 47 +- homeassistant/components/lcn/sensor.py | 5 + homeassistant/components/lcn/strings.json | 52 ++ homeassistant/components/lcn/switch.py | 11 +- homeassistant/components/lcn/websocket.py | 450 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lcn/conftest.py | 11 +- .../lcn/fixtures/config_entry_pchk.json | 4 +- tests/components/lcn/test_config_flow.py | 181 ++++++- tests/components/lcn/test_init.py | 3 +- tests/components/lcn/test_websocket.py | 303 ++++++++++++ 24 files changed, 1234 insertions(+), 75 deletions(-) create mode 100644 homeassistant/components/lcn/websocket.py create mode 100644 tests/components/lcn/test_websocket.py diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 6866a10d55e..75f417cb3a5 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import ( + ADD_ENTITIES_CALLBACKS, CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, @@ -47,6 +48,7 @@ from .helpers import ( ) from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES +from .websocket import register_panel_and_ws_api _LOGGER = logging.getLogger(__name__) @@ -115,6 +117,7 @@ async def async_setup_entry( _LOGGER.debug('LCN connected to "%s"', config_entry.title) hass.data[DOMAIN][config_entry.entry_id] = { CONNECTION: lcn_connection, + ADD_ENTITIES_CALLBACKS: {}, } # Update config_entry with LCN device serials await async_update_config_entry(hass, config_entry) @@ -140,6 +143,8 @@ async def async_setup_entry( DOMAIN, service_name, service(hass).async_call_service, service.schema ) + await register_panel_and_ws_api(hass) + return True diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 35836e4653e..d7e5798bb91 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -15,7 +15,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import LcnEntity -from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, SETPOINTS +from .const import ( + ADD_ENTITIES_CALLBACKS, + BINSENSOR_PORTS, + CONF_DOMAIN_DATA, + DOMAIN, + SETPOINTS, +) from .helpers import DeviceConnectionType, InputType, get_device_connection @@ -43,6 +49,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_BINARY_SENSOR: (async_add_entities, create_lcn_binary_sensor_entity)} + ) async_add_entities( create_lcn_binary_sensor_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index c03061618f7..d34a872d867 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -28,11 +28,13 @@ from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( + ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, + DOMAIN, ) from .helpers import DeviceConnectionType, InputType, get_device_connection @@ -56,6 +58,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_CLIMATE: (async_add_entities, create_lcn_climate_entity)} + ) async_add_entities( create_lcn_climate_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index d05eb896f27..664f32e5585 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -3,32 +3,50 @@ from __future__ import annotations import logging +from typing import Any import pypck +import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_IMPORT, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant import config_entries from homeassistant.const import ( + CONF_BASE, + CONF_DEVICES, + CONF_ENTITIES, CONF_HOST, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN +from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN from .helpers import purge_device_registry, purge_entity_registry _LOGGER = logging.getLogger(__name__) +CONFIG_DATA = { + vol.Required(CONF_IP_ADDRESS, default=""): str, + vol.Required(CONF_PORT, default=4114): cv.positive_int, + vol.Required(CONF_USERNAME, default=""): str, + vol.Required(CONF_PASSWORD, default=""): str, + vol.Required(CONF_SK_NUM_TRIES, default=0): cv.positive_int, + vol.Required(CONF_DIM_MODE, default="STEPS200"): vol.In(DIM_MODES), +} -def get_config_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry | None: +USER_DATA = {vol.Required(CONF_HOST, default="pchk"): str, **CONFIG_DATA} + +CONFIG_SCHEMA = vol.Schema(CONFIG_DATA) +USER_SCHEMA = vol.Schema(USER_DATA) + + +def get_config_entry( + hass: HomeAssistant, data: ConfigType +) -> config_entries.ConfigEntry | None: """Check config entries for already configured entries based on the ip address/port.""" return next( ( @@ -41,8 +59,10 @@ def get_config_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry | Non ) -async def validate_connection(host_name: str, data: ConfigType) -> ConfigType: +async def validate_connection(data: ConfigType) -> str | None: """Validate if a connection to LCN can be established.""" + error = None + host_name = data[CONF_HOST] host = data[CONF_IP_ADDRESS] port = data[CONF_PORT] username = data[CONF_USERNAME] @@ -61,43 +81,71 @@ async def validate_connection(host_name: str, data: ConfigType) -> ConfigType: host, port, username, password, settings=settings ) - await connection.async_connect(timeout=5) + try: + await connection.async_connect(timeout=5) + _LOGGER.debug("LCN connection validated") + except pypck.connection.PchkAuthenticationError: + _LOGGER.warning('Authentication on PCHK "%s" failed', host_name) + error = "authentication_error" + except pypck.connection.PchkLicenseError: + _LOGGER.warning( + 'Maximum number of connections on PCHK "%s" was ' + "reached. An additional license key is required", + host_name, + ) + error = "license_error" + except (TimeoutError, ConnectionRefusedError): + _LOGGER.warning('Connection to PCHK "%s" failed', host_name) + error = "connection_refused" - _LOGGER.debug("LCN connection validated") await connection.async_close() - return data + return error -class LcnFlowHandler(ConfigFlow, domain=DOMAIN): +class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a LCN config flow.""" VERSION = 1 - async def async_step_import(self, data: ConfigType) -> ConfigFlowResult: + async def async_step_import( + self, data: ConfigType + ) -> config_entries.ConfigFlowResult: """Import existing configuration from LCN.""" - host_name = data[CONF_HOST] # validate the imported connection parameters - try: - await validate_connection(host_name, data) - except pypck.connection.PchkAuthenticationError: - _LOGGER.warning('Authentication on PCHK "%s" failed', host_name) - return self.async_abort(reason="authentication_error") - except pypck.connection.PchkLicenseError: - _LOGGER.warning( - ( - 'Maximum number of connections on PCHK "%s" was ' - "reached. An additional license key is required" - ), - host_name, + if error := await validate_connection(data): + async_create_issue( + self.hass, + DOMAIN, + error, + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.ERROR, + translation_key=error, + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=lcn" + }, ) - return self.async_abort(reason="license_error") - except TimeoutError: - _LOGGER.warning('Connection to PCHK "%s" failed', host_name) - return self.async_abort(reason="connection_timeout") + return self.async_abort(reason=error) + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LCN", + }, + ) # check if we already have a host with the same address configured if entry := get_config_entry(self.hass, data): - entry.source = SOURCE_IMPORT + 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 purge_entity_registry(self.hass, entry.entry_id, data) @@ -106,4 +154,64 @@ class LcnFlowHandler(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry(entry, data=data) return self.async_abort(reason="existing_configuration_updated") - return self.async_create_entry(title=f"{host_name}", data=data) + return self.async_create_entry(title=f"{data[CONF_HOST]}", data=data) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle a flow initiated by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=USER_SCHEMA) + + errors = None + if get_config_entry(self.hass, user_input): + errors = {CONF_BASE: "already_configured"} + elif (error := await validate_connection(user_input)) is not None: + errors = {CONF_BASE: error} + + if errors is not None: + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, user_input + ), + errors=errors, + ) + + data: dict = { + **user_input, + CONF_DEVICES: [], + CONF_ENTITIES: [], + } + + return self.async_create_entry(title=data[CONF_HOST], data=data) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Reconfigure LCN configuration.""" + errors = None + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + + if user_input is not None: + user_input[CONF_HOST] = entry.data[CONF_HOST] + + await self.hass.config_entries.async_unload(entry.entry_id) + if (error := await validate_connection(user_input)) is not None: + errors = {CONF_BASE: error} + + if errors is None: + data = entry.data.copy() + data.update(user_input) + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_setup(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + await self.hass.config_entries.async_setup(entry.entry_id) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, entry.data), + errors=errors or {}, + ) diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index bcf9ecdf295..24d2e68495c 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -18,6 +18,7 @@ DOMAIN = "lcn" DATA_LCN = "lcn" DEFAULT_NAME = "pchk" +ADD_ENTITIES_CALLBACKS = "add_entities_callbacks" CONNECTION = "connection" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index edc60a202a1..a2f508cee97 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -14,7 +14,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import LcnEntity -from .const import CONF_DOMAIN_DATA, CONF_MOTOR, CONF_REVERSE_TIME +from .const import ( + ADD_ENTITIES_CALLBACKS, + CONF_DOMAIN_DATA, + CONF_MOTOR, + CONF_REVERSE_TIME, + DOMAIN, +) from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 @@ -40,6 +46,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN cover entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_COVER: (async_add_entities, create_lcn_cover_entity)} + ) async_add_entities( create_lcn_cover_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index d46628fc6da..fd8c59ad46f 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -423,6 +423,16 @@ async def async_update_config_entry( hass.config_entries.async_update_entry(config_entry, data=new_data) +def get_device_config( + address: AddressType, config_entry: ConfigEntry +) -> ConfigType | None: + """Return the device configuration for given address and ConfigEntry.""" + for device_config in config_entry.data[CONF_DEVICES]: + if tuple(device_config[CONF_ADDRESS]) == address: + return cast(ConfigType, device_config) + return None + + def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: """Validate that all connection names are unique. diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 584161a0829..b896462c8a1 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -22,10 +22,12 @@ from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( + ADD_ENTITIES_CALLBACKS, CONF_DIMMABLE, CONF_DOMAIN_DATA, CONF_OUTPUT, CONF_TRANSITION, + DOMAIN, OUTPUT_PORTS, ) from .helpers import DeviceConnectionType, InputType, get_device_connection @@ -53,6 +55,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN light entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_LIGHT: (async_add_entities, create_lcn_light_entity)} + ) async_add_entities( create_lcn_light_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index e9717774e17..44aae34e9e6 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -2,9 +2,9 @@ "domain": "lcn", "name": "LCN", "codeowners": ["@alengwenus"], - "config_flow": false, + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.21"] + "requirements": ["pypck==0.7.21", "lcn-frontend==0.1.5"] } diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 7e476987c53..f9220f676d6 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -15,10 +15,12 @@ from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( + ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_OUTPUTS, CONF_REGISTER, CONF_TRANSITION, + DOMAIN, OUTPUT_PORTS, ) from .helpers import DeviceConnectionType, get_device_connection @@ -43,6 +45,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_SCENE: (async_add_entities, create_lcn_scene_entity)} + ) async_add_entities( create_lcn_scene_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 9927ea5908d..0539e83dea8 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -58,6 +58,8 @@ from .const import ( ) from .helpers import has_unique_host_names, is_address +ADDRESS_SCHEMA = vol.Coerce(tuple) + # # Domain data # @@ -169,23 +171,32 @@ CONNECTION_SCHEMA = vol.Schema( ) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CONNECTIONS): vol.All( - cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [BINARY_SENSORS_SCHEMA] - ), - vol.Optional(CONF_CLIMATES): vol.All(cv.ensure_list, [CLIMATES_SCHEMA]), - vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), - vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]), - vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]), - vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSORS_SCHEMA]), - vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCHES_SCHEMA]), - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CONNECTIONS): vol.All( + cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA] + ), + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [BINARY_SENSORS_SCHEMA] + ), + vol.Optional(CONF_CLIMATES): vol.All( + cv.ensure_list, [CLIMATES_SCHEMA] + ), + vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), + vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]), + vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [SENSORS_SCHEMA] + ), + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [SWITCHES_SCHEMA] + ), + }, + ) + }, + ), extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 32b97ab8317..b63ddbef8ad 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -22,7 +22,9 @@ from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( + ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, + DOMAIN, LED_PORTS, S0_INPUTS, SETPOINTS, @@ -56,6 +58,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_SENSOR: (async_add_entities, create_lcn_sensor_entity)} + ) async_add_entities( create_lcn_sensor_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 3bab17cbbcd..a5f303c6392 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -14,6 +14,58 @@ "level": "Level" } }, + "config": { + "step": { + "user": { + "title": "Setup LCN host", + "description": "Set up new connection to LCN host.", + "data": { + "host": "[%key:common::config_flow::data::name%]", + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "sk_num_tries": "Segment coupler scan attempts", + "dim_mode": "Dimming mode" + } + }, + "reconfigure": { + "title": "Reconfigure LCN host", + "description": "Reconfigure connection to LCN host.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "sk_num_tries": "Segment coupler scan attempts", + "dim_mode": "Dimming mode" + } + } + }, + "error": { + "authentication_error": "Authentication failed. Wrong username or password.", + "license_error": "Maximum number of connections was reached. An additional licence key is required.", + "connection_refused": "Unable to connect to PCHK. Check IP and port.", + "already_configured": "PCHK connection using the same ip address/port is already configured." + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "issues": { + "authentication_error": { + "title": "Authentication failed.", + "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure username and password are correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "license_error": { + "title": "Maximum number of connections was reached.", + "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure sufficient PCHK licenses are registered and restart Home Assistant.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "connection_refused": { + "title": "Unable to connect to PCHK.", + "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the connection (IP and port) to the LCN bus coupler is correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + }, "services": { "output_abs": { "name": "Output absolute brightness", diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index b82394ced0d..1136e4c27a1 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -14,7 +14,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import LcnEntity -from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS +from .const import ( + ADD_ENTITIES_CALLBACKS, + CONF_DOMAIN_DATA, + CONF_OUTPUT, + DOMAIN, + OUTPUT_PORTS, +) from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 @@ -40,6 +46,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + {DOMAIN_SWITCH: (async_add_entities, create_lcn_switch_entity)} + ) async_add_entities( create_lcn_switch_entity(hass, entity_config, config_entry) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py new file mode 100644 index 00000000000..5317bc86763 --- /dev/null +++ b/homeassistant/components/lcn/websocket.py @@ -0,0 +1,450 @@ +"""LCN Websocket API.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import TYPE_CHECKING, Any, Final + +import lcn_frontend as lcn_panel +import voluptuous as vol + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.websocket_api import AsyncWebSocketCommandHandler +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ADDRESS, + CONF_DEVICES, + CONF_DOMAIN, + CONF_ENTITIES, + CONF_ENTITY_ID, + CONF_NAME, + CONF_RESOURCE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.config_validation as cv + +from .const import ( + ADD_ENTITIES_CALLBACKS, + CONF_DOMAIN_DATA, + CONF_HARDWARE_SERIAL, + CONF_HARDWARE_TYPE, + CONF_SOFTWARE_SERIAL, + CONNECTION, + DOMAIN, +) +from .helpers import ( + DeviceConnectionType, + async_update_device_config, + generate_unique_id, + get_device_config, + get_device_connection, + get_resource, + purge_device_registry, + purge_entity_registry, + register_lcn_address_devices, +) +from .schemas import ( + ADDRESS_SCHEMA, + DOMAIN_DATA_BINARY_SENSOR, + DOMAIN_DATA_CLIMATE, + DOMAIN_DATA_COVER, + DOMAIN_DATA_LIGHT, + DOMAIN_DATA_SCENE, + DOMAIN_DATA_SENSOR, + DOMAIN_DATA_SWITCH, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api import ActiveConnection + +type AsyncLcnWebSocketCommandHandler = Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry], Awaitable[None] +] + +URL_BASE: Final = "/lcn_static" + + +async def register_panel_and_ws_api(hass: HomeAssistant) -> None: + """Register the LCN Panel and Websocket API.""" + websocket_api.async_register_command(hass, websocket_get_device_configs) + websocket_api.async_register_command(hass, websocket_get_entity_configs) + websocket_api.async_register_command(hass, websocket_scan_devices) + websocket_api.async_register_command(hass, websocket_add_device) + websocket_api.async_register_command(hass, websocket_delete_device) + websocket_api.async_register_command(hass, websocket_add_entity) + websocket_api.async_register_command(hass, websocket_delete_entity) + + if DOMAIN not in hass.data.get("frontend_panels", {}): + hass.http.register_static_path( + URL_BASE, + path=lcn_panel.locate_dir(), + cache_headers=lcn_panel.is_prod_build, + ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name=lcn_panel.webcomponent_name, + config_panel_domain=DOMAIN, + module_url=f"{URL_BASE}/{lcn_panel.entrypoint_js}", + embed_iframe=True, + require_admin=True, + ) + + +def get_config_entry( + func: AsyncLcnWebSocketCommandHandler, +) -> AsyncWebSocketCommandHandler: + """Websocket decorator to ensure the config_entry exists and return it.""" + + @callback + @wraps(func) + async def get_entry( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Get config_entry.""" + if not (config_entry := hass.config_entries.async_get_entry(msg["entry_id"])): + connection.send_result(msg["id"], False) + else: + await func(hass, connection, msg, config_entry) + + return get_entry + + +@websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "lcn/devices", vol.Required("entry_id"): cv.string} +) +@websocket_api.async_response +@get_config_entry +async def websocket_get_device_configs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Get device configs.""" + connection.send_result(msg["id"], config_entry.data[CONF_DEVICES]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "lcn/entities", + vol.Required("entry_id"): cv.string, + vol.Optional(CONF_ADDRESS): ADDRESS_SCHEMA, + } +) +@websocket_api.async_response +@get_config_entry +async def websocket_get_entity_configs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Get entities configs.""" + if CONF_ADDRESS in msg: + entity_configs = [ + entity_config + for entity_config in config_entry.data[CONF_ENTITIES] + if tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] + ] + else: + entity_configs = config_entry.data[CONF_ENTITIES] + + entity_registry = er.async_get(hass) + for entity_config in entity_configs: + entity_unique_id = generate_unique_id( + config_entry.entry_id, + entity_config[CONF_ADDRESS], + entity_config[CONF_RESOURCE], + ) + entity_id = entity_registry.async_get_entity_id( + entity_config[CONF_DOMAIN], DOMAIN, entity_unique_id + ) + + entity_config[CONF_ENTITY_ID] = entity_id + + connection.send_result(msg["id"], entity_configs) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "lcn/devices/scan", vol.Required("entry_id"): cv.string} +) +@websocket_api.async_response +@get_config_entry +async def websocket_scan_devices( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Scan for new devices.""" + host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + await host_connection.scan_modules() + + for device_connection in host_connection.address_conns.values(): + if not device_connection.is_group: + await async_create_or_update_device_in_config_entry( + hass, device_connection, config_entry + ) + + # create/update devices in device registry + register_lcn_address_devices(hass, config_entry) + + connection.send_result(msg["id"], config_entry.data[CONF_DEVICES]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "lcn/devices/add", + vol.Required("entry_id"): cv.string, + vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, + } +) +@websocket_api.async_response +@get_config_entry +async def websocket_add_device( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Add a device.""" + if get_device_config(msg[CONF_ADDRESS], config_entry): + connection.send_result( + msg["id"], False + ) # device_config already in config_entry + return + + device_config = { + CONF_ADDRESS: msg[CONF_ADDRESS], + CONF_NAME: "", + CONF_HARDWARE_SERIAL: -1, + CONF_SOFTWARE_SERIAL: -1, + CONF_HARDWARE_TYPE: -1, + } + + # update device info from LCN + device_connection = get_device_connection(hass, msg[CONF_ADDRESS], config_entry) + await async_update_device_config(device_connection, device_config) + + # add device_config to config_entry + device_configs = [*config_entry.data[CONF_DEVICES], device_config] + data = {**config_entry.data, CONF_DEVICES: device_configs} + hass.config_entries.async_update_entry(config_entry, data=data) + + # create/update devices in device registry + register_lcn_address_devices(hass, config_entry) + + connection.send_result(msg["id"], True) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "lcn/devices/delete", + vol.Required("entry_id"): cv.string, + vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, + } +) +@websocket_api.async_response +@get_config_entry +async def websocket_delete_device( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Delete a device.""" + device_config = get_device_config(msg[CONF_ADDRESS], config_entry) + + device_registry = dr.async_get(hass) + identifiers = { + (DOMAIN, generate_unique_id(config_entry.entry_id, msg[CONF_ADDRESS])) + } + device = device_registry.async_get_device(identifiers, set()) + + if not (device and device_config): + connection.send_result(msg["id"], False) + return + + # remove module/group device from config_entry data + device_configs = [ + dc for dc in config_entry.data[CONF_DEVICES] if dc != device_config + ] + data = {**config_entry.data, CONF_DEVICES: device_configs} + hass.config_entries.async_update_entry(config_entry, data=data) + + # remove all child devices (and entities) from config_entry data + for entity_config in data[CONF_ENTITIES][:]: + if tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS]: + data[CONF_ENTITIES].remove(entity_config) + + hass.config_entries.async_update_entry(config_entry, data=data) + + # cleanup registries + purge_entity_registry(hass, config_entry.entry_id, data) + purge_device_registry(hass, config_entry.entry_id, data) + + # return the device config, not all devices !!! + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "lcn/entities/add", + vol.Required("entry_id"): cv.string, + vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_DOMAIN_DATA): vol.Any( + DOMAIN_DATA_BINARY_SENSOR, + DOMAIN_DATA_SENSOR, + DOMAIN_DATA_SWITCH, + DOMAIN_DATA_LIGHT, + DOMAIN_DATA_CLIMATE, + DOMAIN_DATA_COVER, + DOMAIN_DATA_SCENE, + ), + } +) +@websocket_api.async_response +@get_config_entry +async def websocket_add_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Add an entity.""" + if not (device_config := get_device_config(msg[CONF_ADDRESS], config_entry)): + connection.send_result(msg["id"], False) + return + + domain_name = msg[CONF_DOMAIN] + domain_data = msg[CONF_DOMAIN_DATA] + resource = get_resource(domain_name, domain_data).lower() + unique_id = generate_unique_id( + config_entry.entry_id, + device_config[CONF_ADDRESS], + resource, + ) + + entity_registry = er.async_get(hass) + if entity_registry.async_get_entity_id(msg[CONF_DOMAIN], DOMAIN, unique_id): + connection.send_result(msg["id"], False) + return + + entity_config = { + CONF_ADDRESS: msg[CONF_ADDRESS], + CONF_NAME: msg[CONF_NAME], + CONF_RESOURCE: resource, + CONF_DOMAIN: domain_name, + CONF_DOMAIN_DATA: domain_data, + } + + # Create new entity and add to corresponding component + callbacks = hass.data[DOMAIN][msg["entry_id"]][ADD_ENTITIES_CALLBACKS] + async_add_entities, create_lcn_entity = callbacks[msg[CONF_DOMAIN]] + + entity = create_lcn_entity(hass, entity_config, config_entry) + async_add_entities([entity]) + + # Add entity config to config_entry + entity_configs = [*config_entry.data[CONF_ENTITIES], entity_config] + data = {**config_entry.data, CONF_ENTITIES: entity_configs} + + # schedule config_entry for save + hass.config_entries.async_update_entry(config_entry, data=data) + + connection.send_result(msg["id"], True) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "lcn/entities/delete", + vol.Required("entry_id"): cv.string, + vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_RESOURCE): cv.string, + } +) +@websocket_api.async_response +@get_config_entry +async def websocket_delete_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + config_entry: ConfigEntry, +) -> None: + """Delete an entity.""" + entity_config = next( + ( + entity_config + for entity_config in config_entry.data[CONF_ENTITIES] + if ( + tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] + and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN] + and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE] + ) + ), + None, + ) + + if entity_config is None: + connection.send_result(msg["id"], False) + return + + entity_configs = [ + ec for ec in config_entry.data[CONF_ENTITIES] if ec != entity_config + ] + data = {**config_entry.data, CONF_ENTITIES: entity_configs} + + hass.config_entries.async_update_entry(config_entry, data=data) + + # cleanup registries + purge_entity_registry(hass, config_entry.entry_id, data) + purge_device_registry(hass, config_entry.entry_id, data) + + connection.send_result(msg["id"]) + + +async def async_create_or_update_device_in_config_entry( + hass: HomeAssistant, + device_connection: DeviceConnectionType, + config_entry: ConfigEntry, +) -> None: + """Create or update device in config_entry according to given device_connection.""" + address = ( + device_connection.seg_id, + device_connection.addr_id, + device_connection.is_group, + ) + + device_configs = [*config_entry.data[CONF_DEVICES]] + data = {**config_entry.data, CONF_DEVICES: device_configs} + for device_config in data[CONF_DEVICES]: + if tuple(device_config[CONF_ADDRESS]) == address: + break # device already in config_entry + else: + # create new device_entry + device_config = { + CONF_ADDRESS: address, + CONF_NAME: "", + CONF_HARDWARE_SERIAL: -1, + CONF_SOFTWARE_SERIAL: -1, + CONF_HARDWARE_TYPE: -1, + } + data[CONF_DEVICES].append(device_config) + + # update device_entry + await async_update_device_config(device_connection, device_config) + + hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eecb3a76aac..5e6d29f29f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -310,6 +310,7 @@ FLOWS = { "lastfm", "launch_library", "laundrify", + "lcn", "ld2410_ble", "leaone", "led_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b14531e1731..52215d232ad 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3178,7 +3178,7 @@ "lcn": { "name": "LCN", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "ld2410_ble": { diff --git a/requirements_all.txt b/requirements_all.txt index 6cb3438675c..a7bee65a8b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1242,6 +1242,9 @@ lakeside==0.13 # homeassistant.components.laundrify laundrify-aio==1.2.2 +# homeassistant.components.lcn +lcn-frontend==0.1.5 + # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38b6f91a6f7..7585c0cfa4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,6 +1035,9 @@ lacrosse-view==1.0.2 # homeassistant.components.laundrify laundrify-aio==1.2.2 +# homeassistant.components.lcn +lcn-frontend==0.1.5 + # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index e29a7076430..b1f28b28465 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pypck from pypck.connection import PchkConnectionManager @@ -13,7 +13,7 @@ import pytest from homeassistant.components.lcn.const import DOMAIN from homeassistant.components.lcn.helpers import AddressType, generate_unique_id -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_ADDRESS, CONF_DEVICES, CONF_ENTITIES, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -60,6 +60,7 @@ class MockPchkConnectionManager(PchkConnectionManager): """Get LCN address connection.""" return super().get_address_conn(addr, request_serials) + scan_modules = AsyncMock() send_command = AsyncMock() @@ -67,6 +68,11 @@ def create_config_entry(name: str) -> MockConfigEntry: """Set up config entries with configuration data.""" fixture_filename = f"lcn/config_entry_{name}.json" entry_data = json.loads(load_fixture(fixture_filename)) + for device in entry_data[CONF_DEVICES]: + device[CONF_ADDRESS] = tuple(device[CONF_ADDRESS]) + for entity in entry_data[CONF_ENTITIES]: + entity[CONF_ADDRESS] = tuple(entity[CONF_ADDRESS]) + options = {} title = entry_data[CONF_HOST] @@ -97,6 +103,7 @@ async def init_integration( hass: HomeAssistant, entry: MockConfigEntry ) -> AsyncGenerator[MockPchkConnectionManager]: """Set up the LCN integration in Home Assistant.""" + hass.http = Mock() # needs to be mocked as hass.http.register_static_path is called when registering the frontend lcn_connection = None def lcn_connection_factory(*args, **kwargs): diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 31b51adfce7..08ccd194578 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -9,14 +9,14 @@ "devices": [ { "address": [0, 7, false], - "name": "", + "name": "TestModule", "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 }, { "address": [0, 5, true], - "name": "", + "name": "TestGroup", "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index e1705e4b349..e4cb4c588e9 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -5,9 +5,11 @@ from unittest.mock import patch from pypck.connection import PchkAuthenticationError, PchkLicenseError import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.lcn.config_flow import LcnFlowHandler, validate_connection from homeassistant.components.lcn.const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN from homeassistant.const import ( + CONF_BASE, CONF_DEVICES, CONF_ENTITIES, CONF_HOST, @@ -16,25 +18,33 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry -IMPORT_DATA = { - CONF_HOST: "pchk", +CONFIG_DATA = { CONF_IP_ADDRESS: "127.0.0.1", - CONF_PORT: 4114, + CONF_PORT: 1234, CONF_USERNAME: "lcn", CONF_PASSWORD: "lcn", CONF_SK_NUM_TRIES: 0, CONF_DIM_MODE: "STEPS200", +} + +CONNECTION_DATA = {CONF_HOST: "pchk", **CONFIG_DATA} + +IMPORT_DATA = { + **CONNECTION_DATA, CONF_DEVICES: [], CONF_ENTITIES: [], } -async def test_step_import(hass: HomeAssistant) -> None: +async def test_step_import( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test for import step.""" with ( @@ -46,14 +56,18 @@ async def test_step_import(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "pchk" assert result["data"] == IMPORT_DATA + assert issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) -async def test_step_import_existing_host(hass: HomeAssistant) -> None: +async def test_step_import_existing_host( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test for update of config_entry if imported host already exists.""" # Create config entry and add it to hass @@ -67,13 +81,15 @@ async def test_step_import_existing_host(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_data ) - await hass.async_block_till_done() # Check if config entry was updated assert result["type"] is FlowResultType.ABORT assert result["reason"] == "existing_configuration_updated" assert mock_entry.source == config_entries.SOURCE_IMPORT assert mock_entry.data == IMPORT_DATA + assert issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) @pytest.mark.parametrize( @@ -81,10 +97,12 @@ async def test_step_import_existing_host(hass: HomeAssistant) -> None: [ (PchkAuthenticationError, "authentication_error"), (PchkLicenseError, "license_error"), - (TimeoutError, "connection_timeout"), + (TimeoutError, "connection_refused"), ], ) -async def test_step_import_error(hass: HomeAssistant, error, reason) -> None: +async def test_step_import_error( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, error, reason +) -> None: """Test for error in import is handled correctly.""" with patch( "pypck.connection.PchkConnectionManager.async_connect", side_effect=error @@ -94,7 +112,146 @@ async def test_step_import_error(hass: HomeAssistant, error, reason) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason + assert issue_registry.async_get_issue(DOMAIN, reason) + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + flow = LcnFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_step_user(hass): + """Test for user step.""" + with ( + patch("pypck.connection.PchkConnectionManager.async_connect"), + patch("homeassistant.components.lcn.async_setup", return_value=True), + patch("homeassistant.components.lcn.async_setup_entry", return_value=True), + ): + data = CONNECTION_DATA.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=data + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == CONNECTION_DATA[CONF_HOST] + assert result["data"] == { + **CONNECTION_DATA, + CONF_DEVICES: [], + CONF_ENTITIES: [], + } + + +async def test_step_user_existing_host(hass, entry): + """Test for user defined host already exists.""" + entry.add_to_hass(hass) + + with patch("pypck.connection.PchkConnectionManager.async_connect"): + config_data = entry.data.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_data + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "already_configured"} + + +@pytest.mark.parametrize( + ("error", "errors"), + [ + (PchkAuthenticationError, {CONF_BASE: "authentication_error"}), + (PchkLicenseError, {CONF_BASE: "license_error"}), + (TimeoutError, {CONF_BASE: "connection_refused"}), + ], +) +async def test_step_user_error(hass, error, errors): + """Test for error in user step is handled correctly.""" + with patch( + "pypck.connection.PchkConnectionManager.async_connect", side_effect=error + ): + data = CONNECTION_DATA.copy() + data.update({CONF_HOST: "pchk"}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=data + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == errors + + +async def test_step_reconfigure(hass, entry): + """Test for reconfigure step.""" + entry.add_to_hass(hass) + old_entry_data = entry.data.copy() + + with ( + patch("pypck.connection.PchkConnectionManager.async_connect"), + patch("homeassistant.components.lcn.async_setup", return_value=True), + patch("homeassistant.components.lcn.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=CONFIG_DATA.copy(), + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry.title == CONNECTION_DATA[CONF_HOST] + assert entry.data == {**old_entry_data, **CONFIG_DATA} + + +@pytest.mark.parametrize( + ("error", "errors"), + [ + (PchkAuthenticationError, {CONF_BASE: "authentication_error"}), + (PchkLicenseError, {CONF_BASE: "license_error"}), + (TimeoutError, {CONF_BASE: "connection_refused"}), + ], +) +async def test_step_reconfigure_error(hass, entry, error, errors): + """Test for error in reconfigure step is handled correctly.""" + entry.add_to_hass(hass) + with patch( + "pypck.connection.PchkConnectionManager.async_connect", side_effect=error + ): + data = {**CONNECTION_DATA, CONF_HOST: "pchk"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == errors + + +async def test_validate_connection(): + """Test the connection validation.""" + data = CONNECTION_DATA.copy() + + with ( + patch("pypck.connection.PchkConnectionManager.async_connect") as async_connect, + patch("pypck.connection.PchkConnectionManager.async_close") as async_close, + ): + result = await validate_connection(data=data) + + assert async_connect.is_called + assert async_close.is_called + assert result is None diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 670735439ce..c118b98ecef 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -1,6 +1,6 @@ """Test init of LCN integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from pypck.connection import ( PchkAuthenticationError, @@ -31,6 +31,7 @@ async def test_async_setup_entry(hass: HomeAssistant, entry, lcn_connection) -> async def test_async_setup_multiple_entries(hass: HomeAssistant, entry, entry2) -> None: """Test a successful setup and unload of multiple entries.""" + hass.http = Mock() with patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager): for config_entry in (entry, entry2): config_entry.add_to_hass(hass) diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py new file mode 100644 index 00000000000..f1f0a19b572 --- /dev/null +++ b/tests/components/lcn/test_websocket.py @@ -0,0 +1,303 @@ +"""LCN Websocket Tests.""" + +from pypck.lcn_addr import LcnAddr +import pytest + +from homeassistant.components.lcn.const import CONF_DOMAIN_DATA +from homeassistant.components.lcn.helpers import get_device_config, get_resource +from homeassistant.const import ( + CONF_ADDRESS, + CONF_DEVICES, + CONF_DOMAIN, + CONF_ENTITIES, + CONF_NAME, + CONF_RESOURCE, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + +DEVICES_PAYLOAD = {CONF_TYPE: "lcn/devices", "entry_id": ""} +ENTITIES_PAYLOAD = { + CONF_TYPE: "lcn/entities", + "entry_id": "", +} +SCAN_PAYLOAD = {CONF_TYPE: "lcn/devices/scan", "entry_id": ""} +DEVICES_ADD_PAYLOAD = { + CONF_TYPE: "lcn/devices/add", + "entry_id": "", + CONF_ADDRESS: (0, 10, False), +} +DEVICES_DELETE_PAYLOAD = { + CONF_TYPE: "lcn/devices/delete", + "entry_id": "", + CONF_ADDRESS: (0, 7, False), +} +ENTITIES_ADD_PAYLOAD = { + CONF_TYPE: "lcn/entities/add", + "entry_id": "", + CONF_ADDRESS: (0, 7, False), + CONF_NAME: "test_switch", + CONF_DOMAIN: "switch", + CONF_DOMAIN_DATA: {"output": "RELAY5"}, +} +ENTITIES_DELETE_PAYLOAD = { + CONF_TYPE: "lcn/entities/delete", + "entry_id": "", + CONF_ADDRESS: (0, 7, False), + CONF_DOMAIN: "switch", + CONF_RESOURCE: "relay1", +} + + +async def test_lcn_devices_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/devices command.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({**DEVICES_PAYLOAD, "entry_id": entry.entry_id}) + + res = await client.receive_json() + assert res["success"], res + assert len(res["result"]) == len(entry.data[CONF_DEVICES]) + assert all( + {**result, CONF_ADDRESS: tuple(result[CONF_ADDRESS])} + in entry.data[CONF_DEVICES] + for result in res["result"] + ) + + +@pytest.mark.parametrize( + "payload", + [ + ENTITIES_PAYLOAD, + {**ENTITIES_PAYLOAD, CONF_ADDRESS: (0, 7, False)}, + ], +) +async def test_lcn_entities_command( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entry, + lcn_connection, + payload, +) -> None: + """Test lcn/entities command.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + **payload, + "entry_id": entry.entry_id, + } + ) + + res = await client.receive_json() + assert res["success"], res + entities = [ + entity + for entity in entry.data[CONF_ENTITIES] + if CONF_ADDRESS not in payload or entity[CONF_ADDRESS] == payload[CONF_ADDRESS] + ] + assert len(res["result"]) == len(entities) + assert all( + {**result, CONF_ADDRESS: tuple(result[CONF_ADDRESS])} in entities + for result in res["result"] + ) + + +async def test_lcn_devices_scan_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/devices/scan command.""" + # add new module which is not stored in config_entry + lcn_connection.get_address_conn(LcnAddr(0, 10, False)) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({**SCAN_PAYLOAD, "entry_id": entry.entry_id}) + + res = await client.receive_json() + assert res["success"], res + + lcn_connection.scan_modules.assert_awaited() + assert len(res["result"]) == len(entry.data[CONF_DEVICES]) + assert all( + {**result, CONF_ADDRESS: tuple(result[CONF_ADDRESS])} + in entry.data[CONF_DEVICES] + for result in res["result"] + ) + + +async def test_lcn_devices_add_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/devices/add command.""" + client = await hass_ws_client(hass) + assert get_device_config((0, 10, False), entry) is None + + await client.send_json_auto_id({**DEVICES_ADD_PAYLOAD, "entry_id": entry.entry_id}) + + res = await client.receive_json() + assert res["success"], res + + assert get_device_config((0, 10, False), entry) + + +async def test_lcn_devices_delete_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/devices/delete command.""" + client = await hass_ws_client(hass) + assert get_device_config((0, 7, False), entry) + + await client.send_json_auto_id( + {**DEVICES_DELETE_PAYLOAD, "entry_id": entry.entry_id} + ) + + res = await client.receive_json() + assert res["success"], res + assert get_device_config((0, 7, False), entry) is None + + +async def test_lcn_entities_add_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/entities/add command.""" + client = await hass_ws_client(hass) + + entity_config = { + key: ENTITIES_ADD_PAYLOAD[key] + for key in (CONF_ADDRESS, CONF_NAME, CONF_DOMAIN, CONF_DOMAIN_DATA) + } + + resource = get_resource( + ENTITIES_ADD_PAYLOAD[CONF_DOMAIN], ENTITIES_ADD_PAYLOAD[CONF_DOMAIN_DATA] + ).lower() + + assert {**entity_config, CONF_RESOURCE: resource} not in entry.data[CONF_ENTITIES] + + await client.send_json_auto_id({**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id}) + + res = await client.receive_json() + assert res["success"], res + + assert {**entity_config, CONF_RESOURCE: resource} in entry.data[CONF_ENTITIES] + + +async def test_lcn_entities_delete_command( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection +) -> None: + """Test lcn/entities/delete command.""" + client = await hass_ws_client(hass) + + assert ( + len( + [ + entity + for entity in entry.data[CONF_ENTITIES] + if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] + and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] + and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + ] + ) + == 1 + ) + + await client.send_json_auto_id( + {**ENTITIES_DELETE_PAYLOAD, "entry_id": entry.entry_id} + ) + + res = await client.receive_json() + assert res["success"], res + + assert ( + len( + [ + entity + for entity in entry.data[CONF_ENTITIES] + if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] + and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] + and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + ] + ) + == 0 + ) + + +@pytest.mark.parametrize( + ("payload", "entity_id", "result"), + [ + (DEVICES_PAYLOAD, "12345", False), + (ENTITIES_PAYLOAD, "12345", False), + (SCAN_PAYLOAD, "12345", False), + (DEVICES_ADD_PAYLOAD, "12345", False), + (DEVICES_DELETE_PAYLOAD, "12345", False), + (ENTITIES_ADD_PAYLOAD, "12345", False), + (ENTITIES_DELETE_PAYLOAD, "12345", False), + ], +) +async def test_lcn_command_host_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + lcn_connection, + payload, + entity_id, + result, +) -> None: + """Test lcn commands for unknown host.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({**payload, "entry_id": entity_id}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"] == result + + +@pytest.mark.parametrize( + ("payload", "address", "result"), + [ + (DEVICES_ADD_PAYLOAD, (0, 7, False), False), # device already existing + (DEVICES_DELETE_PAYLOAD, (0, 42, False), False), + (ENTITIES_ADD_PAYLOAD, (0, 42, False), False), + (ENTITIES_DELETE_PAYLOAD, (0, 42, 0), False), + ], +) +async def test_lcn_command_address_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entry, + lcn_connection, + payload, + address, + result, +) -> None: + """Test lcn commands for address error.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {**payload, "entry_id": entry.entry_id, CONF_ADDRESS: address} + ) + + res = await client.receive_json() + assert res["success"], res + assert res["result"] == result + + +async def test_lcn_entities_add_existing_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entry, + lcn_connection, +) -> None: + """Test lcn commands for address error.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + **ENTITIES_ADD_PAYLOAD, + "entry_id": entry.entry_id, + CONF_DOMAIN_DATA: {"output": "RELAY1"}, + } + ) + + res = await client.receive_json() + assert res["success"], res + assert res["result"] is False