diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index bbf93e57b6c..0b7c9abdbc5 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = { Platform.FAN, Platform.DATETIME, Platform.LIGHT, + Platform.SCENE, Platform.SENSOR, Platform.SWITCH, Platform.TIME, @@ -227,3 +228,9 @@ class FanConf: """Common config keys for fan.""" MAX_STEP: Final = "max_step" + + +class SceneConf: + """Common config keys for scene.""" + + SCENE_NUMBER: Final = "scene_number" diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index bc997f617b3..fd16aba1ef8 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -10,13 +10,23 @@ from homeassistant import config_entries from homeassistant.components.scene import BaseScene from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType -from .const import KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, SceneConf +from .entity import ( + KnxUiEntity, + KnxUiEntityPlatformController, + KnxYamlEntity, + _KnxEntityBase, +) from .knx_module import KNXModule from .schema import SceneSchema +from .storage.const import CONF_ENTITY, CONF_GA_SCENE +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -26,18 +36,53 @@ async def async_setup_entry( ) -> None: """Set up scene(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.SCENE] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.SCENE, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiScene, + ), + ) - async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.SCENE): + entities.extend( + KnxYamlScene(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.SCENE): + entities.extend( + KnxUiScene(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXScene(KnxYamlEntity, BaseScene): +class _KnxScene(BaseScene, _KnxEntityBase): """Representation of a KNX scene.""" _device: XknxScene + async def _async_activate(self, **kwargs: Any) -> None: + """Activate the scene.""" + await self._device.run() + + def after_update_callback(self, device: XknxDevice) -> None: + """Call after device was updated.""" + self._async_record_activation() + super().after_update_callback(device) + + +class KnxYamlScene(_KnxScene, KnxYamlEntity): + """Representation of a KNX scene configured from YAML.""" + + _device: XknxScene + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Init KNX scene.""" + """Initialize KNX scene.""" super().__init__( knx_module=knx_module, device=XknxScene( @@ -52,11 +97,28 @@ class KNXScene(KnxYamlEntity, BaseScene): f"{self._device.scene_value.group_address}_{self._device.scene_number}" ) - async def _async_activate(self, **kwargs: Any) -> None: - """Activate the scene.""" - await self._device.run() - def after_update_callback(self, device: XknxDevice) -> None: - """Call after device was updated.""" - self._async_record_activation() - super().after_update_callback(device) +class KnxUiScene(_KnxScene, KnxUiEntity): + """Representation of a KNX scene configured from the UI.""" + + _device: XknxScene + + def __init__( + self, + knx_module: KNXModule, + unique_id: str, + config: ConfigType, + ) -> None: + """Initialize KNX scene.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + knx_conf = ConfigExtractor(config[DOMAIN]) + self._device = XknxScene( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address=knx_conf.get_write(CONF_GA_SCENE), + scene_number=knx_conf.get(SceneConf.SCENE_NUMBER), + ) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 3ded33494cc..50f56e33099 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -61,6 +61,7 @@ from .const import ( CoverConf, FanConf, FanZeroMode, + SceneConf, ) from .validation import ( backwards_compatible_xknx_climate_enum_member, @@ -822,7 +823,7 @@ class SceneSchema(KNXPlatformSchema): { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(KNX_ADDRESS): ga_list_validator, - vol.Required(CONF_SCENE_NUMBER): vol.All( + vol.Required(SceneConf.SCENE_NUMBER): vol.All( vol.Coerce(int), vol.Range(min=1, max=64) ), vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 813adb5cc53..412caf575cb 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -72,5 +72,8 @@ CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" +# Scene +CONF_GA_SCENE: Final = "ga_scene" + # Sensor CONF_ALWAYS_CALLBACK: Final = "always_callback" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 64a576f20c4..717b44c78d5 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -40,6 +40,7 @@ from ..const import ( CoverConf, FanConf, FanZeroMode, + SceneConf, ) from ..dpt import get_supported_dpts from .const import ( @@ -82,6 +83,7 @@ from .const import ( CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, + CONF_GA_SCENE, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, CONF_GA_SPEED, @@ -419,6 +421,25 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst( ), ) +SCENE_KNX_SCHEMA = vol.Schema( + { + vol.Required(CONF_GA_SCENE): GASelector( + state=False, + passive=False, + write_required=True, + valid_dpt=["17.001", "18.001"], + ), + vol.Required(SceneConf.SCENE_NUMBER): AllSerializeFirst( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=64, step=1, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + }, +) + SWITCH_KNX_SCHEMA = vol.Schema( { vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"), @@ -694,6 +715,7 @@ KNX_SCHEMA_FOR_PLATFORM = { Platform.DATETIME: DATETIME_KNX_SCHEMA, Platform.FAN: FAN_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA, + Platform.SCENE: SCENE_KNX_SCHEMA, Platform.SENSOR: SENSOR_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, Platform.TIME: TIME_KNX_SCHEMA, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 9ea12aadb52..bccec77a78d 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -774,6 +774,19 @@ } } }, + "scene": { + "description": "A KNX entity can activate a KNX scene and updates when the scene number is received.", + "knx": { + "ga_scene": { + "description": "Group address to activate a scene.", + "label": "Scene" + }, + "scene_number": { + "description": "The scene number this entity is associated with.", + "label": "Scene number" + } + } + }, "sensor": { "description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.", "knx": { diff --git a/tests/components/knx/fixtures/config_store_scene.json b/tests/components/knx/fixtures/config_store_scene.json new file mode 100644 index 00000000000..a1284a6469b --- /dev/null +++ b/tests/components/knx/fixtures/config_store_scene.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "scene": { + "knx_es_01KCXJ181N1TEDNC81WXEMXRNS": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_scene": { + "write": "1/1/1" + }, + "scene_number": 12 + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index eb440c78f5c..e5b68aba38d 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1576,6 +1576,50 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[scene] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_scene', + 'options': dict({ + 'passive': False, + 'state': False, + 'validDPTs': list([ + dict({ + 'main': 17, + 'sub': 1, + }), + dict({ + 'main': 18, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'scene_number', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 64.0, + 'min': 1.0, + 'mode': 'box', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[sensor] dict({ 'id': 1, diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index 7dc850b4843..4156657f966 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -2,10 +2,16 @@ from homeassistant.components.knx.const import KNX_ADDRESS from homeassistant.components.knx.schema import SceneSchema -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + EntityCategory, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events @@ -56,3 +62,35 @@ async def test_activate_knx_scene( # different scene number - should not be recorded await knx.receive_write("1/1/1", (0x00,)) assert len(events) == 4 + + +async def test_scene_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test creating a scene.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.SCENE, + entity_data={"name": "test"}, + knx_data={ + "ga_scene": {"write": "1/1/1"}, + "scene_number": 5, + }, + ) + # activate scene from HA + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (0x04,)) # raw scene number is 0-based + + +async def test_scene_ui_load(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test loading a scene from storage.""" + await knx.setup_integration(config_store_fixture="config_store_scene.json") + # activate scene from HA + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (0x0B,))