From a447040d0bed815cec71efe6f00dfc735a0e0d7d Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 16 Nov 2023 16:16:13 +0100 Subject: [PATCH 01/51] knx entity CRUD - initial commit - switch --- homeassistant/components/knx/__init__.py | 23 ++++- .../components/knx/helpers/entity_store.py | 97 +++++++++++++++++++ .../knx/helpers/entity_store_schema.py | 39 ++++++++ homeassistant/components/knx/switch.py | 90 ++++++++++++----- homeassistant/components/knx/websocket.py | 91 +++++++++++++++++ 5 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/knx/helpers/entity_store.py create mode 100644 homeassistant/components/knx/helpers/entity_store_schema.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 9c64b4e1b3105a..1ef05033e909ce 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -67,6 +67,7 @@ ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure +from .helpers.entity_store import KNXEntityStore from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject from .schema import ( BinarySensorSchema, @@ -191,9 +192,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_knx_exposure(hass, knx_module.xknx, expose_config) ) # always forward sensor for system entities (telegram counter, etc.) - platforms = {platform for platform in SUPPORTED_PLATFORMS if platform in config} - platforms.add(Platform.SENSOR) - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) + # TODO: forward all platforms to get entity store connected + await hass.config_entries.async_forward_entry_setup(entry, Platform.SWITCH) + await hass.config_entries.async_forward_entry_setups( + entry, + [ + platform + for platform in SUPPORTED_PLATFORMS + if platform in config and platform not in (Platform.SENSOR, Platform.SWITCH) + ], + ) # set up notify service for backwards compatibility - remove 2024.11 if NotifySchema.PLATFORM in config: @@ -278,6 +287,7 @@ def __init__( self.entry = entry self.project = KNXProject(hass=hass, entry=entry) + self.entity_store = KNXEntityStore(hass=hass, entry=entry) self.xknx = XKNX( connection_config=self.connection_config(), @@ -298,8 +308,10 @@ def __init__( ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() + self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self._knx_event_callback: TelegramQueue.Callback = ( + self.register_event_callback() + ) self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) @@ -309,6 +321,7 @@ def __init__( async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.project.load_project() + await self.entity_store.load_data() await self.telegrams.load_history() await self.xknx.start() diff --git a/homeassistant/components/knx/helpers/entity_store.py b/homeassistant/components/knx/helpers/entity_store.py new file mode 100644 index 00000000000000..3492c07dc76c3d --- /dev/null +++ b/homeassistant/components/knx/helpers/entity_store.py @@ -0,0 +1,97 @@ +"""KNX entity configuration store.""" +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any, Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.storage import Store +from homeassistant.util.uuid import random_uuid_hex + +from ..const import DOMAIN + +if TYPE_CHECKING: + from ..knx_entity import KnxEntity + +_LOGGER = logging.getLogger(__name__) + +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/entity_store.json" + +KNXPlatformStoreModel = dict[str, dict[str, Any]] # uuid: configuration +KNXEntityStoreModel = dict[ + str, KNXPlatformStoreModel +] # platform: KNXPlatformStoreModel + + +class KNXEntityStore: + """Manage KNX entity store data.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize project data.""" + self.hass = hass + self._store = Store[KNXEntityStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + + self.data: KNXEntityStoreModel = {} + # entities and async_add_entity are filled by platform setups + self.entities: dict[str, KnxEntity] = {} # unique_id as key + self.async_add_entity: dict[ + Platform, Callable[[str, dict[str, Any]], None] + ] = {} + + async def load_data(self) -> None: + """Load project data from storage.""" + self.data = await self._store.async_load() or {} + _LOGGER.debug( + "Loaded KNX entity data for %s entity platforms from storage", + len(self.data), + ) + + async def create_entitiy(self, platform: Platform, data: dict[str, Any]) -> None: + """Create a new entity.""" + if platform not in self.async_add_entity: + raise EntityStoreException(f"Entity platform not ready: {platform}") + unique_id = f"knx_es_{random_uuid_hex()}" + if unique_id in self.data.setdefault(platform, {}): + raise EntityStoreException("Unique id already used.") + self.async_add_entity[platform](unique_id, data) + # store data after entity is added to make sure config doesn't raise exceptions + self.data[platform][unique_id] = data + await self._store.async_save(self.data) + + async def update_entity( + self, platform: Platform, unique_id: str, data: dict[str, Any] + ) -> None: + """Update an existing entity.""" + if platform not in self.async_add_entity: + raise EntityStoreException(f"Entity platform not ready: {platform}") + if platform not in self.data or unique_id not in self.data[platform]: + raise EntityStoreException(f"Entity not found in {platform}: {unique_id}") + await self.entities.pop(unique_id).async_remove() + self.async_add_entity[platform](unique_id, data) + # store data after entity is added to make sure config doesn't raise exceptions + self.data[platform][unique_id] = data + await self._store.async_save(self.data) + + async def delete_entity(self, platform: Platform, unique_id: str) -> None: + """Delete an existing entity.""" + try: + del self.data[platform][unique_id] + except KeyError as err: + raise EntityStoreException( + f"Entity not found in {platform}: {unique_id}" + ) from err + _entity_id = self.entities.pop(unique_id).entity_id + entity_registry = er.async_get(self.hass) + entity_registry.async_remove(_entity_id) + await self._store.async_save(self.data) + + +class EntityStoreException(Exception): + """KNX entity store exception.""" diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py new file mode 100644 index 00000000000000..fbde6e17678a74 --- /dev/null +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -0,0 +1,39 @@ +"""KNX entity store schema.""" +import voluptuous as vol + +from homeassistant.components.switch import ( + DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, +) +from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA + +from ..schema import ga_list_validator, sync_state_validator + +BASE_ENTITY_SCHEMA = vol.Schema( + { + vol.Required("name"): str, + vol.Required("device_id"): vol.Maybe(str), + vol.Required("entity_category"): vol.Maybe(ENTITY_CATEGORIES_SCHEMA), + vol.Required("sync_state"): sync_state_validator, + } +) +SWITCH_SCHEMA = BASE_ENTITY_SCHEMA.extend( + { + vol.Required("device_class"): vol.Maybe(SWITCH_DEVICE_CLASSES_SCHEMA), + vol.Required("invert"): bool, + vol.Required("switch_address"): ga_list_validator, + vol.Required("switch_state_address"): ga_list_validator, + vol.Required("respond_to_read"): bool, + } +) +CREATE_ENTITY_SCHEMA = vol.Any( + SWITCH_SCHEMA.extend({vol.Required("platform"): "switch"}) +) +UPDATE_ENTITY_SCHEMA = vol.Any( + SWITCH_SCHEMA.extend( + { + vol.Required("platform"): "switch", + vol.Required("unique_id"): str, + } + ) +) +# TODO: use cv.key_value_schemas diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 096ce235e2cb10..d39874e9438e8e 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -18,11 +18,12 @@ STATE_UNKNOWN, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType +from . import KNXModule from .const import CONF_RESPOND_TO_READ, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity from .schema import SwitchSchema @@ -34,32 +35,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SWITCH] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXSwitch(xknx, entity_config) for entity_config in config) + yaml_config: list[ConfigType] | None + if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH): + async_add_entities( + KnxYamlSwitch(knx_module.xknx, entity_config) + for entity_config in yaml_config + ) + ui_config: dict[str, ConfigType] | None + if ui_config := knx_module.entity_store.data.get(Platform.SWITCH): + async_add_entities( + KnxUiSwitch(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + @callback + def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None: + """Add KNX entity at runtime.""" + async_add_entities([KnxUiSwitch(knx_module, unique_id, config)]) -class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): - """Representation of a KNX switch.""" + knx_module.entity_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch - _device: XknxSwitch - def __init__(self, xknx: XKNX, config: ConfigType) -> None: - """Initialize of KNX switch.""" - super().__init__( - device=XknxSwitch( - xknx, - name=config[CONF_NAME], - group_address=config[KNX_ADDRESS], - group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS), - respond_to_read=config[CONF_RESPOND_TO_READ], - invert=config[SwitchSchema.CONF_INVERT], - ) - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_unique_id = str(self._device.switch.group_address) +class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity): + """Base class for a KNX switch.""" + + _device: XknxSwitch async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -82,3 +84,47 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._device.set_off() + + +class KnxYamlSwitch(_KnxSwitch): + """Representation of a KNX switch configured from YAML.""" + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize of KNX switch.""" + super().__init__( + device=XknxSwitch( + xknx, + name=config[CONF_NAME], + group_address=config[KNX_ADDRESS], + group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + invert=config[SwitchSchema.CONF_INVERT], + ) + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = str(self._device.switch.group_address) + + +class KnxUiSwitch(_KnxSwitch): + """Representation of a KNX switch configured from UI.""" + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize of KNX switch.""" + super().__init__( + device=XknxSwitch( + knx_module.xknx, + name=config[CONF_NAME], + group_address=config["switch_address"], + group_address_state=config["switch_state_address"], + respond_to_read=config[CONF_RESPOND_TO_READ], + invert=config["invert"], + ) + ) + self._attr_entity_category = config[CONF_ENTITY_CATEGORY] + self._attr_device_class = config[CONF_DEVICE_CLASS] + self._attr_unique_id = unique_id + # TODO: self._attr_device_info = + knx_module.entity_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 0ac5a21d33395f..893c8086655f34 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN +from .helpers.entity_store import EntityStoreException +from .helpers.entity_store_schema import CREATE_ENTITY_SCHEMA, UPDATE_ENTITY_SCHEMA from .telegrams import TelegramDict if TYPE_CHECKING: @@ -30,6 +32,9 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) websocket_api.async_register_command(hass, ws_get_knx_project) + websocket_api.async_register_command(hass, ws_create_entity) + websocket_api.async_register_command(hass, ws_update_entity) + websocket_api.async_register_command(hass, ws_delete_entity) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -213,3 +218,89 @@ def forward_telegram(telegram: TelegramDict) -> None: name="KNX GroupMonitor subscription", ) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + vol.All( + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): "knx/create_entity", + vol.Required("data"): CREATE_ENTITY_SCHEMA, + } + ), + ) +) +@websocket_api.async_response +async def ws_create_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.entity_store.create_entitiy(msg["data"]["platform"], msg["data"]) + except EntityStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + vol.All( + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): "knx/update_entity", + vol.Required("data"): UPDATE_ENTITY_SCHEMA, + } + ), + ) +) +@websocket_api.async_response +async def ws_update_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.entity_store.update_entity( + msg["data"]["platform"], msg["data"]["unique_id"], msg["data"] + ) + except EntityStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/delete_entity", + vol.Required("platform"): str, + vol.Required("unique_id"): str, + } +) +@websocket_api.async_response +async def ws_delete_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.entity_store.delete_entity(msg["platform"], msg["unique_id"]) + except EntityStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) From cc11f5678554c7439e3a62d658b4f6666ada362e Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 16 Nov 2023 22:05:58 +0100 Subject: [PATCH 02/51] platform dependent schema --- .../knx/helpers/entity_store_schema.py | 46 ++++++++++++++----- homeassistant/components/knx/websocket.py | 20 +++++--- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py index fbde6e17678a74..e73d6b0e1e1d5a 100644 --- a/homeassistant/components/knx/helpers/entity_store_schema.py +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -4,6 +4,8 @@ from homeassistant.components.switch import ( DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.const import Platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from ..schema import ga_list_validator, sync_state_validator @@ -16,24 +18,46 @@ vol.Required("sync_state"): sync_state_validator, } ) + SWITCH_SCHEMA = BASE_ENTITY_SCHEMA.extend( { vol.Required("device_class"): vol.Maybe(SWITCH_DEVICE_CLASSES_SCHEMA), vol.Required("invert"): bool, vol.Required("switch_address"): ga_list_validator, - vol.Required("switch_state_address"): ga_list_validator, + vol.Required("switch_state_address"): vol.Maybe(ga_list_validator), vol.Required("respond_to_read"): bool, } ) -CREATE_ENTITY_SCHEMA = vol.Any( - SWITCH_SCHEMA.extend({vol.Required("platform"): "switch"}) -) -UPDATE_ENTITY_SCHEMA = vol.Any( - SWITCH_SCHEMA.extend( + +ENTITY_STORE_DATA_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("platform"): vol.Coerce(Platform), + vol.Required("data"): dict, + }, + extra=vol.ALLOW_EXTRA, + ), + cv.key_value_schemas( + "platform", { - vol.Required("platform"): "switch", - vol.Required("unique_id"): str, - } - ) + Platform.SWITCH: vol.Schema( + {vol.Required("data"): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA + ) + }, + ), ) -# TODO: use cv.key_value_schemas + +CREATE_ENTITY_BASE_SCHEMA = { + vol.Required("platform"): str, + vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA +} + +UPDATE_ENTITY_BASE_SCHEMA = { + vol.Required("unique_id"): str, + **CREATE_ENTITY_BASE_SCHEMA, +} + +DELETE_ENTITY_SCHEMA = { + vol.Required("platform"): vol.Coerce(Platform), + vol.Required("unique_id"): str, +} diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 893c8086655f34..f3c233ca8a1a4e 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -14,7 +14,12 @@ from .const import DOMAIN from .helpers.entity_store import EntityStoreException -from .helpers.entity_store_schema import CREATE_ENTITY_SCHEMA, UPDATE_ENTITY_SCHEMA +from .helpers.entity_store_schema import ( + CREATE_ENTITY_BASE_SCHEMA, + DELETE_ENTITY_SCHEMA, + ENTITY_STORE_DATA_SCHEMA, + UPDATE_ENTITY_BASE_SCHEMA, +) from .telegrams import TelegramDict if TYPE_CHECKING: @@ -226,9 +231,10 @@ def forward_telegram(telegram: TelegramDict) -> None: websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( { vol.Required("type"): "knx/create_entity", - vol.Required("data"): CREATE_ENTITY_SCHEMA, + **CREATE_ENTITY_BASE_SCHEMA, } ), + ENTITY_STORE_DATA_SCHEMA, ) ) @websocket_api.async_response @@ -240,7 +246,7 @@ async def ws_create_entity( """Handle get info command of group monitor.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.entity_store.create_entitiy(msg["data"]["platform"], msg["data"]) + await knx.entity_store.create_entitiy(msg["platform"], msg["data"]) except EntityStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) @@ -255,9 +261,10 @@ async def ws_create_entity( websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( { vol.Required("type"): "knx/update_entity", - vol.Required("data"): UPDATE_ENTITY_SCHEMA, + **UPDATE_ENTITY_BASE_SCHEMA, } ), + ENTITY_STORE_DATA_SCHEMA, ) ) @websocket_api.async_response @@ -270,7 +277,7 @@ async def ws_update_entity( knx: KNXModule = hass.data[DOMAIN] try: await knx.entity_store.update_entity( - msg["data"]["platform"], msg["data"]["unique_id"], msg["data"] + msg["platform"], msg["unique_id"], msg["data"] ) except EntityStoreException as err: connection.send_error( @@ -284,8 +291,7 @@ async def ws_update_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_entity", - vol.Required("platform"): str, - vol.Required("unique_id"): str, + **DELETE_ENTITY_SCHEMA, } ) @websocket_api.async_response From afc9b4332ee62f01b6851d1ccdba48461361fdf7 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 17 Nov 2023 10:27:01 +0100 Subject: [PATCH 03/51] coerce empty GA-lists to None --- .../components/knx/helpers/entity_store_schema.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py index e73d6b0e1e1d5a..33e513928ed1be 100644 --- a/homeassistant/components/knx/helpers/entity_store_schema.py +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -8,7 +8,11 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA -from ..schema import ga_list_validator, sync_state_validator +from ..validation import ( + ga_list_validator, + ga_list_validator_optional, + sync_state_validator, +) BASE_ENTITY_SCHEMA = vol.Schema( { @@ -24,7 +28,7 @@ vol.Required("device_class"): vol.Maybe(SWITCH_DEVICE_CLASSES_SCHEMA), vol.Required("invert"): bool, vol.Required("switch_address"): ga_list_validator, - vol.Required("switch_state_address"): vol.Maybe(ga_list_validator), + vol.Required("switch_state_address"): ga_list_validator_optional, vol.Required("respond_to_read"): bool, } ) From 56d12c5377d5b37a52474e6cd7adcf5bb452835d Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 17 Nov 2023 11:40:47 +0100 Subject: [PATCH 04/51] read entity configuration from WS --- .../components/knx/helpers/entity_store.py | 2 +- .../knx/helpers/entity_store_schema.py | 2 +- homeassistant/components/knx/websocket.py | 36 ++++++++++++++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/knx/helpers/entity_store.py b/homeassistant/components/knx/helpers/entity_store.py index 3492c07dc76c3d..0047e1c6b3891c 100644 --- a/homeassistant/components/knx/helpers/entity_store.py +++ b/homeassistant/components/knx/helpers/entity_store.py @@ -20,7 +20,7 @@ STORAGE_VERSION: Final = 1 STORAGE_KEY: Final = f"{DOMAIN}/entity_store.json" -KNXPlatformStoreModel = dict[str, dict[str, Any]] # uuid: configuration +KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration KNXEntityStoreModel = dict[ str, KNXPlatformStoreModel ] # platform: KNXPlatformStoreModel diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py index 33e513928ed1be..8202e2a4c2519d 100644 --- a/homeassistant/components/knx/helpers/entity_store_schema.py +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -61,7 +61,7 @@ **CREATE_ENTITY_BASE_SCHEMA, } -DELETE_ENTITY_SCHEMA = { +LOOKUP_ENTITY_SCHEMA = { vol.Required("platform"): vol.Coerce(Platform), vol.Required("unique_id"): str, } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index f3c233ca8a1a4e..d63f4c60689426 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -16,8 +16,8 @@ from .helpers.entity_store import EntityStoreException from .helpers.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, - DELETE_ENTITY_SCHEMA, ENTITY_STORE_DATA_SCHEMA, + LOOKUP_ENTITY_SCHEMA, UPDATE_ENTITY_BASE_SCHEMA, ) from .telegrams import TelegramDict @@ -40,6 +40,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_create_entity) websocket_api.async_register_command(hass, ws_update_entity) websocket_api.async_register_command(hass, ws_delete_entity) + websocket_api.async_register_command(hass, ws_get_entity_config) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -243,7 +244,7 @@ async def ws_create_entity( connection: websocket_api.ActiveConnection, msg: dict, ) -> None: - """Handle get info command of group monitor.""" + """Create entity in entity store and load it.""" knx: KNXModule = hass.data[DOMAIN] try: await knx.entity_store.create_entitiy(msg["platform"], msg["data"]) @@ -273,7 +274,7 @@ async def ws_update_entity( connection: websocket_api.ActiveConnection, msg: dict, ) -> None: - """Handle get info command of group monitor.""" + """Update entity in entity store and reload it.""" knx: KNXModule = hass.data[DOMAIN] try: await knx.entity_store.update_entity( @@ -291,7 +292,7 @@ async def ws_update_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_entity", - **DELETE_ENTITY_SCHEMA, + **LOOKUP_ENTITY_SCHEMA, } ) @websocket_api.async_response @@ -300,7 +301,7 @@ async def ws_delete_entity( connection: websocket_api.ActiveConnection, msg: dict, ) -> None: - """Handle get info command of group monitor.""" + """Delete entity from entity store and remove it.""" knx: KNXModule = hass.data[DOMAIN] try: await knx.entity_store.delete_entity(msg["platform"], msg["unique_id"]) @@ -310,3 +311,28 @@ async def ws_delete_entity( ) return connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_entity_config", + **LOOKUP_ENTITY_SCHEMA, + } +) +@callback +def ws_get_entity_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get entity configuration from entity store.""" + knx: KNXModule = hass.data[DOMAIN] + try: + config = knx.entity_store.data[msg["platform"]][msg["unique_id"]] + except KeyError: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Entity not found." + ) + return + connection.send_result(msg["id"], config) From 549a7f9b6798bb812b62809b8f15017bd8a20f1b Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 8 Dec 2023 10:11:57 +0100 Subject: [PATCH 05/51] use entity_id instead of unique_id for lookup --- .../components/knx/helpers/entity_store.py | 44 +++++++++++++++---- .../knx/helpers/entity_store_schema.py | 21 +++++---- homeassistant/components/knx/websocket.py | 30 ++++++++++--- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/knx/helpers/entity_store.py b/homeassistant/components/knx/helpers/entity_store.py index 0047e1c6b3891c..95b0980dec1237 100644 --- a/homeassistant/components/knx/helpers/entity_store.py +++ b/homeassistant/components/knx/helpers/entity_store.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.util.uuid import random_uuid_hex @@ -61,10 +61,25 @@ async def create_entitiy(self, platform: Platform, data: dict[str, Any]) -> None if unique_id in self.data.setdefault(platform, {}): raise EntityStoreException("Unique id already used.") self.async_add_entity[platform](unique_id, data) - # store data after entity is added to make sure config doesn't raise exceptions + # store data after entity was added to be sure config didn't raise exceptions self.data[platform][unique_id] = data await self._store.async_save(self.data) + @callback + def get_entity_config(self, entity_id: str) -> dict[str, Any]: + """Return KNX entity configuration.""" + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise EntityStoreException(f"Entity not found: {entity_id}") + try: + return { + "platform": entry.domain, + "unique_id": entry.unique_id, + "data": self.data[entry.domain][entry.unique_id], + } + except KeyError as err: + raise EntityStoreException(f"Entity data not found: {entity_id}") from err + async def update_entity( self, platform: Platform, unique_id: str, data: dict[str, Any] ) -> None: @@ -79,19 +94,32 @@ async def update_entity( self.data[platform][unique_id] = data await self._store.async_save(self.data) - async def delete_entity(self, platform: Platform, unique_id: str) -> None: + async def delete_entity(self, entity_id: str) -> None: """Delete an existing entity.""" + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise EntityStoreException(f"Entity not found: {entity_id}") try: - del self.data[platform][unique_id] + del self.data[entry.domain][entry.unique_id] except KeyError as err: raise EntityStoreException( - f"Entity not found in {platform}: {unique_id}" + f"Entity not found in {entry.domain}: {entry.unique_id}" ) from err - _entity_id = self.entities.pop(unique_id).entity_id - entity_registry = er.async_get(self.hass) - entity_registry.async_remove(_entity_id) + try: + del self.entities[entry.unique_id] + except KeyError: + _LOGGER.warning("Entity not initialized when deleted: %s", entity_id) + entity_registry.async_remove(entity_id) await self._store.async_save(self.data) + def get_entity_entries(self) -> list[er.RegistryEntry]: + """Get entity_ids of all configured entities by platform.""" + return [ + entity.registry_entry + for entity in self.entities.values() + if entity.registry_entry is not None + ] + class EntityStoreException(Exception): """KNX entity store exception.""" diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py index 8202e2a4c2519d..376e50df5b2a68 100644 --- a/homeassistant/components/knx/helpers/entity_store_schema.py +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -17,19 +17,23 @@ BASE_ENTITY_SCHEMA = vol.Schema( { vol.Required("name"): str, - vol.Required("device_id"): vol.Maybe(str), - vol.Required("entity_category"): vol.Maybe(ENTITY_CATEGORIES_SCHEMA), - vol.Required("sync_state"): sync_state_validator, + vol.Optional("device_id", default=None): vol.Maybe(str), + vol.Optional("entity_category", default=None): vol.Any( + ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) + ), + vol.Optional("sync_state", default=True): sync_state_validator, } ) SWITCH_SCHEMA = BASE_ENTITY_SCHEMA.extend( { - vol.Required("device_class"): vol.Maybe(SWITCH_DEVICE_CLASSES_SCHEMA), - vol.Required("invert"): bool, + vol.Optional("device_class", default=None): vol.Maybe( + SWITCH_DEVICE_CLASSES_SCHEMA + ), + vol.Optional("invert", default=False): bool, vol.Required("switch_address"): ga_list_validator, vol.Required("switch_state_address"): ga_list_validator_optional, - vol.Required("respond_to_read"): bool, + vol.Optional("respond_to_read", default=False): bool, } ) @@ -60,8 +64,3 @@ vol.Required("unique_id"): str, **CREATE_ENTITY_BASE_SCHEMA, } - -LOOKUP_ENTITY_SCHEMA = { - vol.Required("platform"): vol.Coerce(Platform), - vol.Required("unique_id"): str, -} diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index d63f4c60689426..b0130ee380fc39 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -17,7 +17,6 @@ from .helpers.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, ENTITY_STORE_DATA_SCHEMA, - LOOKUP_ENTITY_SCHEMA, UPDATE_ENTITY_BASE_SCHEMA, ) from .telegrams import TelegramDict @@ -41,6 +40,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_update_entity) websocket_api.async_register_command(hass, ws_delete_entity) websocket_api.async_register_command(hass, ws_get_entity_config) + websocket_api.async_register_command(hass, ws_get_entity_entries) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -292,7 +292,7 @@ async def ws_update_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_entity", - **LOOKUP_ENTITY_SCHEMA, + vol.Required("entity_id"): str, } ) @websocket_api.async_response @@ -304,7 +304,7 @@ async def ws_delete_entity( """Delete entity from entity store and remove it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.entity_store.delete_entity(msg["platform"], msg["unique_id"]) + await knx.entity_store.delete_entity(msg["entity_id"]) except EntityStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) @@ -313,11 +313,31 @@ async def ws_delete_entity( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_entity_entries", + } +) +@callback +def ws_get_entity_entries( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get entities configured from entity store.""" + knx: KNXModule = hass.data[DOMAIN] + entity_entries = [ + entry.extended_dict for entry in knx.entity_store.get_entity_entries() + ] + connection.send_result(msg["id"], entity_entries) + + @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_entity_config", - **LOOKUP_ENTITY_SCHEMA, + vol.Required("entity_id"): str, } ) @callback @@ -329,7 +349,7 @@ def ws_get_entity_config( """Get entity configuration from entity store.""" knx: KNXModule = hass.data[DOMAIN] try: - config = knx.entity_store.data[msg["platform"]][msg["unique_id"]] + config = knx.entity_store.get_entity_config(msg["entity_id"]) except KeyError: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Entity not found." From f91dd070de24ee74a00f25fec3014836f0206f5d Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 22 Dec 2023 13:36:40 +0100 Subject: [PATCH 06/51] Add device support --- homeassistant/components/knx/__init__.py | 20 +++++++++++ .../knx/helpers/entity_store_schema.py | 4 +-- homeassistant/components/knx/switch.py | 6 +++- homeassistant/components/knx/websocket.py | 36 +++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 1ef05033e909ce..e438509b88a895 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -31,6 +31,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -272,6 +273,25 @@ def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None: await hass.async_add_executor_job(remove_files, storage_dir, knxkeys_filename) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + knx_module: KNXModule = hass.data[DOMAIN] + if not device_entry.identifiers.isdisjoint( + knx_module.interface_device.device_info["identifiers"] + ): + # can not remove interface device + return False + entity_registry = er.async_get(hass) + enitites = er.async_entries_for_device( + entity_registry, device_entry.id, include_disabled_entities=True + ) + for entity in enitites: + await knx_module.entity_store.delete_entity(entity.entity_id) + return True + + class KNXModule: """Representation of KNX Object.""" diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/helpers/entity_store_schema.py index 376e50df5b2a68..5c0e067d0a298a 100644 --- a/homeassistant/components/knx/helpers/entity_store_schema.py +++ b/homeassistant/components/knx/helpers/entity_store_schema.py @@ -16,8 +16,8 @@ BASE_ENTITY_SCHEMA = vol.Schema( { - vol.Required("name"): str, - vol.Optional("device_id", default=None): vol.Maybe(str), + vol.Optional("name", default=None): vol.Maybe(str), + vol.Optional("device_info", default=None): vol.Maybe(str), vol.Optional("entity_category", default=None): vol.Any( ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) ), diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index d39874e9438e8e..28e3d2a5c9b348 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -19,6 +19,7 @@ Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -126,5 +127,8 @@ def __init__( self._attr_entity_category = config[CONF_ENTITY_CATEGORY] self._attr_device_class = config[CONF_DEVICE_CLASS] self._attr_unique_id = unique_id - # TODO: self._attr_device_info = + if device_info := config.get("device_info"): + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) + self._attr_has_entity_name = True + knx_module.entity_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index b0130ee380fc39..c2f6610435f51a 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -11,6 +11,9 @@ from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import UNDEFINED +from homeassistant.util.uuid import random_uuid_hex from .const import DOMAIN from .helpers.entity_store import EntityStoreException @@ -41,6 +44,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_delete_entity) websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entity_entries) + websocket_api.async_register_command(hass, ws_create_device) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -356,3 +360,35 @@ def ws_get_entity_config( ) return connection.send_result(msg["id"], config) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/create_device", + vol.Required("name"): str, + vol.Optional("area_id"): str, + } +) +@callback +def ws_create_device( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Create a new KNX device.""" + knx: KNXModule = hass.data[DOMAIN] + identifier = f"knx_vdev_{random_uuid_hex()}" + device_registry = dr.async_get(hass) + _device = device_registry.async_get_or_create( + config_entry_id=knx.entry.entry_id, + manufacturer="KNX", + name=msg["name"], + identifiers={(DOMAIN, identifier)}, + ) + device_registry.async_update_device( + _device.id, + area_id=msg.get("area_id") or UNDEFINED, + configuration_url=f"homeassistant://knx/entities/view?device_id={_device.id}", + ) + connection.send_result(msg["id"], _device.dict_repr) From 20840623eee73c8fc5a940f82c3e6e262c16a025 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 22 Dec 2023 16:07:48 +0100 Subject: [PATCH 07/51] Rename KNXEntityStore to KNXConfigStore --- homeassistant/components/knx/__init__.py | 8 +-- homeassistant/components/knx/config_flow.py | 2 +- .../knx/{helpers => storage}/__init__.py | 0 .../config_store.py} | 68 +++++++++++-------- .../entity_store_schema.py | 0 .../knx/{helpers => storage}/keyring.py | 0 homeassistant/components/knx/switch.py | 6 +- homeassistant/components/knx/websocket.py | 20 +++--- 8 files changed, 57 insertions(+), 47 deletions(-) rename homeassistant/components/knx/{helpers => storage}/__init__.py (100%) rename homeassistant/components/knx/{helpers/entity_store.py => storage/config_store.py} (65%) rename homeassistant/components/knx/{helpers => storage}/entity_store_schema.py (100%) rename homeassistant/components/knx/{helpers => storage}/keyring.py (100%) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index e438509b88a895..736559b96c11f6 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -68,7 +68,6 @@ ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure -from .helpers.entity_store import KNXEntityStore from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject from .schema import ( BinarySensorSchema, @@ -92,6 +91,7 @@ WeatherSchema, ) from .services import register_knx_services +from .storage.config_store import KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -288,7 +288,7 @@ async def async_remove_config_entry_device( entity_registry, device_entry.id, include_disabled_entities=True ) for entity in enitites: - await knx_module.entity_store.delete_entity(entity.entity_id) + await knx_module.config_store.delete_entity(entity.entity_id) return True @@ -307,7 +307,7 @@ def __init__( self.entry = entry self.project = KNXProject(hass=hass, entry=entry) - self.entity_store = KNXEntityStore(hass=hass, entry=entry) + self.config_store = KNXConfigStore(hass=hass, entry=entry) self.xknx = XKNX( connection_config=self.connection_config(), @@ -341,7 +341,7 @@ def __init__( async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.project.load_project() - await self.entity_store.load_data() + await self.config_store.load_data() await self.telegrams.load_history() await self.xknx.start() diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 7d6443bd9ef421..2fc1f49800c817 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -62,7 +62,7 @@ TELEGRAM_LOG_MAX, KNXConfigEntryData, ) -from .helpers.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file +from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file from .validation import ia_validator, ip_v4_validator CONF_KNX_GATEWAY: Final = "gateway" diff --git a/homeassistant/components/knx/helpers/__init__.py b/homeassistant/components/knx/storage/__init__.py similarity index 100% rename from homeassistant/components/knx/helpers/__init__.py rename to homeassistant/components/knx/storage/__init__.py diff --git a/homeassistant/components/knx/helpers/entity_store.py b/homeassistant/components/knx/storage/config_store.py similarity index 65% rename from homeassistant/components/knx/helpers/entity_store.py rename to homeassistant/components/knx/storage/config_store.py index 95b0980dec1237..a739b243561166 100644 --- a/homeassistant/components/knx/helpers/entity_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -1,7 +1,7 @@ """KNX entity configuration store.""" from collections.abc import Callable import logging -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, TypedDict from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION: Final = 1 -STORAGE_KEY: Final = f"{DOMAIN}/entity_store.json" +STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration KNXEntityStoreModel = dict[ @@ -26,19 +26,25 @@ ] # platform: KNXPlatformStoreModel -class KNXEntityStore: - """Manage KNX entity store data.""" +class KNXConfigStoreModel(TypedDict): + """Represent KNX configuration store data.""" + + entities: KNXEntityStoreModel + + +class KNXConfigStore: + """Manage KNX config store data.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, ) -> None: - """Initialize project data.""" + """Initialize config store.""" self.hass = hass - self._store = Store[KNXEntityStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self.data = KNXConfigStoreModel(entities={}) - self.data: KNXEntityStoreModel = {} # entities and async_add_entity are filled by platform setups self.entities: dict[str, KnxEntity] = {} # unique_id as key self.async_add_entity: dict[ @@ -46,23 +52,24 @@ def __init__( ] = {} async def load_data(self) -> None: - """Load project data from storage.""" - self.data = await self._store.async_load() or {} - _LOGGER.debug( - "Loaded KNX entity data for %s entity platforms from storage", - len(self.data), - ) + """Load config store data from storage.""" + if data := await self._store.async_load(): + self.data = KNXConfigStoreModel(**data) + _LOGGER.debug( + "Loaded KNX config data from storage. %s entity platforms", + len(self.data["entities"]), + ) async def create_entitiy(self, platform: Platform, data: dict[str, Any]) -> None: """Create a new entity.""" if platform not in self.async_add_entity: - raise EntityStoreException(f"Entity platform not ready: {platform}") + raise ConfigStoreException(f"Entity platform not ready: {platform}") unique_id = f"knx_es_{random_uuid_hex()}" - if unique_id in self.data.setdefault(platform, {}): - raise EntityStoreException("Unique id already used.") + if unique_id in self.data["entities"].setdefault(platform, {}): + raise ConfigStoreException("Unique id already used.") self.async_add_entity[platform](unique_id, data) # store data after entity was added to be sure config didn't raise exceptions - self.data[platform][unique_id] = data + self.data["entities"][platform][unique_id] = data await self._store.async_save(self.data) @callback @@ -70,39 +77,42 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: """Return KNX entity configuration.""" entity_registry = er.async_get(self.hass) if (entry := entity_registry.async_get(entity_id)) is None: - raise EntityStoreException(f"Entity not found: {entity_id}") + raise ConfigStoreException(f"Entity not found: {entity_id}") try: return { "platform": entry.domain, "unique_id": entry.unique_id, - "data": self.data[entry.domain][entry.unique_id], + "data": self.data["entities"][entry.domain][entry.unique_id], } except KeyError as err: - raise EntityStoreException(f"Entity data not found: {entity_id}") from err + raise ConfigStoreException(f"Entity data not found: {entity_id}") from err async def update_entity( self, platform: Platform, unique_id: str, data: dict[str, Any] ) -> None: """Update an existing entity.""" if platform not in self.async_add_entity: - raise EntityStoreException(f"Entity platform not ready: {platform}") - if platform not in self.data or unique_id not in self.data[platform]: - raise EntityStoreException(f"Entity not found in {platform}: {unique_id}") + raise ConfigStoreException(f"Entity platform not ready: {platform}") + if ( + platform not in self.data["entities"] + or unique_id not in self.data["entities"][platform] + ): + raise ConfigStoreException(f"Entity not found in {platform}: {unique_id}") await self.entities.pop(unique_id).async_remove() self.async_add_entity[platform](unique_id, data) # store data after entity is added to make sure config doesn't raise exceptions - self.data[platform][unique_id] = data + self.data["entities"][platform][unique_id] = data await self._store.async_save(self.data) async def delete_entity(self, entity_id: str) -> None: """Delete an existing entity.""" entity_registry = er.async_get(self.hass) if (entry := entity_registry.async_get(entity_id)) is None: - raise EntityStoreException(f"Entity not found: {entity_id}") + raise ConfigStoreException(f"Entity not found: {entity_id}") try: - del self.data[entry.domain][entry.unique_id] + del self.data["entities"][entry.domain][entry.unique_id] except KeyError as err: - raise EntityStoreException( + raise ConfigStoreException( f"Entity not found in {entry.domain}: {entry.unique_id}" ) from err try: @@ -121,5 +131,5 @@ def get_entity_entries(self) -> list[er.RegistryEntry]: ] -class EntityStoreException(Exception): - """KNX entity store exception.""" +class ConfigStoreException(Exception): + """KNX config store exception.""" diff --git a/homeassistant/components/knx/helpers/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py similarity index 100% rename from homeassistant/components/knx/helpers/entity_store_schema.py rename to homeassistant/components/knx/storage/entity_store_schema.py diff --git a/homeassistant/components/knx/helpers/keyring.py b/homeassistant/components/knx/storage/keyring.py similarity index 100% rename from homeassistant/components/knx/helpers/keyring.py rename to homeassistant/components/knx/storage/keyring.py diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 28e3d2a5c9b348..c0eaf59eb9c456 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -45,7 +45,7 @@ async def async_setup_entry( for entity_config in yaml_config ) ui_config: dict[str, ConfigType] | None - if ui_config := knx_module.entity_store.data.get(Platform.SWITCH): + if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH): async_add_entities( KnxUiSwitch(knx_module, unique_id, config) for unique_id, config in ui_config.items() @@ -56,7 +56,7 @@ def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None: """Add KNX entity at runtime.""" async_add_entities([KnxUiSwitch(knx_module, unique_id, config)]) - knx_module.entity_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch + knx_module.config_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity): @@ -131,4 +131,4 @@ def __init__( self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) self._attr_has_entity_name = True - knx_module.entity_store.entities[unique_id] = self + knx_module.config_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index c2f6610435f51a..7df0dea3a91f2f 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -16,8 +16,8 @@ from homeassistant.util.uuid import random_uuid_hex from .const import DOMAIN -from .helpers.entity_store import EntityStoreException -from .helpers.entity_store_schema import ( +from .storage.config_store import ConfigStoreException +from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, ENTITY_STORE_DATA_SCHEMA, UPDATE_ENTITY_BASE_SCHEMA, @@ -251,8 +251,8 @@ async def ws_create_entity( """Create entity in entity store and load it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.entity_store.create_entitiy(msg["platform"], msg["data"]) - except EntityStoreException as err: + await knx.config_store.create_entitiy(msg["platform"], msg["data"]) + except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) @@ -281,10 +281,10 @@ async def ws_update_entity( """Update entity in entity store and reload it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.entity_store.update_entity( + await knx.config_store.update_entity( msg["platform"], msg["unique_id"], msg["data"] ) - except EntityStoreException as err: + except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) @@ -308,8 +308,8 @@ async def ws_delete_entity( """Delete entity from entity store and remove it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.entity_store.delete_entity(msg["entity_id"]) - except EntityStoreException as err: + await knx.config_store.delete_entity(msg["entity_id"]) + except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) @@ -332,7 +332,7 @@ def ws_get_entity_entries( """Get entities configured from entity store.""" knx: KNXModule = hass.data[DOMAIN] entity_entries = [ - entry.extended_dict for entry in knx.entity_store.get_entity_entries() + entry.extended_dict for entry in knx.config_store.get_entity_entries() ] connection.send_result(msg["id"], entity_entries) @@ -353,7 +353,7 @@ def ws_get_entity_config( """Get entity configuration from entity store.""" knx: KNXModule = hass.data[DOMAIN] try: - config = knx.entity_store.get_entity_config(msg["entity_id"]) + config = knx.config_store.get_entity_config(msg["entity_id"]) except KeyError: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Entity not found." From 13c28e28e704ac13a0a4fabdf4c5e3f4a232bf41 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 22 Dec 2023 16:11:00 +0100 Subject: [PATCH 08/51] fix test after rename --- tests/components/knx/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index f12a57f97ba1ca..3dad9320e2131d 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -76,10 +76,10 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): """Patch file upload. Yields the Keyring instance (return_value).""" with ( patch( - "homeassistant.components.knx.helpers.keyring.process_uploaded_file" + "homeassistant.components.knx.storage.keyring.process_uploaded_file" ) as file_upload_mock, patch( - "homeassistant.components.knx.helpers.keyring.sync_load_keyring", + "homeassistant.components.knx.storage.keyring.sync_load_keyring", return_value=return_value, side_effect=side_effect, ), From 5580d58aac398a6dc9076d1d09f262feebcdad16 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 22 Dec 2023 23:59:54 +0100 Subject: [PATCH 09/51] Send schema options for creating / editing entities --- .../components/knx/storage/config_store.py | 2 ++ .../knx/storage/entity_store_schema.py | 23 ++++++++++++---- homeassistant/components/knx/switch.py | 8 +++--- homeassistant/components/knx/websocket.py | 26 +++++++++++++++++-- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index a739b243561166..9b30c160d07327 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -11,6 +11,7 @@ from homeassistant.util.uuid import random_uuid_hex from ..const import DOMAIN +from .entity_store_schema import SCHEMA_OPTIONS if TYPE_CHECKING: from ..knx_entity import KnxEntity @@ -83,6 +84,7 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: "platform": entry.domain, "unique_id": entry.unique_id, "data": self.data["entities"][entry.domain][entry.unique_id], + "schema_options": SCHEMA_OPTIONS.get(entry.domain), } except KeyError as err: raise ConfigStoreException(f"Entity data not found: {entity_id}") from err diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 5c0e067d0a298a..8dfecceef1e807 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,6 +3,7 @@ from homeassistant.components.switch import ( DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, + SwitchDeviceClass, ) from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv @@ -21,21 +22,29 @@ vol.Optional("entity_category", default=None): vol.Any( ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) ), - vol.Optional("sync_state", default=True): sync_state_validator, + vol.Optional("device_class", default=None): vol.Maybe( + str + ), # overwrite in platform schema } ) -SWITCH_SCHEMA = BASE_ENTITY_SCHEMA.extend( +SWITCH_SCHEMA = vol.Schema( { - vol.Optional("device_class", default=None): vol.Maybe( - SWITCH_DEVICE_CLASSES_SCHEMA + vol.Required("entity"): BASE_ENTITY_SCHEMA.extend( + { + vol.Optional("device_class", default=None): vol.Maybe( + SWITCH_DEVICE_CLASSES_SCHEMA + ), + } ), vol.Optional("invert", default=False): bool, vol.Required("switch_address"): ga_list_validator, vol.Required("switch_state_address"): ga_list_validator_optional, vol.Optional("respond_to_read", default=False): bool, + vol.Optional("sync_state", default=True): sync_state_validator, } ) +SWITCH_SCHEMA_OPTIONS = {"entity": {"device_class": list(SwitchDeviceClass)}} ENTITY_STORE_DATA_SCHEMA = vol.All( vol.Schema( @@ -57,10 +66,14 @@ CREATE_ENTITY_BASE_SCHEMA = { vol.Required("platform"): str, - vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA + vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA with vol.All() } UPDATE_ENTITY_BASE_SCHEMA = { vol.Required("unique_id"): str, **CREATE_ENTITY_BASE_SCHEMA, } + +SCHEMA_OPTIONS: dict[str, dict] = { + Platform.SWITCH: SWITCH_SCHEMA_OPTIONS, +} diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c0eaf59eb9c456..0450450f5a48ec 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -117,17 +117,17 @@ def __init__( super().__init__( device=XknxSwitch( knx_module.xknx, - name=config[CONF_NAME], + name=config["entity"][CONF_NAME], group_address=config["switch_address"], group_address_state=config["switch_state_address"], respond_to_read=config[CONF_RESPOND_TO_READ], invert=config["invert"], ) ) - self._attr_entity_category = config[CONF_ENTITY_CATEGORY] - self._attr_device_class = config[CONF_DEVICE_CLASS] + self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] + self._attr_device_class = config["entity"][CONF_DEVICE_CLASS] self._attr_unique_id = unique_id - if device_info := config.get("device_info"): + if device_info := config["entity"].get("device_info"): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) self._attr_has_entity_name = True diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 7df0dea3a91f2f..fb93826b7086f9 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -20,6 +20,7 @@ from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, ENTITY_STORE_DATA_SCHEMA, + SCHEMA_OPTIONS, UPDATE_ENTITY_BASE_SCHEMA, ) from .telegrams import TelegramDict @@ -45,6 +46,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entity_entries) websocket_api.async_register_command(hass, ws_create_device) + websocket_api.async_register_command(hass, ws_get_platform_schema_options) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -353,13 +355,13 @@ def ws_get_entity_config( """Get entity configuration from entity store.""" knx: KNXModule = hass.data[DOMAIN] try: - config = knx.config_store.get_entity_config(msg["entity_id"]) + config_info = knx.config_store.get_entity_config(msg["entity_id"]) except KeyError: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Entity not found." ) return - connection.send_result(msg["id"], config) + connection.send_result(msg["id"], config_info) @websocket_api.require_admin @@ -392,3 +394,23 @@ def ws_create_device( configuration_url=f"homeassistant://knx/entities/view?device_id={_device.id}", ) connection.send_result(msg["id"], _device.dict_repr) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_platform_schema_options", + vol.Required("platform"): str, + } +) +@callback +def ws_get_platform_schema_options( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Send schema options for a platform entity configuration.""" + connection.send_result( + msg["id"], + result=SCHEMA_OPTIONS.get(msg["platform"]), + ) From 88611a5090edd9f5e056b0838d8bceb43463238e Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 23 Dec 2023 10:21:31 +0100 Subject: [PATCH 10/51] Return entity_id after entity creation --- homeassistant/components/knx/storage/config_store.py | 7 ++++++- homeassistant/components/knx/websocket.py | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 9b30c160d07327..4367b75884f537 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -61,7 +61,9 @@ async def load_data(self) -> None: len(self.data["entities"]), ) - async def create_entitiy(self, platform: Platform, data: dict[str, Any]) -> None: + async def create_entitiy( + self, platform: Platform, data: dict[str, Any] + ) -> str | None: """Create a new entity.""" if platform not in self.async_add_entity: raise ConfigStoreException(f"Entity platform not ready: {platform}") @@ -73,6 +75,9 @@ async def create_entitiy(self, platform: Platform, data: dict[str, Any]) -> None self.data["entities"][platform][unique_id] = data await self._store.async_save(self.data) + entity_registry = er.async_get(self.hass) + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + @callback def get_entity_config(self, entity_id: str) -> dict[str, Any]: """Return KNX entity configuration.""" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index fb93826b7086f9..99cb9eb606acdb 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -253,13 +253,13 @@ async def ws_create_entity( """Create entity in entity store and load it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.config_store.create_entitiy(msg["platform"], msg["data"]) + entity_id = await knx.config_store.create_entitiy(msg["platform"], msg["data"]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return - connection.send_result(msg["id"]) + connection.send_result(msg["id"], entity_id) @websocket_api.require_admin From 1cf08e58df3f7a5b19a1c02c0063e32b738430e2 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 23 Dec 2023 22:47:41 +0100 Subject: [PATCH 11/51] remove device_class config in favour of more-info-dialog settings --- .../knx/storage/entity_store_schema.py | 20 ++----------------- homeassistant/components/knx/switch.py | 1 - 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 8dfecceef1e807..d695a6fd14b385 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,10 +1,6 @@ """KNX entity store schema.""" import voluptuous as vol -from homeassistant.components.switch import ( - DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, - SwitchDeviceClass, -) from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA @@ -22,21 +18,12 @@ vol.Optional("entity_category", default=None): vol.Any( ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) ), - vol.Optional("device_class", default=None): vol.Maybe( - str - ), # overwrite in platform schema } ) SWITCH_SCHEMA = vol.Schema( { - vol.Required("entity"): BASE_ENTITY_SCHEMA.extend( - { - vol.Optional("device_class", default=None): vol.Maybe( - SWITCH_DEVICE_CLASSES_SCHEMA - ), - } - ), + vol.Required("entity"): BASE_ENTITY_SCHEMA, vol.Optional("invert", default=False): bool, vol.Required("switch_address"): ga_list_validator, vol.Required("switch_state_address"): ga_list_validator_optional, @@ -44,7 +31,6 @@ vol.Optional("sync_state", default=True): sync_state_validator, } ) -SWITCH_SCHEMA_OPTIONS = {"entity": {"device_class": list(SwitchDeviceClass)}} ENTITY_STORE_DATA_SCHEMA = vol.All( vol.Schema( @@ -74,6 +60,4 @@ **CREATE_ENTITY_BASE_SCHEMA, } -SCHEMA_OPTIONS: dict[str, dict] = { - Platform.SWITCH: SWITCH_SCHEMA_OPTIONS, -} +SCHEMA_OPTIONS: dict[str, dict] = {} diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 0450450f5a48ec..6e1defd67facd8 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -125,7 +125,6 @@ def __init__( ) ) self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] - self._attr_device_class = config["entity"][CONF_DEVICE_CLASS] self._attr_unique_id = unique_id if device_info := config["entity"].get("device_info"): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) From f50925d7191ed5376ee8377168b69a689e08bbe8 Mon Sep 17 00:00:00 2001 From: farmio Date: Wed, 27 Dec 2023 16:45:16 +0100 Subject: [PATCH 12/51] refactor group address schema for custom selector --- .../knx/storage/entity_store_schema.py | 36 +++++++++++++++---- homeassistant/components/knx/switch.py | 7 ++-- homeassistant/components/knx/validation.py | 8 +++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index d695a6fd14b385..8742a87bd205e0 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -5,11 +5,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA -from ..validation import ( - ga_list_validator, - ga_list_validator_optional, - sync_state_validator, -) +from ..validation import ga_validator, sync_state_validator BASE_ENTITY_SCHEMA = vol.Schema( { @@ -21,12 +17,38 @@ } ) + +def ga_schema( + send: bool = True, + read: bool = True, + passive: bool = True, + send_required: bool = False, + read_required: bool = False, +) -> vol.Schema: + """Return a schema for a knx group address selector.""" + schema = {} + _send_marker = vol.Required if send and send_required else vol.Optional + schema[_send_marker("send", default=None)] = ( + None if not read else ga_validator if send_required else vol.Maybe(ga_validator) + ) + _read_marker = vol.Required if read and read_required else vol.Optional + schema[_read_marker("read", default=None)] = ( + None if not read else ga_validator if read_required else vol.Maybe(ga_validator) + ) + schema[vol.Optional("passive", default=None)] = vol.All( + vol.Maybe([ga_validator]) if passive else vol.Any(None, []), + vol.Any( # Coerce `None` to an empty list if passive is allowed + vol.IsTrue(), vol.SetTo([]) + ), + ) + return vol.Schema(schema) + + SWITCH_SCHEMA = vol.Schema( { vol.Required("entity"): BASE_ENTITY_SCHEMA, vol.Optional("invert", default=False): bool, - vol.Required("switch_address"): ga_list_validator, - vol.Required("switch_state_address"): ga_list_validator_optional, + vol.Required("ga_switch"): ga_schema(send_required=True), vol.Optional("respond_to_read", default=False): bool, vol.Optional("sync_state", default=True): sync_state_validator, } diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 6e1defd67facd8..f02e6205de2146 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -118,8 +118,11 @@ def __init__( device=XknxSwitch( knx_module.xknx, name=config["entity"][CONF_NAME], - group_address=config["switch_address"], - group_address_state=config["switch_state_address"], + group_address=config["ga_switch"]["send"], + group_address_state=[ + config["ga_switch"]["read"], + *config["ga_switch"]["passive"], + ], respond_to_read=config[CONF_RESPOND_TO_READ], invert=config["invert"], ) diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 4e56314a6770c2..a12d7f2d762369 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -56,6 +56,14 @@ def ga_validator(value: Any) -> str | int: vol.IsTrue("value must be a group address or a list containing group addresses"), ) +ga_list_validator_optional = vol.Maybe( + vol.All( + cv.ensure_list, + [ga_validator], + vol.Any(vol.IsTrue(), vol.SetTo(None)), # avoid empty lists -> None + ) +) + ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), From e21bd0c0d73b2aa4882ff67971e198cd94c83954 Mon Sep 17 00:00:00 2001 From: farmio Date: Wed, 27 Dec 2023 23:30:25 +0100 Subject: [PATCH 13/51] Rename GA keys and remove invalid keys from schema --- .../knx/storage/entity_store_schema.py | 47 +++++++++++-------- homeassistant/components/knx/switch.py | 4 +- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 8742a87bd205e0..2c8d91de9f8689 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -19,28 +19,37 @@ def ga_schema( - send: bool = True, - read: bool = True, + write: bool = True, + state: bool = True, passive: bool = True, - send_required: bool = False, - read_required: bool = False, + write_required: bool = False, + state_required: bool = False, ) -> vol.Schema: """Return a schema for a knx group address selector.""" schema = {} - _send_marker = vol.Required if send and send_required else vol.Optional - schema[_send_marker("send", default=None)] = ( - None if not read else ga_validator if send_required else vol.Maybe(ga_validator) - ) - _read_marker = vol.Required if read and read_required else vol.Optional - schema[_read_marker("read", default=None)] = ( - None if not read else ga_validator if read_required else vol.Maybe(ga_validator) - ) - schema[vol.Optional("passive", default=None)] = vol.All( - vol.Maybe([ga_validator]) if passive else vol.Any(None, []), - vol.Any( # Coerce `None` to an empty list if passive is allowed - vol.IsTrue(), vol.SetTo([]) - ), - ) + if write: + _write_marker = vol.Required if write_required else vol.Optional + schema[_write_marker("write", default=None)] = ( + ga_validator if write_required else vol.Maybe(ga_validator) + ) + else: + schema[vol.Remove("write")] = object + if state: + _state_marker = vol.Required if state_required else vol.Optional + schema[_state_marker("state", default=None)] = ( + ga_validator if state_required else vol.Maybe(ga_validator) + ) + else: + schema[vol.Remove("state")] = object + if passive: + schema[vol.Optional("passive", default=list)] = vol.Any( + [ga_validator], + vol.All( # Coerce `None` to an empty list if passive is allowed + vol.IsFalse(), vol.SetTo(list) + ), + ) + else: + schema[vol.Remove("passive")] = object return vol.Schema(schema) @@ -48,7 +57,7 @@ def ga_schema( { vol.Required("entity"): BASE_ENTITY_SCHEMA, vol.Optional("invert", default=False): bool, - vol.Required("ga_switch"): ga_schema(send_required=True), + vol.Required("ga_switch"): ga_schema(write_required=True), vol.Optional("respond_to_read", default=False): bool, vol.Optional("sync_state", default=True): sync_state_validator, } diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index f02e6205de2146..ba3efad8f47be3 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -118,9 +118,9 @@ def __init__( device=XknxSwitch( knx_module.xknx, name=config["entity"][CONF_NAME], - group_address=config["ga_switch"]["send"], + group_address=config["ga_switch"]["write"], group_address_state=[ - config["ga_switch"]["read"], + config["ga_switch"]["state"], *config["ga_switch"]["passive"], ], respond_to_read=config[CONF_RESPOND_TO_READ], From eb77652daede269a09926e0b3e77452e52b06153 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 18 Jan 2024 16:43:24 +0100 Subject: [PATCH 14/51] fix rebase --- homeassistant/components/knx/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 736559b96c11f6..2822a5a9c861b6 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -328,10 +328,8 @@ def __init__( ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self._knx_event_callback: TelegramQueue.Callback = ( - self.register_event_callback() - ) + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) From 83d54ac982052a8adcb5ba21e9c97fc865609ec6 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 18 Jan 2024 16:43:55 +0100 Subject: [PATCH 15/51] Fix deleting devices and their entities --- homeassistant/components/knx/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 2822a5a9c861b6..0184fbd5661bc5 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -283,12 +283,9 @@ async def async_remove_config_entry_device( ): # can not remove interface device return False - entity_registry = er.async_get(hass) - enitites = er.async_entries_for_device( - entity_registry, device_entry.id, include_disabled_entities=True - ) - for entity in enitites: - await knx_module.config_store.delete_entity(entity.entity_id) + for entity in knx_module.config_store.get_entity_entries(): + if entity.device_id == device_entry.id: + await knx_module.config_store.delete_entity(entity.entity_id) return True From bada30d7274152158bcc84e03b46b5c4c4b1ecd8 Mon Sep 17 00:00:00 2001 From: farmio Date: Sun, 4 Feb 2024 16:39:44 +0100 Subject: [PATCH 16/51] Validate entity schema in extra step - return validation infos --- .../knx/storage/entity_store_schema.py | 18 +++--- .../knx/storage/entity_store_validation.py | 55 +++++++++++++++++++ homeassistant/components/knx/websocket.py | 24 ++++---- 3 files changed, 80 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/knx/storage/entity_store_validation.py diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 2c8d91de9f8689..7ece0344b7a418 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,4 +1,6 @@ """KNX entity store schema.""" +from typing import Any + import voluptuous as vol from homeassistant.const import Platform @@ -26,18 +28,20 @@ def ga_schema( state_required: bool = False, ) -> vol.Schema: """Return a schema for a knx group address selector.""" - schema = {} + schema: dict[vol.Marker, Any] = {} if write: - _write_marker = vol.Required if write_required else vol.Optional - schema[_write_marker("write", default=None)] = ( - ga_validator if write_required else vol.Maybe(ga_validator) + schema |= ( + {vol.Required("write"): ga_validator} + if write_required + else {vol.Optional("write", default=None): vol.Maybe(ga_validator)} ) else: schema[vol.Remove("write")] = object if state: - _state_marker = vol.Required if state_required else vol.Optional - schema[_state_marker("state", default=None)] = ( - ga_validator if state_required else vol.Maybe(ga_validator) + schema |= ( + {vol.Required("state"): ga_validator} + if state_required + else {vol.Optional("state", default=None): vol.Maybe(ga_validator)} ) else: schema[vol.Remove("state")] = object diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py new file mode 100644 index 00000000000000..33a95d0b3e7156 --- /dev/null +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -0,0 +1,55 @@ +"""KNX Entity Store Validation.""" +from typing import Literal, TypedDict + +import voluptuous as vol + +from .entity_store_schema import ENTITY_STORE_DATA_SCHEMA + + +class _ErrorDescription(TypedDict): + path: list[str] | None + error_message: str + error_class: str + + +class EntityStoreValidationError(TypedDict): + """Negative entity store validation result.""" + + success: Literal[False] + error_base: str + errors: list[_ErrorDescription] + + +class EntityStoreValidationSuccess(TypedDict): + """Positive entity store validation result.""" + + success: Literal[True] + entity_id: str | None + + +def parse_invalid(exc: vol.Invalid) -> _ErrorDescription: + """Parse a vol.Invalid exception.""" + return _ErrorDescription( + path=[str(path) for path in exc.path], # exc.path: str | vol.Required + error_message=exc.msg, + error_class=type(exc).__name__, + ) + + +def validate_entity_data(entity_data: dict) -> EntityStoreValidationError | None: + """Validate entity data. Return `None` if valid.""" + try: + ENTITY_STORE_DATA_SCHEMA(entity_data) + except vol.MultipleInvalid as exc: + return { + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(invalid) for invalid in exc.errors], + } + except vol.Invalid as exc: + return { + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(exc)], + } + return None diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 99cb9eb606acdb..8d50a0ce1cad33 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -23,6 +23,10 @@ SCHEMA_OPTIONS, UPDATE_ENTITY_BASE_SCHEMA, ) +from .storage.entity_store_validation import ( + EntityStoreValidationSuccess, + validate_entity_data, +) from .telegrams import TelegramDict if TYPE_CHECKING: @@ -234,15 +238,10 @@ def forward_telegram(telegram: TelegramDict) -> None: @websocket_api.require_admin @websocket_api.websocket_command( - vol.All( - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): "knx/create_entity", - **CREATE_ENTITY_BASE_SCHEMA, - } - ), - ENTITY_STORE_DATA_SCHEMA, - ) + { + vol.Required("type"): "knx/create_entity", + **CREATE_ENTITY_BASE_SCHEMA, + } ) @websocket_api.async_response async def ws_create_entity( @@ -251,6 +250,9 @@ async def ws_create_entity( msg: dict, ) -> None: """Create entity in entity store and load it.""" + if (validation_error := validate_entity_data(msg)) is not None: + connection.send_result(msg["id"], validation_error) + return knx: KNXModule = hass.data[DOMAIN] try: entity_id = await knx.config_store.create_entitiy(msg["platform"], msg["data"]) @@ -259,7 +261,9 @@ async def ws_create_entity( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return - connection.send_result(msg["id"], entity_id) + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=entity_id) + ) @websocket_api.require_admin From 8e8b68b721ef388f78b9a811ded1205e398661cb Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 16 Feb 2024 21:25:34 +0100 Subject: [PATCH 17/51] Use exception to signal validation error; return validated data --- .../knx/storage/entity_store_validation.py | 41 ++++++++++++------- homeassistant/components/knx/websocket.py | 13 ++++-- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index 33a95d0b3e7156..443744b96217da 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -36,20 +36,33 @@ def parse_invalid(exc: vol.Invalid) -> _ErrorDescription: ) -def validate_entity_data(entity_data: dict) -> EntityStoreValidationError | None: - """Validate entity data. Return `None` if valid.""" +def validate_entity_data(entity_data: dict) -> dict: + """Validate entity data. Return validated data or raise EntityStoreValidationException.""" try: - ENTITY_STORE_DATA_SCHEMA(entity_data) + # return so defaults are applied + return ENTITY_STORE_DATA_SCHEMA(entity_data) # type: ignore[no-any-return] except vol.MultipleInvalid as exc: - return { - "success": False, - "error_base": str(exc), - "errors": [parse_invalid(invalid) for invalid in exc.errors], - } + raise EntityStoreValidationException( + validation_error={ + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(invalid) for invalid in exc.errors], + } + ) from exc except vol.Invalid as exc: - return { - "success": False, - "error_base": str(exc), - "errors": [parse_invalid(exc)], - } - return None + raise EntityStoreValidationException( + validation_error={ + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(exc)], + } + ) from exc + + +class EntityStoreValidationException(Exception): + """Entity store validation exception.""" + + def __init__(self, validation_error: EntityStoreValidationError) -> None: + """Initialize.""" + super().__init__(validation_error) + self.validation_error = validation_error diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 8d50a0ce1cad33..166bddf00fd7a5 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -24,6 +24,7 @@ UPDATE_ENTITY_BASE_SCHEMA, ) from .storage.entity_store_validation import ( + EntityStoreValidationException, EntityStoreValidationSuccess, validate_entity_data, ) @@ -250,12 +251,18 @@ async def ws_create_entity( msg: dict, ) -> None: """Create entity in entity store and load it.""" - if (validation_error := validate_entity_data(msg)) is not None: - connection.send_result(msg["id"], validation_error) + try: + validated_data = validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) return knx: KNXModule = hass.data[DOMAIN] try: - entity_id = await knx.config_store.create_entitiy(msg["platform"], msg["data"]) + entity_id = await knx.config_store.create_entitiy( + # use validation result so defaults are applied + validated_data["platform"], + validated_data["data"], + ) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) From e1ee4d221d9c849e49de6da7b75b81aa6ed03c51 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 16 Feb 2024 21:56:07 +0100 Subject: [PATCH 18/51] Forward validation result when editing entities --- .../knx/storage/entity_store_schema.py | 1 + homeassistant/components/knx/websocket.py | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7ece0344b7a418..f4d57eaa0b7f8a 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,6 +11,7 @@ BASE_ENTITY_SCHEMA = vol.Schema( { + # TODO: name shall be required when no device_info is given vol.Optional("name", default=None): vol.Maybe(str), vol.Optional("device_info", default=None): vol.Maybe(str), vol.Optional("entity_category", default=None): vol.Any( diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 166bddf00fd7a5..d52e313de2e2c6 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -19,7 +19,6 @@ from .storage.config_store import ConfigStoreException from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, - ENTITY_STORE_DATA_SCHEMA, SCHEMA_OPTIONS, UPDATE_ENTITY_BASE_SCHEMA, ) @@ -275,15 +274,10 @@ async def ws_create_entity( @websocket_api.require_admin @websocket_api.websocket_command( - vol.All( - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): "knx/update_entity", - **UPDATE_ENTITY_BASE_SCHEMA, - } - ), - ENTITY_STORE_DATA_SCHEMA, - ) + { + vol.Required("type"): "knx/update_entity", + **UPDATE_ENTITY_BASE_SCHEMA, + } ) @websocket_api.async_response async def ws_update_entity( @@ -292,17 +286,26 @@ async def ws_update_entity( msg: dict, ) -> None: """Update entity in entity store and reload it.""" + try: + validated_data = validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return knx: KNXModule = hass.data[DOMAIN] try: await knx.config_store.update_entity( - msg["platform"], msg["unique_id"], msg["data"] + validated_data["platform"], + validated_data["unique_id"], + validated_data["data"], ) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return - connection.send_result(msg["id"]) + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) @websocket_api.require_admin From fac7d614df34e0c1d928c9f8bb5afd49823c1869 Mon Sep 17 00:00:00 2001 From: farmio Date: Sun, 18 Feb 2024 10:28:22 +0100 Subject: [PATCH 19/51] Get proper validation error message for optional GAs --- .../knx/storage/entity_store_schema.py | 32 +++++++++---------- homeassistant/components/knx/validation.py | 7 ++++ 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index f4d57eaa0b7f8a..a8bec5f14fa0bd 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -7,7 +7,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA -from ..validation import ga_validator, sync_state_validator +from ..validation import ga_validator, maybe_ga_validator, sync_state_validator BASE_ENTITY_SCHEMA = vol.Schema( { @@ -30,22 +30,20 @@ def ga_schema( ) -> vol.Schema: """Return a schema for a knx group address selector.""" schema: dict[vol.Marker, Any] = {} - if write: - schema |= ( - {vol.Required("write"): ga_validator} - if write_required - else {vol.Optional("write", default=None): vol.Maybe(ga_validator)} - ) - else: - schema[vol.Remove("write")] = object - if state: - schema |= ( - {vol.Required("state"): ga_validator} - if state_required - else {vol.Optional("state", default=None): vol.Maybe(ga_validator)} - ) - else: - schema[vol.Remove("state")] = object + + def add_ga_item(key: str, allowed: bool, required: bool) -> None: + """Add a group address item to the schema.""" + if not allowed: + schema[vol.Remove(key)] = object + return + if required: + schema[vol.Required(key)] = ga_validator + else: + schema[vol.Optional(key, default=None)] = maybe_ga_validator + + add_ga_item("write", write, write_required) + add_ga_item("state", state, state_required) + if passive: schema[vol.Optional("passive", default=list)] = vol.Any( [ga_validator], diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index a12d7f2d762369..9ed4f32c92065a 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -50,6 +50,13 @@ def ga_validator(value: Any) -> str | int: return value +def maybe_ga_validator(value: Any) -> str | int | None: + """Validate a group address or None.""" + # this is a version of vol.Maybe(ga_validator) that delivers the + # error message of ga_validator if validation fails. + return ga_validator(value) if value is not None else None + + ga_list_validator = vol.All( cv.ensure_list, [ga_validator], From efb54f905657acbfad35d1c8f80002c88853271d Mon Sep 17 00:00:00 2001 From: farmio Date: Mon, 19 Feb 2024 09:03:51 +0100 Subject: [PATCH 20/51] Add entity validation only WS command --- homeassistant/components/knx/websocket.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index d52e313de2e2c6..617bd6e71a801c 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -44,6 +44,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) websocket_api.async_register_command(hass, ws_get_knx_project) + websocket_api.async_register_command(hass, ws_validate_entity) websocket_api.async_register_command(hass, ws_create_entity) websocket_api.async_register_command(hass, ws_update_entity) websocket_api.async_register_command(hass, ws_delete_entity) @@ -236,6 +237,30 @@ def forward_telegram(telegram: TelegramDict) -> None: connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/validate_entity", + **CREATE_ENTITY_BASE_SCHEMA, + } +) +@callback +def ws_validate_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Validate entity data.""" + try: + validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { From ee30f59e59abd8d2bfb7cf26ee64aa4a9120ff30 Mon Sep 17 00:00:00 2001 From: farmio Date: Tue, 20 Feb 2024 22:02:34 +0100 Subject: [PATCH 21/51] use ulid instead of uuid --- homeassistant/components/knx/storage/config_store.py | 4 ++-- homeassistant/components/knx/websocket.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 4367b75884f537..a6ced30cc75582 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store -from homeassistant.util.uuid import random_uuid_hex +from homeassistant.util.ulid import ulid_now from ..const import DOMAIN from .entity_store_schema import SCHEMA_OPTIONS @@ -67,7 +67,7 @@ async def create_entitiy( """Create a new entity.""" if platform not in self.async_add_entity: raise ConfigStoreException(f"Entity platform not ready: {platform}") - unique_id = f"knx_es_{random_uuid_hex()}" + unique_id = f"knx_es_{ulid_now()}" if unique_id in self.data["entities"].setdefault(platform, {}): raise ConfigStoreException("Unique id already used.") self.async_add_entity[platform](unique_id, data) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 617bd6e71a801c..cee4cd9e26e1c3 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import UNDEFINED -from homeassistant.util.uuid import random_uuid_hex +from homeassistant.util.ulid import ulid_now from .const import DOMAIN from .storage.config_store import ConfigStoreException @@ -419,7 +419,7 @@ def ws_create_device( ) -> None: """Create a new KNX device.""" knx: KNXModule = hass.data[DOMAIN] - identifier = f"knx_vdev_{random_uuid_hex()}" + identifier = f"knx_vdev_{ulid_now()}" device_registry = dr.async_get(hass) _device = device_registry.async_get_or_create( config_entry_id=knx.entry.entry_id, From 13738b1578129c801777b76fbc7f6aa9f0ac8c7d Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 23 Feb 2024 22:33:08 +0100 Subject: [PATCH 22/51] Fix error handling for edit unknown entity --- homeassistant/components/knx/websocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index cee4cd9e26e1c3..f675a05159c61c 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -395,9 +395,9 @@ def ws_get_entity_config( knx: KNXModule = hass.data[DOMAIN] try: config_info = knx.config_store.get_entity_config(msg["entity_id"]) - except KeyError: + except ConfigStoreException as err: connection.send_error( - msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Entity not found." + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result(msg["id"], config_info) From 804ea00f9ce1f4a92ddaaabf907573be3c233852 Mon Sep 17 00:00:00 2001 From: farmio Date: Tue, 27 Feb 2024 11:16:19 +0100 Subject: [PATCH 23/51] Remove unused optional group address sets from validated schema --- .../knx/storage/entity_store_schema.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index a8bec5f14fa0bd..955be95b2629e0 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -56,6 +56,26 @@ def add_ga_item(key: str, allowed: bool, required: bool) -> None: return vol.Schema(schema) +def optional_ga_schema( + key: str, ga_schema_validator: vol.Schema +) -> dict[vol.Marker, vol.Schema]: + """Validate group address schema or remove key if no address is set.""" + # frontend will return {key: {"write": None, "state": None}} for unused GA sets + # -> remove this entirely for optional keys + # if one GA is set, validate as usual + return { + vol.Optional(key): ga_schema_validator, + vol.Remove(key): vol.Schema( + { + vol.Optional("write"): None, + vol.Optional("state"): None, + vol.Optional("passive"): vol.IsFalse(), # None or empty list + }, + extra=vol.ALLOW_EXTRA, + ), + } + + SWITCH_SCHEMA = vol.Schema( { vol.Required("entity"): BASE_ENTITY_SCHEMA, From 6a9a76cc31ce740cfeb32a6fa04ad71b7ab0ed7b Mon Sep 17 00:00:00 2001 From: farmio Date: Tue, 27 Feb 2024 15:00:37 +0100 Subject: [PATCH 24/51] Add optional dpt field for ga_schema --- .../components/knx/storage/entity_store_schema.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 955be95b2629e0..c5eaffcb09acb9 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,4 +1,5 @@ """KNX entity store schema.""" +from enum import Enum from typing import Any import voluptuous as vol @@ -27,6 +28,7 @@ def ga_schema( passive: bool = True, write_required: bool = False, state_required: bool = False, + dpt: type[Enum] | None = None, ) -> vol.Schema: """Return a schema for a knx group address selector.""" schema: dict[vol.Marker, Any] = {} @@ -53,6 +55,12 @@ def add_ga_item(key: str, allowed: bool, required: bool) -> None: ) else: schema[vol.Remove("passive")] = object + + if dpt is not None: + schema[vol.Required("dpt")] = vol.In(dpt) + else: + schema[vol.Remove("dpt")] = object + return vol.Schema(schema) From caaa0a1185a63cfddc73f755831ff8d3a9d9e74d Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 9 Mar 2024 22:24:46 +0100 Subject: [PATCH 25/51] Move knx config things to sub-key --- .../knx/storage/entity_store_schema.py | 29 +++++++++++++++---- homeassistant/components/knx/switch.py | 10 +++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index c5eaffcb09acb9..e561cb941bd7d5 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -10,15 +10,29 @@ from ..validation import ga_validator, maybe_ga_validator, sync_state_validator -BASE_ENTITY_SCHEMA = vol.Schema( +BASE_ENTITY_SCHEMA = vol.All( { - # TODO: name shall be required when no device_info is given vol.Optional("name", default=None): vol.Maybe(str), vol.Optional("device_info", default=None): vol.Maybe(str), vol.Optional("entity_category", default=None): vol.Any( ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) ), - } + }, + vol.Any( + vol.Schema( + { + vol.Required("name"): str, + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required("device_info"): str, + }, + extra=vol.ALLOW_EXTRA, + ), + msg="One of `Device` or `Name` is required", + ), ) @@ -87,9 +101,12 @@ def optional_ga_schema( SWITCH_SCHEMA = vol.Schema( { vol.Required("entity"): BASE_ENTITY_SCHEMA, - vol.Optional("invert", default=False): bool, - vol.Required("ga_switch"): ga_schema(write_required=True), - vol.Optional("respond_to_read", default=False): bool, + vol.Required("knx"): { + vol.Optional("invert", default=False): bool, + vol.Required("ga_switch"): ga_schema(write_required=True), + vol.Optional("respond_to_read", default=False): bool, + vol.Optional("sync_state", default=True): sync_state_validator, + }, vol.Optional("sync_state", default=True): sync_state_validator, } ) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index ba3efad8f47be3..c1c54095f9310f 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -118,13 +118,13 @@ def __init__( device=XknxSwitch( knx_module.xknx, name=config["entity"][CONF_NAME], - group_address=config["ga_switch"]["write"], + group_address=config["knx"]["ga_switch"]["write"], group_address_state=[ - config["ga_switch"]["state"], - *config["ga_switch"]["passive"], + config["knx"]["ga_switch"]["state"], + *config["knx"]["ga_switch"]["passive"], ], - respond_to_read=config[CONF_RESPOND_TO_READ], - invert=config["invert"], + respond_to_read=config["knx"][CONF_RESPOND_TO_READ], + invert=config["knx"]["invert"], ) ) self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] From 43b5514f24186caf96e80153fc8a82dd6cbf430a Mon Sep 17 00:00:00 2001 From: farmio Date: Wed, 27 Mar 2024 22:30:42 +0100 Subject: [PATCH 26/51] Add light platform --- homeassistant/components/knx/__init__.py | 32 +-- homeassistant/components/knx/const.py | 13 +- homeassistant/components/knx/light.py | 198 ++++++++++++++++-- .../components/knx/storage/config_store.py | 3 +- .../knx/storage/entity_store_schema.py | 104 ++++++++- .../knx/storage/entity_store_validation.py | 1 + homeassistant/components/knx/websocket.py | 2 +- 7 files changed, 306 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 0184fbd5661bc5..58884cac1039e8 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -63,7 +63,8 @@ DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, - SUPPORTED_PLATFORMS, + SUPPORTED_PLATFORMS_UI, + SUPPORTED_PLATFORMS_YAML, TELEGRAM_LOG_DEFAULT, ) from .device import KNXInterfaceDevice @@ -193,15 +194,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_knx_exposure(hass, knx_module.xknx, expose_config) ) # always forward sensor for system entities (telegram counter, etc.) - await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) - # TODO: forward all platforms to get entity store connected - await hass.config_entries.async_forward_entry_setup(entry, Platform.SWITCH) + # forward all platforms that support UI entity management + await hass.config_entries.async_forward_entry_setups( + entry, {Platform.SENSOR} | SUPPORTED_PLATFORMS_UI + ) + # forward yaml-only managed platforms on demand await hass.config_entries.async_forward_entry_setups( entry, [ platform - for platform in SUPPORTED_PLATFORMS - if platform in config and platform not in (Platform.SENSOR, Platform.SWITCH) + for platform in SUPPORTED_PLATFORMS_YAML + if platform in config + and platform not in SUPPORTED_PLATFORMS_UI | {Platform.SENSOR} ], ) @@ -230,15 +234,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms( entry, - [ + { Platform.SENSOR, # always unload system entities (telegram counter, etc.) - *[ - platform - for platform in SUPPORTED_PLATFORMS - if platform in hass.data[DATA_KNX_CONFIG] - and platform is not Platform.SENSOR - ], - ], + } + | SUPPORTED_PLATFORMS_UI + | { + platform + for platform in SUPPORTED_PLATFORMS_YAML + if platform in hass.data[DATA_KNX_CONFIG] + }, ) if unload_ok: await knx_module.stop() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 6cec901adc79ac..0b7b517dca556f 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -127,12 +127,13 @@ class KNXConfigEntryData(TypedDict, total=False): class ColorTempModes(Enum): """Color temperature modes for config validation.""" - ABSOLUTE = "DPT-7.600" - ABSOLUTE_FLOAT = "DPT-9" - RELATIVE = "DPT-5.001" + # YAML uses Enum.name (with vol.Upper), UI uses Enum.value for lookup + ABSOLUTE = "7.600" + ABSOLUTE_FLOAT = "9" + RELATIVE = "5.001" -SUPPORTED_PLATFORMS: Final = [ +SUPPORTED_PLATFORMS_YAML: Final = { Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, @@ -150,7 +151,9 @@ class ColorTempModes(Enum): Platform.TEXT, Platform.TIME, Platform.WEATHER, -] +} + +SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index b1c1681a817c91..314771c89b7390 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -19,14 +19,17 @@ LightEntity, ) from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util +from . import KNXModule from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes from .knx_entity import KnxEntity from .schema import LightSchema +from .storage.entity_store_schema import LightColorMode async def async_setup_entry( @@ -35,13 +38,30 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up light(s) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.LIGHT] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXLight(xknx, entity_config) for entity_config in config) + yaml_config: list[ConfigType] | None + if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT): + async_add_entities( + KnxYamlLight(knx_module.xknx, entity_config) + for entity_config in yaml_config + ) + ui_config: dict[str, ConfigType] | None + if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT): + async_add_entities( + KnxUiLight(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + + @callback + def add_new_ui_light(unique_id: str, config: dict[str, Any]) -> None: + """Add KNX entity at runtime.""" + async_add_entities([KnxUiLight(knx_module, unique_id, config)]) + knx_module.config_store.async_add_entity[Platform.LIGHT] = add_new_ui_light -def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: + +def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight: """Return a KNX Light device to be used within XKNX.""" def individual_color_addresses(color: str, feature: str) -> Any | None: @@ -151,29 +171,111 @@ def individual_color_addresses(color: str, feature: str) -> Any | None: ) -class KNXLight(KnxEntity, LightEntity): - """Representation of a KNX light.""" +def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight: + """Return a KNX Light device to be used within XKNX.""" - _device: XknxLight + def get_write(key: str) -> str | None: + """Get the write group address.""" + return knx_config[key]["write"] if key in knx_config else None - def __init__(self, xknx: XKNX, config: ConfigType) -> None: - """Initialize of KNX light.""" - super().__init__(_create_light(xknx, config)) - self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] - self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = self._device_unique_id() - - def _device_unique_id(self) -> str: - """Return unique id for this device.""" - if self._device.switch.group_address is not None: - return f"{self._device.switch.group_address}" + def get_state(key: str) -> list[Any] | None: + """Get the state group address.""" return ( - f"{self._device.red.brightness.group_address}_" - f"{self._device.green.brightness.group_address}_" - f"{self._device.blue.brightness.group_address}" + [knx_config[key]["state"], *knx_config[key]["passive"]] + if key in knx_config + else None ) + def get_dpt(key: str) -> str | None: + """Get the DPT.""" + return knx_config[key].get("dpt") if key in knx_config else None + + group_address_tunable_white = None + group_address_tunable_white_state = None + group_address_color_temp = None + group_address_color_temp_state = None + color_temperature_type = ColorTemperatureType.UINT_2_BYTE + if ga_color_temp := knx_config.get("ga_color_temp"): + if ga_color_temp["dpt"] == ColorTempModes.RELATIVE: + group_address_tunable_white = ga_color_temp["write"] + group_address_tunable_white_state = [ + ga_color_temp["state"], + *ga_color_temp["passive"], + ] + else: + # absolute uint or float + group_address_color_temp = ga_color_temp["write"] + group_address_color_temp_state = [ + ga_color_temp["state"], + *ga_color_temp["passive"], + ] + if ga_color_temp["dpt"] == ColorTempModes.ABSOLUTE_FLOAT: + color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE + + _color_dpt = get_dpt("ga_color") + return XknxLight( + xknx, + name=name, + group_address_switch=get_write("ga_switch"), + group_address_switch_state=get_state("ga_switch"), + group_address_brightness=get_write("ga_brightness"), + group_address_brightness_state=get_state("ga_brightness"), + group_address_color=get_write("ga_color") + if _color_dpt == LightColorMode.RGB + else None, + group_address_color_state=get_state("ga_color") + if _color_dpt == LightColorMode.RGB + else None, + group_address_rgbw=get_write("ga_color") + if _color_dpt == LightColorMode.RGBW + else None, + group_address_rgbw_state=get_state("ga_color") + if _color_dpt == LightColorMode.RGBW + else None, + group_address_hue=get_write("ga_hue"), + group_address_hue_state=get_state("ga_hue"), + group_address_saturation=get_write("ga_saturation"), + group_address_saturation_state=get_state("ga_saturation"), + group_address_xyy_color=get_write("ga_color") + if _color_dpt == LightColorMode.XYY + else None, + group_address_xyy_color_state=get_write("ga_color") + if _color_dpt == LightColorMode.XYY + else None, + group_address_tunable_white=group_address_tunable_white, + group_address_tunable_white_state=group_address_tunable_white_state, + group_address_color_temperature=group_address_color_temp, + group_address_color_temperature_state=group_address_color_temp_state, + group_address_switch_red=get_write("ga_red_switch"), + group_address_switch_red_state=get_state("ga_red_switch"), + group_address_brightness_red=get_write("ga_red_brightness"), + group_address_brightness_red_state=get_state("ga_red_brightness"), + group_address_switch_green=get_write("ga_green_switch"), + group_address_switch_green_state=get_state("ga_green_switch"), + group_address_brightness_green=get_write("ga_green_brightness"), + group_address_brightness_green_state=get_state("ga_green_brightness"), + group_address_switch_blue=get_write("ga_blue_switch"), + group_address_switch_blue_state=get_state("ga_blue_switch"), + group_address_brightness_blue=get_write("ga_blue_brightness"), + group_address_brightness_blue_state=get_state("ga_blue_brightness"), + group_address_switch_white=get_write("ga_white_switch"), + group_address_switch_white_state=get_state("ga_white_switch"), + group_address_brightness_white=get_write("ga_white_brightness"), + group_address_brightness_white_state=get_state("ga_white_brightness"), + color_temperature_type=color_temperature_type, + min_kelvin=knx_config["color_temp_min"], + max_kelvin=knx_config["color_temp_max"], + sync_state=knx_config["sync_state"], + ) + + +class _KnxLight(KnxEntity, LightEntity): + """Representation of a KNX light.""" + + _attr_max_color_temp_kelvin: int + _attr_min_color_temp_kelvin: int + _device: XknxLight + @property def is_on(self) -> bool: """Return true if light is on.""" @@ -392,3 +494,53 @@ async def set_color( async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._device.set_off() + + +class KnxYamlLight(_KnxLight): + """Representation of a KNX light.""" + + _device: XknxLight + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize of KNX light.""" + super().__init__(_create_yaml_light(xknx, config)) + self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] + self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = self._device_unique_id() + + def _device_unique_id(self) -> str: + """Return unique id for this device.""" + if self._device.switch.group_address is not None: + return f"{self._device.switch.group_address}" + return ( + f"{self._device.red.brightness.group_address}_" + f"{self._device.green.brightness.group_address}_" + f"{self._device.blue.brightness.group_address}" + ) + + +class KnxUiLight(_KnxLight): + """Representation of a KNX light.""" + + _device: XknxLight + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: ConfigType + ) -> None: + """Initialize of KNX light.""" + super().__init__( + _create_ui_light( + knx_module.xknx, config["knx"], config["entity"][CONF_NAME] + ) + ) + self._attr_max_color_temp_kelvin: int = config["knx"]["color_temp_max"] + self._attr_min_color_temp_kelvin: int = config["knx"]["color_temp_min"] + + self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] + self._attr_unique_id = unique_id + if device_info := config["entity"].get("device_info"): + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) + self._attr_has_entity_name = True + + knx_module.config_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index a6ced30cc75582..6301a01966662e 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -1,4 +1,5 @@ """KNX entity configuration store.""" + from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, Final, TypedDict @@ -61,7 +62,7 @@ async def load_data(self) -> None: len(self.data["entities"]), ) - async def create_entitiy( + async def create_entity( self, platform: Platform, data: dict[str, Any] ) -> str | None: """Create a new entity.""" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index e561cb941bd7d5..a3b10bb851508c 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,5 +1,6 @@ """KNX entity store schema.""" -from enum import Enum + +from enum import Enum, StrEnum, unique from typing import Any import voluptuous as vol @@ -8,6 +9,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from ..const import SUPPORTED_PLATFORMS_UI, ColorTempModes from ..validation import ga_validator, maybe_ga_validator, sync_state_validator BASE_ENTITY_SCHEMA = vol.All( @@ -107,14 +109,107 @@ def optional_ga_schema( vol.Optional("respond_to_read", default=False): bool, vol.Optional("sync_state", default=True): sync_state_validator, }, + } +) + + +@unique +class LightColorMode(StrEnum): + """Enum for light color mode.""" + + RGB = "232.600" + RGBW = "251.600" + XYY = "242.600" + + +@unique +class LightColorModeSchema(StrEnum): + """Enum for light color mode.""" + + DEFAULT = "default" + INDIVIDUAL = "individual" + HSV = "hsv" + + +_COMMON_LIGHT_SCHEMA = vol.Schema( + { vol.Optional("sync_state", default=True): sync_state_validator, + **optional_ga_schema( + "ga_color_temp", ga_schema(write_required=True, dpt=ColorTempModes) + ), + vol.Optional("color_temp_min", default=2700): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional("color_temp_max", default=6000): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + }, + extra=vol.REMOVE_EXTRA, +) + +_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( + { + vol.Required("_light_color_mode_schema"): LightColorModeSchema.DEFAULT.value, + vol.Required("ga_switch"): ga_schema(write_required=True), + **optional_ga_schema("ga_brightness", ga_schema(write_required=True)), + **optional_ga_schema( + "ga_color", + ga_schema(write_required=True, dpt=LightColorMode), + ), + } +) + +_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( + { + vol.Required("_light_color_mode_schema"): LightColorModeSchema.INDIVIDUAL.value, + **optional_ga_schema("ga_switch", ga_schema(write_required=True)), + **optional_ga_schema("ga_brightness", ga_schema(write_required=True)), + vol.Required("ga_red_brightness"): ga_schema(write_required=True), + **optional_ga_schema("ga_red_switch", ga_schema(write_required=False)), + vol.Required("ga_green_brightness"): ga_schema(write_required=True), + **optional_ga_schema("ga_green_switch", ga_schema(write_required=False)), + vol.Required("ga_blue_brightness"): ga_schema(write_required=True), + **optional_ga_schema("ga_blue_switch", ga_schema(write_required=False)), + **optional_ga_schema("ga_white_brightness", ga_schema(write_required=True)), + **optional_ga_schema("ga_white_switch", ga_schema(write_required=False)), + } +) + +_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( + { + vol.Required("_light_color_mode_schema"): LightColorModeSchema.HSV.value, + vol.Required("ga_switch"): ga_schema(write_required=True), + vol.Required("ga_brightness"): ga_schema(write_required=True), + vol.Required("ga_hue"): ga_schema(write_required=True), + vol.Required("ga_saturation"): ga_schema(write_required=True), + } +) + + +LIGHT_KNX_SCHEMA = cv.key_value_schemas( + "_light_color_mode_schema", + default_schema=_DEFAULT_LIGHT_SCHEMA, + value_schemas={ + LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, + LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, + LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, + }, +) + +LIGHT_SCHEMA = vol.Schema( + { + vol.Required("entity"): BASE_ENTITY_SCHEMA, + vol.Required("knx"): LIGHT_KNX_SCHEMA, } ) ENTITY_STORE_DATA_SCHEMA = vol.All( vol.Schema( { - vol.Required("platform"): vol.Coerce(Platform), + vol.Required("platform"): vol.All( + vol.Coerce(Platform), + vol.In(SUPPORTED_PLATFORMS_UI), + ), vol.Required("data"): dict, }, extra=vol.ALLOW_EXTRA, @@ -124,7 +219,10 @@ def optional_ga_schema( { Platform.SWITCH: vol.Schema( {vol.Required("data"): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA - ) + ), + Platform.LIGHT: vol.Schema( + {vol.Required("data"): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA + ), }, ), ) diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index 443744b96217da..e9997bd9f1a27f 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -1,4 +1,5 @@ """KNX Entity Store Validation.""" + from typing import Literal, TypedDict import voluptuous as vol diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index f675a05159c61c..f4228e3b2d01b4 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -282,7 +282,7 @@ async def ws_create_entity( return knx: KNXModule = hass.data[DOMAIN] try: - entity_id = await knx.config_store.create_entitiy( + entity_id = await knx.config_store.create_entity( # use validation result so defaults are applied validated_data["platform"], validated_data["data"], From c81a1baf8f28f36a0f6be6d27c098736c153311d Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 2 May 2024 21:57:31 +0200 Subject: [PATCH 27/51] async_forward_entry_setups only once --- homeassistant/components/knx/__init__.py | 31 ++++++++++-------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 58884cac1039e8..f7e9b161962371 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -193,20 +193,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - # always forward sensor for system entities (telegram counter, etc.) - # forward all platforms that support UI entity management - await hass.config_entries.async_forward_entry_setups( - entry, {Platform.SENSOR} | SUPPORTED_PLATFORMS_UI - ) - # forward yaml-only managed platforms on demand await hass.config_entries.async_forward_entry_setups( entry, - [ - platform - for platform in SUPPORTED_PLATFORMS_YAML - if platform in config - and platform not in SUPPORTED_PLATFORMS_UI | {Platform.SENSOR} - ], + { + Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.) + *SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management + *{ # forward yaml-only managed platforms on demand + platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config + }, + }, ) # set up notify service for backwards compatibility - remove 2024.11 @@ -236,12 +231,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, { Platform.SENSOR, # always unload system entities (telegram counter, etc.) - } - | SUPPORTED_PLATFORMS_UI - | { - platform - for platform in SUPPORTED_PLATFORMS_YAML - if platform in hass.data[DATA_KNX_CONFIG] + *SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management + *{ # unload yaml-only managed platforms if configured + platform + for platform in SUPPORTED_PLATFORMS_YAML + if platform in hass.data[DATA_KNX_CONFIG] + }, }, ) if unload_ok: From a6a10f89c77f7bd3010f680109fb3b6991dc4038 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 4 May 2024 15:41:10 +0200 Subject: [PATCH 28/51] Test crate and remove devices --- tests/components/knx/test_device.py | 65 +++++++++++++++++++ tests/components/knx/test_interface_device.py | 31 ++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/components/knx/test_device.py diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py new file mode 100644 index 00000000000000..aaaf98e021acaf --- /dev/null +++ b/tests/components/knx/test_device.py @@ -0,0 +1,65 @@ +"""Test KNX devices.""" + +from homeassistant.components.knx.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_create_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/create_device", + "name": "Test Device", + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["name"] == "Test Device" + assert res["result"]["manufacturer"] == "KNX" + assert res["result"]["identifiers"] + assert res["result"]["config_entries"][0] == knx.mock_config_entry.entry_id + + device_identifier = res["result"]["identifiers"][0][1] + assert device_registry.async_get_device({(DOMAIN, device_identifier)}) + device_id = res["result"]["id"] + assert device_registry.async_get(device_id) + + +async def test_remove_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/create_device", + "name": "Test Device", + } + ) + res = await client.receive_json() + device_id = res["result"]["id"] + assert device_registry.async_get(device_id) + + response = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert response["success"] + assert not device_registry.async_get(device_id) diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 6cf5d8026b9d8e..c21c25b6fad7af 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -1,4 +1,4 @@ -"""Test KNX scene.""" +"""Test KNX interface device.""" from unittest.mock import patch @@ -8,12 +8,14 @@ from homeassistant.components.knx.sensor import SCAN_INTERVAL from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import KNXTestKit from tests.common import async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_diagnostic_entities( @@ -111,3 +113,28 @@ async def test_removed_entity( ) await hass.async_block_till_done() unregister_mock.assert_called_once() + + +async def test_remove_interface_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration({}) + client = await hass_ws_client(hass) + knx_devices = device_registry.devices.get_devices_for_config_entry_id( + knx.mock_config_entry.entry_id + ) + assert len(knx_devices) == 1 + assert knx_devices[0].name == "KNX Interface" + device_id = knx_devices[0].id + # interface device can't be removed + res = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert not res["success"] + assert ( + res["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) From 85afe052d753c3cb9834501e945274d3b1e223e0 Mon Sep 17 00:00:00 2001 From: farmio Date: Mon, 6 May 2024 17:02:15 +0200 Subject: [PATCH 29/51] Test removing entities of a removed device --- tests/components/knx/README.md | 3 +- tests/components/knx/conftest.py | 21 ++++++++++-- .../components/knx/fixtures/config_store.json | 29 +++++++++++++++++ tests/components/knx/test_device.py | 32 +++++++++++++------ tests/components/knx/test_websocket.py | 9 ++++-- 5 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store.json diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 930b9e71c28801..8778feb2251cd8 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -24,9 +24,10 @@ All outgoing telegrams are pushed to an assertion queue. Assert them in order th Asserts that no telegram was sent (assertion queue is empty). - `knx.assert_telegram_count(count: int)` Asserts that `count` telegrams were sent. -- `knx.assert_read(group_address: str)` +- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None)` Asserts that a GroupValueRead telegram was sent to `group_address`. The telegram will be removed from the assertion queue. + Optionally inject incoming GroupValueResponse telegram after reception to clear the value reader waiting task. This can also be done manually with `knx.receive_response`. - `knx.assert_response(group_address: str, payload: int | tuple[int, ...])` Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`. The telegram will be removed from the assertion queue. diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index cd7146b565be20..77e1ad672cc5e1 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -30,6 +30,9 @@ DOMAIN as KNX_DOMAIN, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -37,6 +40,7 @@ from tests.common import MockConfigEntry, load_fixture FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) +FIXTURE_CONFIG_STORAGE_DATA = json.loads(load_fixture("config_store.json", KNX_DOMAIN)) class KNXTestKit: @@ -166,9 +170,16 @@ async def assert_telegram( telegram.payload.value.value == payload # type: ignore[attr-defined] ), f"Payload mismatch in {telegram} - Expected: {payload}" - async def assert_read(self, group_address: str) -> None: - """Assert outgoing GroupValueRead telegram. One by one in timely order.""" + async def assert_read( + self, group_address: str, response: int | tuple[int, ...] | None = None + ) -> None: + """Assert outgoing GroupValueRead telegram. One by one in timely order. + + Optionally inject incoming GroupValueResponse telegram after reception. + """ await self.assert_telegram(group_address, None, GroupValueRead) + if response is not None: + await self.receive_response(group_address, response) async def assert_response( self, group_address: str, payload: int | tuple[int, ...] @@ -280,3 +291,9 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: "version": 1, "data": FIXTURE_PROJECT_DATA, } + + +@pytest.fixture +def load_config_store(hass_storage): + """Mock KNX config store data.""" + hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store.json new file mode 100644 index 00000000000000..971b692ade1381 --- /dev/null +++ b/tests/components/knx/fixtures/config_store.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "switch": { + "knx_es_9d97829f47f1a2a3176a7c5b4216070c": { + "entity": { + "entity_category": null, + "name": "test", + "device_info": "knx_vdev_4c80a564f5fe5da701ed293966d6384d" + }, + "knx": { + "ga_switch": { + "write": "1/1/45", + "state": "1/0/45", + "passive": [] + }, + "invert": false, + "sync_state": true, + "respond_to_read": false + } + } + }, + "light": {} + } + } +} diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index aaaf98e021acaf..330fd854a50227 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -1,8 +1,13 @@ """Test KNX devices.""" +from typing import Any + from homeassistant.components.knx.const import DOMAIN +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .conftest import KNXTestKit @@ -43,23 +48,30 @@ async def test_remove_device( hass: HomeAssistant, knx: KNXTestKit, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, + load_config_store: None, + hass_storage: dict[str, Any], ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) await knx.setup_integration({}) client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "knx/create_device", - "name": "Test Device", - } + await knx.assert_read("1/0/45", response=True) + + assert hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") + test_device = device_registry.async_get_device( + {(DOMAIN, "knx_vdev_4c80a564f5fe5da701ed293966d6384d")} ) - res = await client.receive_json() - device_id = res["result"]["id"] - assert device_registry.async_get(device_id) + device_id = test_device.id + device_entities = entity_registry.entities.get_entries_for_device_id(device_id) + assert len(device_entities) == 1 response = await client.remove_device(device_id, knx.mock_config_entry.entry_id) assert response["success"] - assert not device_registry.async_get(device_id) + assert not device_registry.async_get_device( + {(DOMAIN, "knx_vdev_4c80a564f5fe5da701ed293966d6384d")} + ) + assert not entity_registry.entities.get_entries_for_device_id(device_id) + assert not hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index ca60905b0ba7fb..eb22bac85bc457 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant.components.knx import DOMAIN, KNX_ADDRESS, SwitchSchema +from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -87,6 +88,7 @@ async def test_knx_project_file_process( assert res["success"], res assert hass.data[DOMAIN].project.loaded + assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == _parse_result async def test_knx_project_file_process_error( @@ -126,19 +128,20 @@ async def test_knx_project_file_remove( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, + hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" await knx.setup_integration({}) + assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) assert hass.data[DOMAIN].project.loaded await client.send_json({"id": 6, "type": "knx/project_file_remove"}) - with patch("homeassistant.helpers.storage.Store.async_remove") as remove_mock: - res = await client.receive_json() - remove_mock.assert_called_once_with() + res = await client.receive_json() assert res["success"], res assert not hass.data[DOMAIN].project.loaded + assert not hass_storage.get(KNX_PROJECT_STORAGE_KEY) async def test_knx_get_project( From 9fa16159e7adb81e38ce55a071d0184d45b79c07 Mon Sep 17 00:00:00 2001 From: farmio Date: Tue, 7 May 2024 17:21:12 +0200 Subject: [PATCH 30/51] Test entity creation and storage --- tests/components/knx/test_config_store.py | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/components/knx/test_config_store.py diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py new file mode 100644 index 00000000000000..eb29d4b602ed43 --- /dev/null +++ b/tests/components/knx/test_config_store.py @@ -0,0 +1,80 @@ +"""Test KNX devices.""" + +from typing import Any + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_create_switch( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_name = "Test no device" + test_entity_id = "switch.test_no_device" + assert not entity_registry.async_get(test_entity_id) + + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": test_name}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + assert res["result"]["entity_id"] == test_entity_id + + entity = entity_registry.async_get(test_entity_id) + assert entity + + # Test if entity is correctly stored in registry + await client.send_json_auto_id({"type": "knx/get_entity_entries"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"] == [ + entity.extended_dict, + ] + # Test if entity is correctly stored in config store + await client.send_json_auto_id( + { + "type": "knx/get_entity_config", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"] == { + "platform": Platform.SWITCH, + "unique_id": entity.unique_id, + "data": { + "entity": { + "name": test_name, + "device_info": None, + "entity_category": None, + }, + "knx": { + "ga_switch": {"write": "1/2/3", "state": None, "passive": []}, + "invert": False, + "respond_to_read": False, + "sync_state": True, + }, + }, + "schema_options": None, + } From 4ef3979f549fe6be525806e542edd2d30d450265 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 9 May 2024 23:03:05 +0200 Subject: [PATCH 31/51] Test deleting entities --- tests/components/knx/test_config_store.py | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index eb29d4b602ed43..080353850283c1 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -2,6 +2,9 @@ from typing import Any +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -78,3 +81,86 @@ async def test_create_switch( }, "schema_options": None, } + + +async def test_delete_entity( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity deletion.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_name = "Test no device" + test_entity_id = "switch.test_no_device" + assert not entity_registry.async_get(test_entity_id) + + # create entity + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": test_name}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["entity_id"] == test_entity_id + assert entity_registry.async_get(test_entity_id) + + # delete entity + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert res["success"], res + + assert not entity_registry.async_get(test_entity_id) + assert not hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") + + +async def test_delete_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test unsuccessful entity deletion.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + # delete unknown entity + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": "switch.non_existing_entity", + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found") + + # delete entity not in config store + test_entity_id = "sensor.knx_interface_individual_address" + assert entity_registry.async_get(test_entity_id) + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found") From 5aa6434cbf928c59f02e412882698f65951afea6 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 9 May 2024 23:15:10 +0200 Subject: [PATCH 32/51] Test unsuccessful entity creation --- tests/components/knx/test_config_store.py | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 080353850283c1..a149803d6d7975 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -83,6 +83,52 @@ async def test_create_switch( } +async def test_create_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test unsuccessful entity creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + # create entity with invalid platform + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": "invalid_platform", + "data": { + "entity": {"name": "Test invalid platform"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("expected Platform or one of") + + # create entity with unsupported platform + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": "tts", # "tts" is not a supported platform (and is unlikely te ever be) + "data": { + "entity": {"name": "Test invalid platform"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("value must be one of") + + async def test_delete_entity( hass: HomeAssistant, knx: KNXTestKit, From c2901be3325434f18e5df48faeebb72092bb6535 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 9 May 2024 23:51:11 +0200 Subject: [PATCH 33/51] Test updating entity data --- tests/components/knx/test_config_store.py | 145 +++++++++++++++++----- 1 file changed, 116 insertions(+), 29 deletions(-) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index a149803d6d7975..96d03c66bda4fc 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -11,25 +11,19 @@ from .conftest import KNXTestKit -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator -async def test_create_switch( - hass: HomeAssistant, - knx: KNXTestKit, +async def _create_test_switch( entity_registry: er.EntityRegistry, - hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], -) -> None: - """Test entity creation.""" - await knx.setup_integration({}) - client = await hass_ws_client(hass) - + ws_client: MockHAClientWebSocket, +) -> er.RegistryEntry: + """Create a test switch entity and return its entity_id and name.""" test_name = "Test no device" test_entity_id = "switch.test_no_device" assert not entity_registry.async_get(test_entity_id) - await client.send_json_auto_id( + await ws_client.send_json_auto_id( { "type": "knx/create_entity", "platform": Platform.SWITCH, @@ -39,36 +33,51 @@ async def test_create_switch( }, } ) - res = await client.receive_json() + res = await ws_client.receive_json() assert res["success"], res assert res["result"]["success"] is True assert res["result"]["entity_id"] == test_entity_id entity = entity_registry.async_get(test_entity_id) assert entity + return entity + + +async def test_create_switch( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await _create_test_switch(entity_registry, client) # Test if entity is correctly stored in registry await client.send_json_auto_id({"type": "knx/get_entity_entries"}) res = await client.receive_json() assert res["success"], res assert res["result"] == [ - entity.extended_dict, + test_entity.extended_dict, ] # Test if entity is correctly stored in config store await client.send_json_auto_id( { "type": "knx/get_entity_config", - "entity_id": test_entity_id, + "entity_id": test_entity.entity_id, } ) res = await client.receive_json() assert res["success"], res assert res["result"] == { "platform": Platform.SWITCH, - "unique_id": entity.unique_id, + "unique_id": test_entity.unique_id, "data": { "entity": { - "name": test_name, + "name": test_entity.original_name, "device_info": None, "entity_category": None, }, @@ -115,7 +124,7 @@ async def test_create_entity_error( await client.send_json_auto_id( { "type": "knx/create_entity", - "platform": "tts", # "tts" is not a supported platform (and is unlikely te ever be) + "platform": Platform.TTS, # "tts" is not a supported platform (and is unlikely te ever be) "data": { "entity": {"name": "Test invalid platform"}, "knx": {"ga_switch": {"write": "1/2/3"}}, @@ -129,36 +138,114 @@ async def test_create_entity_error( assert res["result"]["error_base"].startswith("value must be one of") -async def test_delete_entity( +async def test_update_entity( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], ) -> None: - """Test entity deletion.""" + """Test entity update.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_name = "Test no device" - test_entity_id = "switch.test_no_device" - assert not entity_registry.async_get(test_entity_id) + test_entity = await _create_test_switch(entity_registry, client) + test_entity_id = test_entity.entity_id - # create entity + # update entity + new_name = "Updated name" + new_ga_switch_write = "4/5/6" await client.send_json_auto_id( { - "type": "knx/create_entity", + "type": "knx/update_entity", "platform": Platform.SWITCH, + "unique_id": test_entity.unique_id, "data": { - "entity": {"name": test_name}, - "knx": {"ga_switch": {"write": "1/2/3"}}, + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, }, } ) res = await client.receive_json() assert res["success"], res - assert res["result"]["entity_id"] == test_entity_id - assert entity_registry.async_get(test_entity_id) + assert res["result"]["success"] + + entity = entity_registry.async_get(test_entity_id) + assert entity + assert entity.original_name == new_name + + assert ( + hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"]["switch"][ + test_entity.unique_id + ]["knx"]["ga_switch"]["write"] + == new_ga_switch_write + ) + + +async def test_update_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity update.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await _create_test_switch(entity_registry, client) + + # update unsupported platform + new_name = "Updated name" + new_ga_switch_write = "4/5/6" + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.TTS, + "unique_id": test_entity.unique_id, + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("value must be one of") + + # entity not found + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.SWITCH, + "unique_id": "non_existing_unique_id", + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found in") + + +async def test_delete_entity( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity deletion.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await _create_test_switch(entity_registry, client) + test_entity_id = test_entity.entity_id # delete entity await client.send_json_auto_id( From b938cc51eeccac4ab5079e87d77d8f765fe65083 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 10 May 2024 00:14:48 +0200 Subject: [PATCH 34/51] Test get entity config --- tests/components/knx/test_config_store.py | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 96d03c66bda4fc..169f6859bc5031 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -2,6 +2,8 @@ from typing import Any +import pytest + from homeassistant.components.knx.storage.config_store import ( STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, ) @@ -297,3 +299,73 @@ async def test_delete_entity_error( assert not res["success"], res assert res["error"]["code"] == "home_assistant_error" assert res["error"]["message"].startswith("Entity not found") + + +async def test_get_entity_config( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity config retrieval.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await _create_test_switch(entity_registry, client) + + await client.send_json_auto_id( + { + "type": "knx/get_entity_config", + "entity_id": test_entity.entity_id, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["platform"] == Platform.SWITCH + assert res["result"]["unique_id"] == test_entity.unique_id + assert res["result"]["data"] == { + "entity": { + "name": "Test no device", + "device_info": None, + "entity_category": None, + }, + "knx": { + "ga_switch": {"write": "1/2/3", "passive": [], "state": None}, + "respond_to_read": False, + "invert": False, + "sync_state": True, + }, + } + + +@pytest.mark.parametrize( + ("test_entity_id", "error_message_start"), + [ + ("switch.non_existing_entity", "Entity not found"), + ("sensor.knx_interface_individual_address", "Entity data not found"), + ], +) +async def test_get_entity_config_error( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + test_entity_id: str, + error_message_start: str, +) -> None: + """Test entity config retrieval errors.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/get_entity_config", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith(error_message_start) From 2a659e18101657228c24f394688c9e51c28ef153 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 10 May 2024 00:37:37 +0200 Subject: [PATCH 35/51] Test validate entity --- tests/components/knx/test_config_store.py | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 169f6859bc5031..2254817e4ca4c5 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -369,3 +369,47 @@ async def test_get_entity_config_error( assert not res["success"], res assert res["error"]["code"] == "home_assistant_error" assert res["error"]["message"].startswith(error_message_start) + + +async def test_validate_entity( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test entity validation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": "test_name"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + + # invalid data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": "test_name"}, + "knx": {"ga_switch": {}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") From 26c251d0ffd47b5a7abcb05d778041dc9394ac65 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 10 May 2024 22:49:18 +0200 Subject: [PATCH 36/51] Update entity data by entity_id instead of unique_id --- .../components/knx/storage/config_store.py | 11 +++- .../knx/storage/entity_store_schema.py | 2 +- homeassistant/components/knx/websocket.py | 2 +- tests/components/knx/test_config_store.py | 66 +++++++++++-------- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 6301a01966662e..9bb604ce7c6f64 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -88,7 +88,6 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: try: return { "platform": entry.domain, - "unique_id": entry.unique_id, "data": self.data["entities"][entry.domain][entry.unique_id], "schema_options": SCHEMA_OPTIONS.get(entry.domain), } @@ -96,16 +95,22 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: raise ConfigStoreException(f"Entity data not found: {entity_id}") from err async def update_entity( - self, platform: Platform, unique_id: str, data: dict[str, Any] + self, platform: Platform, entity_id: str, data: dict[str, Any] ) -> None: """Update an existing entity.""" if platform not in self.async_add_entity: raise ConfigStoreException(f"Entity platform not ready: {platform}") + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise ConfigStoreException(f"Entity not found: {entity_id}") + unique_id = entry.unique_id if ( platform not in self.data["entities"] or unique_id not in self.data["entities"][platform] ): - raise ConfigStoreException(f"Entity not found in {platform}: {unique_id}") + raise ConfigStoreException( + f"Entity not found in storage: {entity_id} - {unique_id}" + ) await self.entities.pop(unique_id).async_remove() self.async_add_entity[platform](unique_id, data) # store data after entity is added to make sure config doesn't raise exceptions diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index a3b10bb851508c..c1d21169a5dbee 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -233,7 +233,7 @@ class LightColorModeSchema(StrEnum): } UPDATE_ENTITY_BASE_SCHEMA = { - vol.Required("unique_id"): str, + vol.Required("entity_id"): str, **CREATE_ENTITY_BASE_SCHEMA, } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index f4228e3b2d01b4..912d2584a5b35b 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -320,7 +320,7 @@ async def ws_update_entity( try: await knx.config_store.update_entity( validated_data["platform"], - validated_data["unique_id"], + validated_data["entity_id"], validated_data["data"], ) except ConfigStoreException as err: diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 2254817e4ca4c5..28f673a7f8ced8 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -66,31 +66,23 @@ async def test_create_switch( test_entity.extended_dict, ] # Test if entity is correctly stored in config store - await client.send_json_auto_id( - { - "type": "knx/get_entity_config", - "entity_id": test_entity.entity_id, - } + test_storage_data = next( + iter( + hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"]["switch"].values() + ) ) - res = await client.receive_json() - assert res["success"], res - assert res["result"] == { - "platform": Platform.SWITCH, - "unique_id": test_entity.unique_id, - "data": { - "entity": { - "name": test_entity.original_name, - "device_info": None, - "entity_category": None, - }, - "knx": { - "ga_switch": {"write": "1/2/3", "state": None, "passive": []}, - "invert": False, - "respond_to_read": False, - "sync_state": True, - }, + assert test_storage_data == { + "entity": { + "name": test_entity.original_name, + "device_info": None, + "entity_category": None, + }, + "knx": { + "ga_switch": {"write": "1/2/3", "state": None, "passive": []}, + "invert": False, + "respond_to_read": False, + "sync_state": True, }, - "schema_options": None, } @@ -161,7 +153,7 @@ async def test_update_entity( { "type": "knx/update_entity", "platform": Platform.SWITCH, - "unique_id": test_entity.unique_id, + "entity_id": test_entity_id, "data": { "entity": {"name": new_name}, "knx": {"ga_switch": {"write": new_ga_switch_write}}, @@ -204,7 +196,7 @@ async def test_update_entity_error( { "type": "knx/update_entity", "platform": Platform.TTS, - "unique_id": test_entity.unique_id, + "entity_id": test_entity.entity_id, "data": { "entity": {"name": new_name}, "knx": {"ga_switch": {"write": new_ga_switch_write}}, @@ -222,7 +214,26 @@ async def test_update_entity_error( { "type": "knx/update_entity", "platform": Platform.SWITCH, - "unique_id": "non_existing_unique_id", + "entity_id": "non_existing_entity_id", + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found:") + + # entity not in storage + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.SWITCH, + # `sensor` isn't yet supported, but we only have sensor entities automatically + # created with no configuration - it doesn't ,atter for the test though + "entity_id": "sensor.knx_interface_individual_address", "data": { "entity": {"name": new_name}, "knx": {"ga_switch": {"write": new_ga_switch_write}}, @@ -232,7 +243,7 @@ async def test_update_entity_error( res = await client.receive_json() assert not res["success"], res assert res["error"]["code"] == "home_assistant_error" - assert res["error"]["message"].startswith("Entity not found in") + assert res["error"]["message"].startswith("Entity not found in storage") async def test_delete_entity( @@ -323,7 +334,6 @@ async def test_get_entity_config( res = await client.receive_json() assert res["success"], res assert res["result"]["platform"] == Platform.SWITCH - assert res["result"]["unique_id"] == test_entity.unique_id assert res["result"]["data"] == { "entity": { "name": "Test no device", From 70d0ef7c46a8a5bb5c2a305b7ef6e5ef02ba42a5 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 10 May 2024 23:37:51 +0200 Subject: [PATCH 37/51] Remove unnecessary uid unique check --- homeassistant/components/knx/storage/config_store.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 9bb604ce7c6f64..b38fd3d7315b72 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -69,11 +69,9 @@ async def create_entity( if platform not in self.async_add_entity: raise ConfigStoreException(f"Entity platform not ready: {platform}") unique_id = f"knx_es_{ulid_now()}" - if unique_id in self.data["entities"].setdefault(platform, {}): - raise ConfigStoreException("Unique id already used.") self.async_add_entity[platform](unique_id, data) # store data after entity was added to be sure config didn't raise exceptions - self.data["entities"][platform][unique_id] = data + self.data["entities"].setdefault(platform, {})[unique_id] = data await self._store.async_save(self.data) entity_registry = er.async_get(self.hass) From 9da038e51f413159875501a80a4cc666da724a86 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 17 May 2024 20:17:51 +0200 Subject: [PATCH 38/51] remove schema_options --- .../components/knx/storage/config_store.py | 2 -- .../knx/storage/entity_store_schema.py | 2 -- homeassistant/components/knx/websocket.py | 22 ------------------- 3 files changed, 26 deletions(-) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index b38fd3d7315b72..9f1ef6c454664e 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -12,7 +12,6 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN -from .entity_store_schema import SCHEMA_OPTIONS if TYPE_CHECKING: from ..knx_entity import KnxEntity @@ -87,7 +86,6 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: return { "platform": entry.domain, "data": self.data["entities"][entry.domain][entry.unique_id], - "schema_options": SCHEMA_OPTIONS.get(entry.domain), } except KeyError as err: raise ConfigStoreException(f"Entity data not found: {entity_id}") from err diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index c1d21169a5dbee..866cfda3c01fd1 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -236,5 +236,3 @@ class LightColorModeSchema(StrEnum): vol.Required("entity_id"): str, **CREATE_ENTITY_BASE_SCHEMA, } - -SCHEMA_OPTIONS: dict[str, dict] = {} diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 912d2584a5b35b..d884b5865ff1b3 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -19,7 +19,6 @@ from .storage.config_store import ConfigStoreException from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, - SCHEMA_OPTIONS, UPDATE_ENTITY_BASE_SCHEMA, ) from .storage.entity_store_validation import ( @@ -51,7 +50,6 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entity_entries) websocket_api.async_register_command(hass, ws_create_device) - websocket_api.async_register_command(hass, ws_get_platform_schema_options) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -433,23 +431,3 @@ def ws_create_device( configuration_url=f"homeassistant://knx/entities/view?device_id={_device.id}", ) connection.send_result(msg["id"], _device.dict_repr) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "knx/get_platform_schema_options", - vol.Required("platform"): str, - } -) -@callback -def ws_get_platform_schema_options( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict, -) -> None: - """Send schema options for a platform entity configuration.""" - connection.send_result( - msg["id"], - result=SCHEMA_OPTIONS.get(msg["platform"]), - ) From 92df5f5eaede04862e4613b50599729e392cec6a Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 18 May 2024 00:16:52 +0200 Subject: [PATCH 39/51] test fixture for entity creation --- .../knx/storage/entity_store_schema.py | 2 +- tests/components/knx/__init__.py | 6 ++ tests/components/knx/conftest.py | 49 +++++++++++ tests/components/knx/test_config_store.py | 85 ++++++++----------- tests/components/knx/test_switch.py | 27 +++++- 5 files changed, 118 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 866cfda3c01fd1..959cd141614802 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -229,7 +229,7 @@ class LightColorModeSchema(StrEnum): CREATE_ENTITY_BASE_SCHEMA = { vol.Required("platform"): str, - vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA with vol.All() + vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA for platform } UPDATE_ENTITY_BASE_SCHEMA = { diff --git a/tests/components/knx/__init__.py b/tests/components/knx/__init__.py index eaa84714dc5a3a..76ae91a193de41 100644 --- a/tests/components/knx/__init__.py +++ b/tests/components/knx/__init__.py @@ -1 +1,7 @@ """Tests for the KNX integration.""" + +from collections.abc import Awaitable, Callable + +from homeassistant.helpers import entity_registry as er + +KnxEntityGenerator = Callable[..., Awaitable[er.RegistryEntry]] diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 77e1ad672cc5e1..697e8b5bbb34da 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -33,11 +33,16 @@ from homeassistant.components.knx.storage.config_store import ( STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from . import KnxEntityGenerator + from tests.common import MockConfigEntry, load_fixture +from tests.typing import WebSocketGenerator FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) FIXTURE_CONFIG_STORAGE_DATA = json.loads(load_fixture("config_store.json", KNX_DOMAIN)) @@ -297,3 +302,47 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: def load_config_store(hass_storage): """Mock KNX config store data.""" hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA + + +@pytest.fixture +async def create_ui_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> KnxEntityGenerator: + """Return a helper to create a KNX entities via WS. + + The KNX integration must be set up before using the helper. + """ + ws_client = await hass_ws_client(hass) + + async def _create_ui_entity( + platform: Platform, + knx_data: dict[str, Any], + entity_data: dict[str, Any] | None = None, + ) -> er.RegistryEntry: + """Create a KNX entity from WS with given configuration.""" + if entity_data is None: + entity_data = {"name": "Test"} + + await ws_client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": platform, + "data": { + "entity": entity_data, + "knx": knx_data, + }, + } + ) + res = await ws_client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + entity_id = res["result"]["entity_id"] + + entity = entity_registry.async_get(entity_id) + assert entity + return entity + + return _create_ui_entity diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 28f673a7f8ced8..b8a5d899d001f7 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -11,52 +11,29 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit -from tests.typing import MockHAClientWebSocket, WebSocketGenerator +from tests.typing import WebSocketGenerator -async def _create_test_switch( - entity_registry: er.EntityRegistry, - ws_client: MockHAClientWebSocket, -) -> er.RegistryEntry: - """Create a test switch entity and return its entity_id and name.""" - test_name = "Test no device" - test_entity_id = "switch.test_no_device" - assert not entity_registry.async_get(test_entity_id) - - await ws_client.send_json_auto_id( - { - "type": "knx/create_entity", - "platform": Platform.SWITCH, - "data": { - "entity": {"name": test_name}, - "knx": {"ga_switch": {"write": "1/2/3"}}, - }, - } - ) - res = await ws_client.receive_json() - assert res["success"], res - assert res["result"]["success"] is True - assert res["result"]["entity_id"] == test_entity_id - - entity = entity_registry.async_get(test_entity_id) - assert entity - return entity - - -async def test_create_switch( +async def test_create_entity( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity creation.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_entity = await _create_test_switch(entity_registry, client) + test_name = "Test no device" + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": test_name}, + ) # Test if entity is correctly stored in registry await client.send_json_auto_id({"type": "knx/get_entity_entries"}) @@ -73,7 +50,7 @@ async def test_create_switch( ) assert test_storage_data == { "entity": { - "name": test_entity.original_name, + "name": test_name, "device_info": None, "entity_category": None, }, @@ -89,9 +66,7 @@ async def test_create_switch( async def test_create_entity_error( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], ) -> None: """Test unsuccessful entity creation.""" await knx.setup_integration({}) @@ -138,12 +113,17 @@ async def test_update_entity( entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_entity = await _create_test_switch(entity_registry, client) + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) test_entity_id = test_entity.entity_id # update entity @@ -179,15 +159,18 @@ async def test_update_entity( async def test_update_entity_error( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_entity = await _create_test_switch(entity_registry, client) + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) # update unsupported platform new_name = "Updated name" @@ -252,12 +235,17 @@ async def test_delete_entity( entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity deletion.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_entity = await _create_test_switch(entity_registry, client) + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) test_entity_id = test_entity.entity_id # delete entity @@ -315,15 +303,18 @@ async def test_delete_entity_error( async def test_get_entity_config( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity config retrieval.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - test_entity = await _create_test_switch(entity_registry, client) + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) await client.send_json_auto_id( { @@ -336,7 +327,7 @@ async def test_get_entity_config( assert res["result"]["platform"] == Platform.SWITCH assert res["result"]["data"] == { "entity": { - "name": "Test no device", + "name": "Test", "device_info": None, "entity_category": None, }, @@ -359,9 +350,7 @@ async def test_get_entity_config( async def test_get_entity_config_error( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], test_entity_id: str, error_message_start: str, ) -> None: @@ -384,9 +373,7 @@ async def test_get_entity_config_error( async def test_validate_entity( hass: HomeAssistant, knx: KNXTestKit, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], ) -> None: """Test entity validation.""" await knx.setup_integration({}) diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 8dce4cf9c27726..f3b8e6b7fca9c4 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -6,9 +6,10 @@ KNX_ADDRESS, ) from homeassistant.components.knx.schema import SwitchSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, State +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import mock_restore_cache @@ -146,3 +147,27 @@ async def test_switch_restore_and_respond(hass: HomeAssistant, knx) -> None: # respond to new state await knx.receive_read(_ADDRESS) await knx.assert_response(_ADDRESS, False) + + +async def test_switch_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +): + """Test creating a switch.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.SWITCH, + entity_data={"name": "test"}, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, + "invert": False, + }, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON From 1e17c1a105390f30df1757d4d21721c191ad5a4c Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 18 May 2024 10:27:10 +0200 Subject: [PATCH 40/51] clean up group address schema class can be used to add custom serializer later --- .../knx/storage/entity_store_schema.py | 90 +++++-------------- .../components/knx/storage/knx_selector.py | 80 +++++++++++++++++ 2 files changed, 104 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/knx/storage/knx_selector.py diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 959cd141614802..b6318357528f38 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,7 +1,6 @@ """KNX entity store schema.""" -from enum import Enum, StrEnum, unique -from typing import Any +from enum import StrEnum, unique import voluptuous as vol @@ -10,7 +9,8 @@ from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from ..const import SUPPORTED_PLATFORMS_UI, ColorTempModes -from ..validation import ga_validator, maybe_ga_validator, sync_state_validator +from ..validation import sync_state_validator +from .knx_selector import GASelector BASE_ENTITY_SCHEMA = vol.All( { @@ -38,57 +38,15 @@ ) -def ga_schema( - write: bool = True, - state: bool = True, - passive: bool = True, - write_required: bool = False, - state_required: bool = False, - dpt: type[Enum] | None = None, -) -> vol.Schema: - """Return a schema for a knx group address selector.""" - schema: dict[vol.Marker, Any] = {} - - def add_ga_item(key: str, allowed: bool, required: bool) -> None: - """Add a group address item to the schema.""" - if not allowed: - schema[vol.Remove(key)] = object - return - if required: - schema[vol.Required(key)] = ga_validator - else: - schema[vol.Optional(key, default=None)] = maybe_ga_validator - - add_ga_item("write", write, write_required) - add_ga_item("state", state, state_required) - - if passive: - schema[vol.Optional("passive", default=list)] = vol.Any( - [ga_validator], - vol.All( # Coerce `None` to an empty list if passive is allowed - vol.IsFalse(), vol.SetTo(list) - ), - ) - else: - schema[vol.Remove("passive")] = object - - if dpt is not None: - schema[vol.Required("dpt")] = vol.In(dpt) - else: - schema[vol.Remove("dpt")] = object - - return vol.Schema(schema) - - def optional_ga_schema( - key: str, ga_schema_validator: vol.Schema + key: str, ga_selector: GASelector ) -> dict[vol.Marker, vol.Schema]: """Validate group address schema or remove key if no address is set.""" # frontend will return {key: {"write": None, "state": None}} for unused GA sets # -> remove this entirely for optional keys # if one GA is set, validate as usual return { - vol.Optional(key): ga_schema_validator, + vol.Optional(key): ga_selector, vol.Remove(key): vol.Schema( { vol.Optional("write"): None, @@ -105,7 +63,7 @@ def optional_ga_schema( vol.Required("entity"): BASE_ENTITY_SCHEMA, vol.Required("knx"): { vol.Optional("invert", default=False): bool, - vol.Required("ga_switch"): ga_schema(write_required=True), + vol.Required("ga_switch"): GASelector(write_required=True), vol.Optional("respond_to_read", default=False): bool, vol.Optional("sync_state", default=True): sync_state_validator, }, @@ -135,7 +93,7 @@ class LightColorModeSchema(StrEnum): { vol.Optional("sync_state", default=True): sync_state_validator, **optional_ga_schema( - "ga_color_temp", ga_schema(write_required=True, dpt=ColorTempModes) + "ga_color_temp", GASelector(write_required=True, dpt=ColorTempModes) ), vol.Optional("color_temp_min", default=2700): vol.All( vol.Coerce(int), vol.Range(min=1) @@ -150,11 +108,11 @@ class LightColorModeSchema(StrEnum): _DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( { vol.Required("_light_color_mode_schema"): LightColorModeSchema.DEFAULT.value, - vol.Required("ga_switch"): ga_schema(write_required=True), - **optional_ga_schema("ga_brightness", ga_schema(write_required=True)), + vol.Required("ga_switch"): GASelector(write_required=True), + **optional_ga_schema("ga_brightness", GASelector(write_required=True)), **optional_ga_schema( "ga_color", - ga_schema(write_required=True, dpt=LightColorMode), + GASelector(write_required=True, dpt=LightColorMode), ), } ) @@ -162,26 +120,26 @@ class LightColorModeSchema(StrEnum): _INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( { vol.Required("_light_color_mode_schema"): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema("ga_switch", ga_schema(write_required=True)), - **optional_ga_schema("ga_brightness", ga_schema(write_required=True)), - vol.Required("ga_red_brightness"): ga_schema(write_required=True), - **optional_ga_schema("ga_red_switch", ga_schema(write_required=False)), - vol.Required("ga_green_brightness"): ga_schema(write_required=True), - **optional_ga_schema("ga_green_switch", ga_schema(write_required=False)), - vol.Required("ga_blue_brightness"): ga_schema(write_required=True), - **optional_ga_schema("ga_blue_switch", ga_schema(write_required=False)), - **optional_ga_schema("ga_white_brightness", ga_schema(write_required=True)), - **optional_ga_schema("ga_white_switch", ga_schema(write_required=False)), + **optional_ga_schema("ga_switch", GASelector(write_required=True)), + **optional_ga_schema("ga_brightness", GASelector(write_required=True)), + vol.Required("ga_red_brightness"): GASelector(write_required=True), + **optional_ga_schema("ga_red_switch", GASelector(write_required=False)), + vol.Required("ga_green_brightness"): GASelector(write_required=True), + **optional_ga_schema("ga_green_switch", GASelector(write_required=False)), + vol.Required("ga_blue_brightness"): GASelector(write_required=True), + **optional_ga_schema("ga_blue_switch", GASelector(write_required=False)), + **optional_ga_schema("ga_white_brightness", GASelector(write_required=True)), + **optional_ga_schema("ga_white_switch", GASelector(write_required=False)), } ) _HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( { vol.Required("_light_color_mode_schema"): LightColorModeSchema.HSV.value, - vol.Required("ga_switch"): ga_schema(write_required=True), - vol.Required("ga_brightness"): ga_schema(write_required=True), - vol.Required("ga_hue"): ga_schema(write_required=True), - vol.Required("ga_saturation"): ga_schema(write_required=True), + vol.Required("ga_switch"): GASelector(write_required=True), + vol.Required("ga_brightness"): GASelector(write_required=True), + vol.Required("ga_hue"): GASelector(write_required=True), + vol.Required("ga_saturation"): GASelector(write_required=True), } ) diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py new file mode 100644 index 00000000000000..c6572db8f6d490 --- /dev/null +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -0,0 +1,80 @@ +"""Selectors for KNX.""" + +from enum import Enum +from typing import Any + +import voluptuous as vol + +from ..validation import ga_validator, maybe_ga_validator + + +class GASelector: + """Selector for a KNX group address structure.""" + + schema: vol.Schema + + def __init__( + self, + write: bool = True, + state: bool = True, + passive: bool = True, + write_required: bool = False, + state_required: bool = False, + dpt: type[Enum] | None = None, + ) -> None: + """Initialize the group address selector.""" + self.write = write + self.state = state + self.passive = passive + self.write_required = write_required + self.state_required = state_required + self.dpt = dpt + + self.schema = self.build_schema() + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + return self.schema(data) + + def build_schema(self) -> vol.Schema: + """Create the schema based on configuration.""" + schema: dict[vol.Marker, Any] = {} # will be modified in-place + self._add_group_addresses(schema) + self._add_passive(schema) + self._add_dpt(schema) + return vol.Schema(schema) + + def _add_group_addresses(self, schema: dict[vol.Marker, Any]) -> None: + """Add basic group address items to the schema.""" + + def add_ga_item(key: str, allowed: bool, required: bool) -> None: + """Add a group address item validator to the schema.""" + if not allowed: + schema[vol.Remove(key)] = object + return + if required: + schema[vol.Required(key)] = ga_validator + else: + schema[vol.Optional(key, default=None)] = maybe_ga_validator + + add_ga_item("write", self.write, self.write_required) + add_ga_item("state", self.state, self.state_required) + + def _add_passive(self, schema: dict[vol.Marker, Any]) -> None: + """Add passive group addresses validator to the schema.""" + if self.passive: + schema[vol.Optional("passive", default=list)] = vol.Any( + [ga_validator], + vol.All( # Coerce `None` to an empty list if passive is allowed + vol.IsFalse(), vol.SetTo(list) + ), + ) + else: + schema[vol.Remove("passive")] = object + + def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: + """Add DPT validator to the schema.""" + if self.dpt is not None: + schema[vol.Required("dpt")] = vol.In(self.dpt) + else: + schema[vol.Remove("dpt")] = object From 623c55ab26444b21755e1044548b06eb16a74e92 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 18 May 2024 10:38:37 +0200 Subject: [PATCH 41/51] Revert: Add light platfrom --- homeassistant/components/knx/const.py | 2 +- homeassistant/components/knx/light.py | 198 ++---------------- .../knx/storage/entity_store_schema.py | 97 +-------- 3 files changed, 25 insertions(+), 272 deletions(-) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 0b7b517dca556f..c63a31f0bb5479 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -153,7 +153,7 @@ class ColorTempModes(Enum): Platform.WEATHER, } -SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} +SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 314771c89b7390..b1c1681a817c91 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -19,17 +19,14 @@ LightEntity, ) from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util -from . import KNXModule from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes from .knx_entity import KnxEntity from .schema import LightSchema -from .storage.entity_store_schema import LightColorMode async def async_setup_entry( @@ -38,30 +35,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up light(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.LIGHT] - yaml_config: list[ConfigType] | None - if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT): - async_add_entities( - KnxYamlLight(knx_module.xknx, entity_config) - for entity_config in yaml_config - ) - ui_config: dict[str, ConfigType] | None - if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT): - async_add_entities( - KnxUiLight(knx_module, unique_id, config) - for unique_id, config in ui_config.items() - ) - - @callback - def add_new_ui_light(unique_id: str, config: dict[str, Any]) -> None: - """Add KNX entity at runtime.""" - async_add_entities([KnxUiLight(knx_module, unique_id, config)]) + async_add_entities(KNXLight(xknx, entity_config) for entity_config in config) - knx_module.config_store.async_add_entity[Platform.LIGHT] = add_new_ui_light - -def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight: +def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: """Return a KNX Light device to be used within XKNX.""" def individual_color_addresses(color: str, feature: str) -> Any | None: @@ -171,111 +151,29 @@ def individual_color_addresses(color: str, feature: str) -> Any | None: ) -def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight: - """Return a KNX Light device to be used within XKNX.""" +class KNXLight(KnxEntity, LightEntity): + """Representation of a KNX light.""" - def get_write(key: str) -> str | None: - """Get the write group address.""" - return knx_config[key]["write"] if key in knx_config else None + _device: XknxLight - def get_state(key: str) -> list[Any] | None: - """Get the state group address.""" + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize of KNX light.""" + super().__init__(_create_light(xknx, config)) + self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] + self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = self._device_unique_id() + + def _device_unique_id(self) -> str: + """Return unique id for this device.""" + if self._device.switch.group_address is not None: + return f"{self._device.switch.group_address}" return ( - [knx_config[key]["state"], *knx_config[key]["passive"]] - if key in knx_config - else None + f"{self._device.red.brightness.group_address}_" + f"{self._device.green.brightness.group_address}_" + f"{self._device.blue.brightness.group_address}" ) - def get_dpt(key: str) -> str | None: - """Get the DPT.""" - return knx_config[key].get("dpt") if key in knx_config else None - - group_address_tunable_white = None - group_address_tunable_white_state = None - group_address_color_temp = None - group_address_color_temp_state = None - color_temperature_type = ColorTemperatureType.UINT_2_BYTE - if ga_color_temp := knx_config.get("ga_color_temp"): - if ga_color_temp["dpt"] == ColorTempModes.RELATIVE: - group_address_tunable_white = ga_color_temp["write"] - group_address_tunable_white_state = [ - ga_color_temp["state"], - *ga_color_temp["passive"], - ] - else: - # absolute uint or float - group_address_color_temp = ga_color_temp["write"] - group_address_color_temp_state = [ - ga_color_temp["state"], - *ga_color_temp["passive"], - ] - if ga_color_temp["dpt"] == ColorTempModes.ABSOLUTE_FLOAT: - color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - - _color_dpt = get_dpt("ga_color") - return XknxLight( - xknx, - name=name, - group_address_switch=get_write("ga_switch"), - group_address_switch_state=get_state("ga_switch"), - group_address_brightness=get_write("ga_brightness"), - group_address_brightness_state=get_state("ga_brightness"), - group_address_color=get_write("ga_color") - if _color_dpt == LightColorMode.RGB - else None, - group_address_color_state=get_state("ga_color") - if _color_dpt == LightColorMode.RGB - else None, - group_address_rgbw=get_write("ga_color") - if _color_dpt == LightColorMode.RGBW - else None, - group_address_rgbw_state=get_state("ga_color") - if _color_dpt == LightColorMode.RGBW - else None, - group_address_hue=get_write("ga_hue"), - group_address_hue_state=get_state("ga_hue"), - group_address_saturation=get_write("ga_saturation"), - group_address_saturation_state=get_state("ga_saturation"), - group_address_xyy_color=get_write("ga_color") - if _color_dpt == LightColorMode.XYY - else None, - group_address_xyy_color_state=get_write("ga_color") - if _color_dpt == LightColorMode.XYY - else None, - group_address_tunable_white=group_address_tunable_white, - group_address_tunable_white_state=group_address_tunable_white_state, - group_address_color_temperature=group_address_color_temp, - group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=get_write("ga_red_switch"), - group_address_switch_red_state=get_state("ga_red_switch"), - group_address_brightness_red=get_write("ga_red_brightness"), - group_address_brightness_red_state=get_state("ga_red_brightness"), - group_address_switch_green=get_write("ga_green_switch"), - group_address_switch_green_state=get_state("ga_green_switch"), - group_address_brightness_green=get_write("ga_green_brightness"), - group_address_brightness_green_state=get_state("ga_green_brightness"), - group_address_switch_blue=get_write("ga_blue_switch"), - group_address_switch_blue_state=get_state("ga_blue_switch"), - group_address_brightness_blue=get_write("ga_blue_brightness"), - group_address_brightness_blue_state=get_state("ga_blue_brightness"), - group_address_switch_white=get_write("ga_white_switch"), - group_address_switch_white_state=get_state("ga_white_switch"), - group_address_brightness_white=get_write("ga_white_brightness"), - group_address_brightness_white_state=get_state("ga_white_brightness"), - color_temperature_type=color_temperature_type, - min_kelvin=knx_config["color_temp_min"], - max_kelvin=knx_config["color_temp_max"], - sync_state=knx_config["sync_state"], - ) - - -class _KnxLight(KnxEntity, LightEntity): - """Representation of a KNX light.""" - - _attr_max_color_temp_kelvin: int - _attr_min_color_temp_kelvin: int - _device: XknxLight - @property def is_on(self) -> bool: """Return true if light is on.""" @@ -494,53 +392,3 @@ async def set_color( async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._device.set_off() - - -class KnxYamlLight(_KnxLight): - """Representation of a KNX light.""" - - _device: XknxLight - - def __init__(self, xknx: XKNX, config: ConfigType) -> None: - """Initialize of KNX light.""" - super().__init__(_create_yaml_light(xknx, config)) - self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] - self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = self._device_unique_id() - - def _device_unique_id(self) -> str: - """Return unique id for this device.""" - if self._device.switch.group_address is not None: - return f"{self._device.switch.group_address}" - return ( - f"{self._device.red.brightness.group_address}_" - f"{self._device.green.brightness.group_address}_" - f"{self._device.blue.brightness.group_address}" - ) - - -class KnxUiLight(_KnxLight): - """Representation of a KNX light.""" - - _device: XknxLight - - def __init__( - self, knx_module: KNXModule, unique_id: str, config: ConfigType - ) -> None: - """Initialize of KNX light.""" - super().__init__( - _create_ui_light( - knx_module.xknx, config["knx"], config["entity"][CONF_NAME] - ) - ) - self._attr_max_color_temp_kelvin: int = config["knx"]["color_temp_max"] - self._attr_min_color_temp_kelvin: int = config["knx"]["color_temp_min"] - - self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] - self._attr_unique_id = unique_id - if device_info := config["entity"].get("device_info"): - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) - self._attr_has_entity_name = True - - knx_module.config_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index b6318357528f38..28ba3e97347756 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -1,14 +1,12 @@ """KNX entity store schema.""" -from enum import StrEnum, unique - import voluptuous as vol from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA -from ..const import SUPPORTED_PLATFORMS_UI, ColorTempModes +from ..const import SUPPORTED_PLATFORMS_UI from ..validation import sync_state_validator from .knx_selector import GASelector @@ -71,96 +69,6 @@ def optional_ga_schema( ) -@unique -class LightColorMode(StrEnum): - """Enum for light color mode.""" - - RGB = "232.600" - RGBW = "251.600" - XYY = "242.600" - - -@unique -class LightColorModeSchema(StrEnum): - """Enum for light color mode.""" - - DEFAULT = "default" - INDIVIDUAL = "individual" - HSV = "hsv" - - -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional("sync_state", default=True): sync_state_validator, - **optional_ga_schema( - "ga_color_temp", GASelector(write_required=True, dpt=ColorTempModes) - ), - vol.Optional("color_temp_min", default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Optional("color_temp_max", default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required("_light_color_mode_schema"): LightColorModeSchema.DEFAULT.value, - vol.Required("ga_switch"): GASelector(write_required=True), - **optional_ga_schema("ga_brightness", GASelector(write_required=True)), - **optional_ga_schema( - "ga_color", - GASelector(write_required=True, dpt=LightColorMode), - ), - } -) - -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required("_light_color_mode_schema"): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema("ga_switch", GASelector(write_required=True)), - **optional_ga_schema("ga_brightness", GASelector(write_required=True)), - vol.Required("ga_red_brightness"): GASelector(write_required=True), - **optional_ga_schema("ga_red_switch", GASelector(write_required=False)), - vol.Required("ga_green_brightness"): GASelector(write_required=True), - **optional_ga_schema("ga_green_switch", GASelector(write_required=False)), - vol.Required("ga_blue_brightness"): GASelector(write_required=True), - **optional_ga_schema("ga_blue_switch", GASelector(write_required=False)), - **optional_ga_schema("ga_white_brightness", GASelector(write_required=True)), - **optional_ga_schema("ga_white_switch", GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required("_light_color_mode_schema"): LightColorModeSchema.HSV.value, - vol.Required("ga_switch"): GASelector(write_required=True), - vol.Required("ga_brightness"): GASelector(write_required=True), - vol.Required("ga_hue"): GASelector(write_required=True), - vol.Required("ga_saturation"): GASelector(write_required=True), - } -) - - -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - "_light_color_mode_schema", - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) - -LIGHT_SCHEMA = vol.Schema( - { - vol.Required("entity"): BASE_ENTITY_SCHEMA, - vol.Required("knx"): LIGHT_KNX_SCHEMA, - } -) - ENTITY_STORE_DATA_SCHEMA = vol.All( vol.Schema( { @@ -178,9 +86,6 @@ class LightColorModeSchema(StrEnum): Platform.SWITCH: vol.Schema( {vol.Required("data"): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), - Platform.LIGHT: vol.Schema( - {vol.Required("data"): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA - ), }, ), ) From 89339fae023b422ec98a9ef6d5d1b5bf5c9ec864 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 18 May 2024 11:19:57 +0200 Subject: [PATCH 42/51] remove unused optional_ga_schema --- .../knx/storage/entity_store_schema.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 28ba3e97347756..978d4616868fcf 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -36,26 +36,6 @@ ) -def optional_ga_schema( - key: str, ga_selector: GASelector -) -> dict[vol.Marker, vol.Schema]: - """Validate group address schema or remove key if no address is set.""" - # frontend will return {key: {"write": None, "state": None}} for unused GA sets - # -> remove this entirely for optional keys - # if one GA is set, validate as usual - return { - vol.Optional(key): ga_selector, - vol.Remove(key): vol.Schema( - { - vol.Optional("write"): None, - vol.Optional("state"): None, - vol.Optional("passive"): vol.IsFalse(), # None or empty list - }, - extra=vol.ALLOW_EXTRA, - ), - } - - SWITCH_SCHEMA = vol.Schema( { vol.Required("entity"): BASE_ENTITY_SCHEMA, From 468ec6c3ced351c8e5d2b8f190ccbe01b8f1bf03 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 18 May 2024 14:31:21 +0200 Subject: [PATCH 43/51] Test GASelector --- .../components/knx/storage/knx_selector.py | 2 +- tests/components/knx/test_knx_selectors.py | 122 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/components/knx/test_knx_selectors.py diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index c6572db8f6d490..100df4d1acd2d8 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -75,6 +75,6 @@ def _add_passive(self, schema: dict[vol.Marker, Any]) -> None: def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: """Add DPT validator to the schema.""" if self.dpt is not None: - schema[vol.Required("dpt")] = vol.In(self.dpt) + schema[vol.Required("dpt")] = vol.In(item.value for item in self.dpt) else: schema[vol.Remove("dpt")] = object diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py new file mode 100644 index 00000000000000..432a0fb9f8030d --- /dev/null +++ b/tests/components/knx/test_knx_selectors.py @@ -0,0 +1,122 @@ +"""Test KNX selectors.""" + +import pytest +import voluptuous as vol + +from homeassistant.components.knx.const import ColorTempModes +from homeassistant.components.knx.storage.knx_selector import GASelector + +INVALID = "invalid" + + +@pytest.mark.parametrize( + ("selector_config", "data", "expected"), + [ + ( + {}, + {}, + {"write": None, "state": None, "passive": []}, + ), + ( + {}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + ( + {}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None, "passive": ["1/2/3"]}, + ), + ( + {}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + ), + ( + {"write": False}, + {"write": "1/2/3"}, + {"state": None, "passive": []}, + ), + ( + {"write": False}, + {"state": "1/2/3"}, + {"state": "1/2/3", "passive": []}, + ), + ( + {"write": False}, + {"passive": ["1/2/3"]}, + {"state": None, "passive": ["1/2/3"]}, + ), + ( + {"passive": False}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None}, + ), + ( + {"passive": False}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None}, + ), + # required keys + ( + {"write_required": True}, + {}, + INVALID, + ), + ( + {"state_required": True}, + {}, + INVALID, + ), + ( + {"write_required": True}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {"state_required": True}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + ( + {"write_required": True}, + {"state": "1/2/3"}, + INVALID, + ), + ( + {"state_required": True}, + {"write": "1/2/3"}, + INVALID, + ), + # dpt key + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3"}, + INVALID, + ), + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "dpt": "7.600"}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, + ), + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, + INVALID, + ), + ], +) +def test_ga_selector(selector_config, data, expected): + """Test GASelector.""" + selector = GASelector(**selector_config) + if expected == INVALID: + with pytest.raises(vol.Invalid): + selector(data) + else: + result = selector(data) + assert result == expected From 5f40e7fbadb79e975ccfb243541c5693ffc168fa Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 20 Jun 2024 23:20:37 +0200 Subject: [PATCH 44/51] lint tests --- tests/components/knx/test_config_store.py | 2 +- tests/components/knx/test_switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index b8a5d899d001f7..49a130339fdf93 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -93,7 +93,7 @@ async def test_create_entity_error( await client.send_json_auto_id( { "type": "knx/create_entity", - "platform": Platform.TTS, # "tts" is not a supported platform (and is unlikely te ever be) + "platform": Platform.TTS, # "tts" is not a supported platform (and is unlikely to ever be) "data": { "entity": {"name": "Test invalid platform"}, "knx": {"ga_switch": {"write": "1/2/3"}}, diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index f3b8e6b7fca9c4..bc0a6b27675fe6 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -153,7 +153,7 @@ async def test_switch_ui_create( hass: HomeAssistant, knx: KNXTestKit, create_ui_entity: KnxEntityGenerator, -): +) -> None: """Test creating a switch.""" await knx.setup_integration({}) await create_ui_entity( From 9280e815b71e819077910dd2a5b11d09eabd2335 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 18 Jul 2024 23:24:22 +0200 Subject: [PATCH 45/51] Review --- tests/components/knx/conftest.py | 9 ++++----- tests/components/knx/test_config_store.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 697e8b5bbb34da..749d1c4252ab87 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import json from typing import Any from unittest.mock import DEFAULT, AsyncMock, Mock, patch @@ -41,11 +40,11 @@ from . import KnxEntityGenerator -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import WebSocketGenerator -FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) -FIXTURE_CONFIG_STORAGE_DATA = json.loads(load_fixture("config_store.json", KNX_DOMAIN)) +FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", KNX_DOMAIN) +FIXTURE_CONFIG_STORAGE_DATA = load_json_object_fixture("config_store.json", KNX_DOMAIN) class KNXTestKit: @@ -299,7 +298,7 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: @pytest.fixture -def load_config_store(hass_storage): +def load_config_store(hass_storage: dict[str, Any]) -> None: """Mock KNX config store data.""" hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 49a130339fdf93..116f4b5d83971b 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -1,4 +1,4 @@ -"""Test KNX devices.""" +"""Test KNX config store.""" from typing import Any From 8ca664f74774c84c7857fe96c59885f5c222dc41 Mon Sep 17 00:00:00 2001 From: farmio Date: Thu, 18 Jul 2024 23:35:10 +0200 Subject: [PATCH 46/51] group entities before adding --- homeassistant/components/knx/switch.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c1c54095f9310f..832ee01a30c01e 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -38,18 +38,19 @@ async def async_setup_entry( """Set up switch(es) for KNX platform.""" knx_module: KNXModule = hass.data[DOMAIN] - yaml_config: list[ConfigType] | None + entities: list[KnxEntity] = [] if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH): - async_add_entities( + entities.extend( KnxYamlSwitch(knx_module.xknx, entity_config) for entity_config in yaml_config ) - ui_config: dict[str, ConfigType] | None if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH): - async_add_entities( + entities.extend( KnxUiSwitch(knx_module, unique_id, config) for unique_id, config in ui_config.items() ) + if entities: + async_add_entities(entities) @callback def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None: From 4d6b6944571c18f7aced890bcb158c294455e182 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 19 Jul 2024 00:07:07 +0200 Subject: [PATCH 47/51] fix / ignore mypy --- homeassistant/components/knx/storage/knx_selector.py | 2 +- homeassistant/components/knx/websocket.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 100df4d1acd2d8..4c59825ade9863 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -75,6 +75,6 @@ def _add_passive(self, schema: dict[vol.Marker, Any]) -> None: def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: """Add DPT validator to the schema.""" if self.dpt is not None: - schema[vol.Required("dpt")] = vol.In(item.value for item in self.dpt) + schema[vol.Required("dpt")] = vol.In([item.value for item in self.dpt]) else: schema[vol.Remove("dpt")] = object diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index d884b5865ff1b3..81f7f351a2300e 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -239,7 +239,7 @@ def forward_telegram(telegram: TelegramDict) -> None: @websocket_api.websocket_command( { vol.Required("type"): "knx/validate_entity", - **CREATE_ENTITY_BASE_SCHEMA, + **CREATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] } ) @callback @@ -263,7 +263,7 @@ def ws_validate_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/create_entity", - **CREATE_ENTITY_BASE_SCHEMA, + **CREATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] } ) @websocket_api.async_response @@ -299,7 +299,7 @@ async def ws_create_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/update_entity", - **UPDATE_ENTITY_BASE_SCHEMA, + **UPDATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] } ) @websocket_api.async_response From e7b44a7a9a1c4ce4a202844d641ec682dca79d7c Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 19 Jul 2024 06:54:48 +0200 Subject: [PATCH 48/51] always has_entity_name --- homeassistant/components/knx/switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 832ee01a30c01e..c09293c25369ff 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -111,6 +111,8 @@ def __init__(self, xknx: XKNX, config: ConfigType) -> None: class KnxUiSwitch(_KnxSwitch): """Representation of a KNX switch configured from UI.""" + _attr_has_entity_name = True + def __init__( self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] ) -> None: @@ -132,6 +134,5 @@ def __init__( self._attr_unique_id = unique_id if device_info := config["entity"].get("device_info"): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) - self._attr_has_entity_name = True knx_module.config_store.entities[unique_id] = self From 48fe0948d37e72aedf06415f4949a63c568a51d2 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 19 Jul 2024 07:19:40 +0200 Subject: [PATCH 49/51] Entity name: check for empty string when no device --- homeassistant/components/knx/storage/entity_store_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 978d4616868fcf..0d8ad4f088f954 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -21,7 +21,7 @@ vol.Any( vol.Schema( { - vol.Required("name"): str, + vol.Required("name"): vol.All(str, vol.IsTrue()), }, extra=vol.ALLOW_EXTRA, ), From d0fc065949d326bfd813d5583ce66d9dac0a7f67 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 19 Jul 2024 13:53:09 +0200 Subject: [PATCH 50/51] use constants instead of strings in schema --- .../components/knx/storage/config_store.py | 7 +-- homeassistant/components/knx/storage/const.py | 14 +++++ .../knx/storage/entity_store_schema.py | 54 +++++++++++-------- .../components/knx/storage/knx_selector.py | 13 ++--- homeassistant/components/knx/switch.py | 32 +++++++---- homeassistant/components/knx/websocket.py | 20 +++---- 6 files changed, 92 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/knx/storage/const.py diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 9f1ef6c454664e..7ea61e1dd3e63b 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -5,13 +5,14 @@ from typing import TYPE_CHECKING, Any, Final, TypedDict from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.util.ulid import ulid_now from ..const import DOMAIN +from .const import CONF_DATA if TYPE_CHECKING: from ..knx_entity import KnxEntity @@ -84,8 +85,8 @@ def get_entity_config(self, entity_id: str) -> dict[str, Any]: raise ConfigStoreException(f"Entity not found: {entity_id}") try: return { - "platform": entry.domain, - "data": self.data["entities"][entry.domain][entry.unique_id], + CONF_PLATFORM: entry.domain, + CONF_DATA: self.data["entities"][entry.domain][entry.unique_id], } except KeyError as err: raise ConfigStoreException(f"Entity data not found: {entity_id}") from err diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py new file mode 100644 index 00000000000000..6453b77ed3b937 --- /dev/null +++ b/homeassistant/components/knx/storage/const.py @@ -0,0 +1,14 @@ +"""Constants used in KNX config store.""" + +from typing import Final + +CONF_DATA: Final = "data" +CONF_ENTITY: Final = "entity" +CONF_DEVICE_INFO: Final = "device_info" +CONF_GA_WRITE: Final = "write" +CONF_GA_STATE: Final = "state" +CONF_GA_PASSIVE: Final = "passive" +CONF_DPT: Final = "dpt" + + +CONF_GA_SWITCH: Final = "ga_switch" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 0d8ad4f088f954..b25fa89f934c70 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -2,32 +2,45 @@ import voluptuous as vol -from homeassistant.const import Platform +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + Platform, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA -from ..const import SUPPORTED_PLATFORMS_UI +from ..const import ( + CONF_INVERT, + CONF_RESPOND_TO_READ, + CONF_SYNC_STATE, + DOMAIN, + SUPPORTED_PLATFORMS_UI, +) from ..validation import sync_state_validator +from .const import CONF_DATA, CONF_DEVICE_INFO, CONF_ENTITY, CONF_GA_SWITCH from .knx_selector import GASelector BASE_ENTITY_SCHEMA = vol.All( { - vol.Optional("name", default=None): vol.Maybe(str), - vol.Optional("device_info", default=None): vol.Maybe(str), - vol.Optional("entity_category", default=None): vol.Any( + vol.Optional(CONF_NAME, default=None): vol.Maybe(str), + vol.Optional(CONF_DEVICE_INFO, default=None): vol.Maybe(str), + vol.Optional(CONF_ENTITY_CATEGORY, default=None): vol.Any( ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) ), }, vol.Any( vol.Schema( { - vol.Required("name"): vol.All(str, vol.IsTrue()), + vol.Required(CONF_NAME): vol.All(str, vol.IsTrue()), }, extra=vol.ALLOW_EXTRA, ), vol.Schema( { - vol.Required("device_info"): str, + vol.Required(CONF_DEVICE_INFO): str, }, extra=vol.ALLOW_EXTRA, ), @@ -35,15 +48,14 @@ ), ) - SWITCH_SCHEMA = vol.Schema( { - vol.Required("entity"): BASE_ENTITY_SCHEMA, - vol.Required("knx"): { - vol.Optional("invert", default=False): bool, - vol.Required("ga_switch"): GASelector(write_required=True), - vol.Optional("respond_to_read", default=False): bool, - vol.Optional("sync_state", default=True): sync_state_validator, + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Optional(CONF_INVERT, default=False): bool, + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, }, } ) @@ -52,30 +64,30 @@ ENTITY_STORE_DATA_SCHEMA = vol.All( vol.Schema( { - vol.Required("platform"): vol.All( + vol.Required(CONF_PLATFORM): vol.All( vol.Coerce(Platform), vol.In(SUPPORTED_PLATFORMS_UI), ), - vol.Required("data"): dict, + vol.Required(CONF_DATA): dict, }, extra=vol.ALLOW_EXTRA, ), cv.key_value_schemas( - "platform", + CONF_PLATFORM, { Platform.SWITCH: vol.Schema( - {vol.Required("data"): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA + {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), }, ), ) CREATE_ENTITY_BASE_SCHEMA = { - vol.Required("platform"): str, - vol.Required("data"): dict, # validated by ENTITY_STORE_DATA_SCHEMA for platform + vol.Required(CONF_PLATFORM): str, + vol.Required(CONF_DATA): dict, # validated by ENTITY_STORE_DATA_SCHEMA for platform } UPDATE_ENTITY_BASE_SCHEMA = { - vol.Required("entity_id"): str, + vol.Required(CONF_ENTITY_ID): str, **CREATE_ENTITY_BASE_SCHEMA, } diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 4c59825ade9863..396cde67fbdce3 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,6 +6,7 @@ import voluptuous as vol from ..validation import ga_validator, maybe_ga_validator +from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE class GASelector: @@ -57,24 +58,24 @@ def add_ga_item(key: str, allowed: bool, required: bool) -> None: else: schema[vol.Optional(key, default=None)] = maybe_ga_validator - add_ga_item("write", self.write, self.write_required) - add_ga_item("state", self.state, self.state_required) + add_ga_item(CONF_GA_WRITE, self.write, self.write_required) + add_ga_item(CONF_GA_STATE, self.state, self.state_required) def _add_passive(self, schema: dict[vol.Marker, Any]) -> None: """Add passive group addresses validator to the schema.""" if self.passive: - schema[vol.Optional("passive", default=list)] = vol.Any( + schema[vol.Optional(CONF_GA_PASSIVE, default=list)] = vol.Any( [ga_validator], vol.All( # Coerce `None` to an empty list if passive is allowed vol.IsFalse(), vol.SetTo(list) ), ) else: - schema[vol.Remove("passive")] = object + schema[vol.Remove(CONF_GA_PASSIVE)] = object def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: """Add DPT validator to the schema.""" if self.dpt is not None: - schema[vol.Required("dpt")] = vol.In([item.value for item in self.dpt]) + schema[vol.Required(CONF_DPT)] = vol.In([item.value for item in self.dpt]) else: - schema[vol.Remove("dpt")] = object + schema[vol.Remove(CONF_DPT)] = object diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c09293c25369ff..94f5592db9092c 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -25,9 +25,23 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import CONF_RESPOND_TO_READ, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from .const import ( + CONF_INVERT, + CONF_RESPOND_TO_READ, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) from .knx_entity import KnxEntity from .schema import SwitchSchema +from .storage.const import ( + CONF_DEVICE_INFO, + CONF_ENTITY, + CONF_GA_PASSIVE, + CONF_GA_STATE, + CONF_GA_SWITCH, + CONF_GA_WRITE, +) async def async_setup_entry( @@ -120,19 +134,19 @@ def __init__( super().__init__( device=XknxSwitch( knx_module.xknx, - name=config["entity"][CONF_NAME], - group_address=config["knx"]["ga_switch"]["write"], + name=config[CONF_ENTITY][CONF_NAME], + group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE], group_address_state=[ - config["knx"]["ga_switch"]["state"], - *config["knx"]["ga_switch"]["passive"], + config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE], ], - respond_to_read=config["knx"][CONF_RESPOND_TO_READ], - invert=config["knx"]["invert"], + respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ], + invert=config[DOMAIN][CONF_INVERT], ) ) - self._attr_entity_category = config["entity"][CONF_ENTITY_CATEGORY] + self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY] self._attr_unique_id = unique_id - if device_info := config["entity"].get("device_info"): + if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) knx_module.config_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 81f7f351a2300e..3f2fe74c705c83 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -10,6 +10,7 @@ from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import UNDEFINED @@ -17,6 +18,7 @@ from .const import DOMAIN from .storage.config_store import ConfigStoreException +from .storage.const import CONF_DATA from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, UPDATE_ENTITY_BASE_SCHEMA, @@ -282,8 +284,8 @@ async def ws_create_entity( try: entity_id = await knx.config_store.create_entity( # use validation result so defaults are applied - validated_data["platform"], - validated_data["data"], + validated_data[CONF_PLATFORM], + validated_data[CONF_DATA], ) except ConfigStoreException as err: connection.send_error( @@ -317,9 +319,9 @@ async def ws_update_entity( knx: KNXModule = hass.data[DOMAIN] try: await knx.config_store.update_entity( - validated_data["platform"], - validated_data["entity_id"], - validated_data["data"], + validated_data[CONF_PLATFORM], + validated_data[CONF_ENTITY_ID], + validated_data[CONF_DATA], ) except ConfigStoreException as err: connection.send_error( @@ -335,7 +337,7 @@ async def ws_update_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_entity", - vol.Required("entity_id"): str, + vol.Required(CONF_ENTITY_ID): str, } ) @websocket_api.async_response @@ -347,7 +349,7 @@ async def ws_delete_entity( """Delete entity from entity store and remove it.""" knx: KNXModule = hass.data[DOMAIN] try: - await knx.config_store.delete_entity(msg["entity_id"]) + await knx.config_store.delete_entity(msg[CONF_ENTITY_ID]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) @@ -380,7 +382,7 @@ def ws_get_entity_entries( @websocket_api.websocket_command( { vol.Required("type"): "knx/get_entity_config", - vol.Required("entity_id"): str, + vol.Required(CONF_ENTITY_ID): str, } ) @callback @@ -392,7 +394,7 @@ def ws_get_entity_config( """Get entity configuration from entity store.""" knx: KNXModule = hass.data[DOMAIN] try: - config_info = knx.config_store.get_entity_config(msg["entity_id"]) + config_info = knx.config_store.get_entity_config(msg[CONF_ENTITY_ID]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) From 0f71ede9bff80cef0dccbdd147e86b1274f3486f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 21 Jul 2024 19:55:34 +0200 Subject: [PATCH 51/51] Fix mypy errors for voluptuous schemas --- homeassistant/components/knx/storage/entity_store_schema.py | 5 +++-- homeassistant/components/knx/websocket.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index b25fa89f934c70..e2f9e786300e45 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,6 +11,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from homeassistant.helpers.typing import VolDictType, VolSchemaType from ..const import ( CONF_INVERT, @@ -61,7 +62,7 @@ ) -ENTITY_STORE_DATA_SCHEMA = vol.All( +ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( vol.Schema( { vol.Required(CONF_PLATFORM): vol.All( @@ -82,7 +83,7 @@ ), ) -CREATE_ENTITY_BASE_SCHEMA = { +CREATE_ENTITY_BASE_SCHEMA: VolDictType = { vol.Required(CONF_PLATFORM): str, vol.Required(CONF_DATA): dict, # validated by ENTITY_STORE_DATA_SCHEMA for platform } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 3f2fe74c705c83..bca1b119ef7739 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -241,7 +241,7 @@ def forward_telegram(telegram: TelegramDict) -> None: @websocket_api.websocket_command( { vol.Required("type"): "knx/validate_entity", - **CREATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] + **CREATE_ENTITY_BASE_SCHEMA, } ) @callback @@ -265,7 +265,7 @@ def ws_validate_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/create_entity", - **CREATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] + **CREATE_ENTITY_BASE_SCHEMA, } ) @websocket_api.async_response @@ -301,7 +301,7 @@ async def ws_create_entity( @websocket_api.websocket_command( { vol.Required("type"): "knx/update_entity", - **UPDATE_ENTITY_BASE_SCHEMA, # type: ignore[dict-item] + **UPDATE_ENTITY_BASE_SCHEMA, } ) @websocket_api.async_response