From 658d3cf06e107e3003cf63943af5537b8833355c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Jan 2025 15:16:58 +0100 Subject: [PATCH] Add support for KNX UI to create BinarySensor entities (#136703) --- homeassistant/components/knx/binary_sensor.py | 130 +++++++++++++----- homeassistant/components/knx/const.py | 8 +- homeassistant/components/knx/schema.py | 10 +- homeassistant/components/knx/storage/const.py | 1 + .../knx/storage/entity_store_schema.py | 32 ++++- tests/components/knx/test_binary_sensor.py | 57 +++++++- 6 files changed, 191 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 96438df96d7..c629860351c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -18,14 +18,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY -from .entity import KnxYamlEntity -from .schema import BinarySensorSchema +from .const import ( + ATTR_COUNTER, + ATTR_SOURCE, + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_MODULE_KEY, +) +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE async def async_setup_entry( @@ -35,40 +49,38 @@ async def async_setup_entry( ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR] - - async_add_entities( - KNXBinarySensor(knx_module, entity_config) for entity_config in config + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BINARY_SENSOR, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiBinarySensor, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BINARY_SENSOR): + entities.extend( + KnxYamlBinarySensor(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get( + Platform.BINARY_SENSOR + ): + entities.extend( + KnxUiBinarySensor(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): + +class _KnxBinarySensor(BinarySensorEntity, RestoreEntity): """Representation of a KNX binary sensor.""" _device: XknxBinarySensor - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX binary sensor.""" - super().__init__( - knx_module=knx_module, - device=XknxBinarySensor( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS], - invert=config[BinarySensorSchema.CONF_INVERT], - sync_state=config[BinarySensorSchema.CONF_SYNC_STATE], - ignore_internal_state=config[ - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE - ], - context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT), - reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER), - ), - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_force_update = self._device.ignore_internal_state - self._attr_unique_id = str(self._device.remote_value.group_address_state) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -92,3 +104,59 @@ class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity): + """Representation of a KNX binary sensor configured from YAML.""" + + _device: XknxBinarySensor + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + device=XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_state=config[CONF_STATE_ADDRESS], + invert=config[CONF_INVERT], + sync_state=config[CONF_SYNC_STATE], + ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE], + context_timeout=config.get(CONF_CONTEXT_TIMEOUT), + reset_after=config.get(CONF_RESET_AFTER), + ), + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_force_update = self._device.ignore_internal_state + self._attr_unique_id = str(self._device.remote_value.group_address_state) + + +class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): + """Representation of a KNX binary sensor configured from UI.""" + + _device: XknxBinarySensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_state=[ + config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], + ], + sync_state=config[DOMAIN][CONF_SYNC_STATE], + invert=config[DOMAIN].get(CONF_INVERT, False), + ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), + context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), + reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + ) + self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ef35479c4e..b403018dae3 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -67,6 +67,8 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" +CONF_CONTEXT_TIMEOUT: Final = "context_timeout" +CONF_IGNORE_INTERNAL_STATE: Final = "ignore_internal_state" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" @@ -156,7 +158,11 @@ SUPPORTED_PLATFORMS_YAML: Final = { Platform.WEATHER, } -SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} +SUPPORTED_PLATFORMS_UI: Final = { + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SWITCH, +} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 9311046e410..5c83da58c3a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -45,6 +45,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, CONF_KNX_EXPOSE, CONF_PAYLOAD_LENGTH, @@ -211,14 +213,6 @@ class BinarySensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX binary sensors.""" PLATFORM = Platform.BINARY_SENSOR - - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_SYNC_STATE = CONF_SYNC_STATE - CONF_INVERT = CONF_INVERT - CONF_IGNORE_INTERNAL_STATE = "ignore_internal_state" - CONF_CONTEXT_TIMEOUT = "context_timeout" - CONF_RESET_AFTER = CONF_RESET_AFTER - DEFAULT_NAME = "KNX Binary Sensor" ENTITY_SCHEMA = vol.All( diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 42b76a5a0fd..cf3f2bb9f95 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -10,6 +10,7 @@ CONF_GA_STATE: Final = "state" CONF_GA_PASSIVE: Final = "passive" CONF_DPT: Final = "dpt" +CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" CONF_GA_COLOR_TEMP: Final = "ga_color_temp" CONF_COLOR_TEMP_MIN: Final = "color_temp_min" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 84854d2ec85..d99ffa86f52 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,12 +11,15 @@ from homeassistant.const import ( CONF_PLATFORM, Platform, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import VolDictType, VolSchemaType from ..const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, DOMAIN, @@ -42,6 +45,7 @@ from .const import ( CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, + CONF_GA_SENSOR, CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, @@ -94,6 +98,29 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: } +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), + vol.Required(CONF_RESPOND_TO_READ, default=False): bool, + vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + }, + } +) + SWITCH_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -213,6 +240,9 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( cv.key_value_schemas( CONF_PLATFORM, { + Platform.BINARY_SENSOR: vol.Schema( + {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA + ), Platform.SWITCH: vol.Schema( {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index dbb8d2ee832..4b58801a8a0 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test KNX binary sensor.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory +import pytest -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import BinarySensorSchema from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -12,10 +21,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import ( @@ -60,7 +71,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: { CONF_NAME: "test_invert", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, }, ] } @@ -113,7 +124,7 @@ async def test_binary_sensor_ignore_internal_state( { CONF_NAME: "test_ignore", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_IGNORE_INTERNAL_STATE: True, CONF_SYNC_STATE: False, }, ] @@ -156,7 +167,7 @@ async def test_binary_sensor_counter( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_CONTEXT_TIMEOUT: context_timeout, + CONF_CONTEXT_TIMEOUT: context_timeout, CONF_SYNC_STATE: False, }, ] @@ -220,7 +231,7 @@ async def test_binary_sensor_reset( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_RESET_AFTER: 1, CONF_SYNC_STATE: False, }, ] @@ -279,7 +290,7 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, CONF_SYNC_STATE: False, }, ] @@ -295,3 +306,37 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: await knx.receive_write(_ADDRESS, True) state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF + + +@pytest.mark.parametrize( + ("knx_data"), + [ + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + }, + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + "invert": True, + }, + ], +) +async def test_binary_sensor_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], +) -> None: + """Test creating a binary sensor.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.BINARY_SENSOR, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", not knx_data.get("invert")) + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON