diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 0b7c9abdbc5f..768de0932e71 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -171,6 +171,7 @@ SUPPORTED_PLATFORMS_UI: Final = { Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.TIME, } diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 412caf575cbf..d9729684eb2e 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -77,3 +77,6 @@ CONF_GA_SCENE: Final = "ga_scene" # Sensor CONF_ALWAYS_CALLBACK: Final = "always_callback" + +# Text +CONF_GA_TEXT: Final = "ga_text" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 717b44c78d53..7025dbda91d3 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -13,10 +13,12 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.text import TextMode from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, + CONF_MODE, CONF_NAME, CONF_PLATFORM, CONF_UNIT_OF_MEASUREMENT, @@ -92,6 +94,7 @@ from .const import ( CONF_GA_SWITCH, CONF_GA_TEMPERATURE_CURRENT, CONF_GA_TEMPERATURE_TARGET, + CONF_GA_TEXT, CONF_GA_TIME, CONF_GA_UP_DOWN, CONF_GA_VALVE, @@ -449,6 +452,20 @@ SWITCH_KNX_SCHEMA = vol.Schema( }, ) +TEXT_KNX_SCHEMA = vol.Schema( + { + vol.Required(CONF_GA_TEXT): GASelector(write_required=True, dpt=["string"]), + vol.Required(CONF_MODE, default=TextMode.TEXT): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(TextMode), + translation_key="component.knx.config_panel.entities.create.text.knx.mode", + ), + ), + vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, +) + TIME_KNX_SCHEMA = vol.Schema( { vol.Required(CONF_GA_TIME): GASelector(write_required=True, valid_dpt="10.001"), @@ -718,6 +735,7 @@ KNX_SCHEMA_FOR_PLATFORM = { Platform.SCENE: SCENE_KNX_SCHEMA, Platform.SENSOR: SENSOR_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, + Platform.TEXT: TEXT_KNX_SCHEMA, Platform.TIME: TIME_KNX_SCHEMA, } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 05976d8dbc0c..d2b69ff4f291 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -836,6 +836,23 @@ } } }, + "text": { + "description": "The KNX text platform is used as an interface to text objects.", + "knx": { + "ga_text": { + "description": "The group address of the text object.", + "label": "Text" + }, + "mode": { + "description": "Select how the entity is displayed in Home Assistant.", + "label": "[%common::config_flow::data::mode%]", + "options": { + "password": "[%common::config_flow::data::password%]", + "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]" + } + } + } + }, "time": { "description": "The KNX time platform is used as an interface to time objects.", "knx": { diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 14c9af11ad35..d4a6f8b2785a 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -2,12 +2,12 @@ from __future__ import annotations -from xknx import XKNX +from propcache.api import cached_property from xknx.devices import Notification as XknxNotification from xknx.dpt import DPTLatin1 from homeassistant import config_entries -from homeassistant.components.text import TextEntity +from homeassistant.components.text import TextEntity, TextMode from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_MODE, @@ -18,13 +18,25 @@ from homeassistant.const import ( 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.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_ADDRESS, + KNX_MODULE_KEY, +) +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule +from .storage.const import CONF_ENTITY, CONF_GA_TEXT +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -32,46 +44,39 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up sensor(s) for KNX platform.""" + """Set up text(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.TEXT] - - async_add_entities(KNXText(knx_module, entity_config) for entity_config in config) - - -def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification: - """Return a KNX Notification to be used within XKNX.""" - return XknxNotification( - xknx, - name=config[CONF_NAME], - group_address=config[KNX_ADDRESS], - group_address_state=config.get(CONF_STATE_ADDRESS), - respond_to_read=config[CONF_RESPOND_TO_READ], - value_type=config[CONF_TYPE], + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.TEXT, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiText, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.TEXT): + entities.extend( + KnxYamlText(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.TEXT): + entities.extend( + KnxUiText(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXText(KnxYamlEntity, TextEntity, RestoreEntity): + +class _KnxText(TextEntity, RestoreEntity): """Representation of a KNX text.""" _device: XknxNotification _attr_native_max = 14 - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize a KNX text.""" - super().__init__( - knx_module=knx_module, - device=_create_notification(knx_module.xknx, config), - ) - self._attr_mode = config[CONF_MODE] - self._attr_pattern = ( - r"[\u0000-\u00ff]*" # Latin-1 - if issubclass(self._device.remote_value.dpt_class, DPTLatin1) - else r"[\u0000-\u007f]*" # ASCII - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = str(self._device.remote_value.group_address) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -81,6 +86,15 @@ class KNXText(KnxYamlEntity, TextEntity, RestoreEntity): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = last_state.state + @cached_property + def pattern(self) -> str | None: + """Return the regex pattern that the value must match.""" + return ( + r"[\u0000-\u00ff]*" # Latin-1 + if issubclass(self._device.remote_value.dpt_class, DPTLatin1) + else r"[\u0000-\u007f]*" # ASCII + ) + @property def native_value(self) -> str | None: """Return the value reported by the text.""" @@ -89,3 +103,56 @@ class KNXText(KnxYamlEntity, TextEntity, RestoreEntity): async def async_set_value(self, value: str) -> None: """Change the value.""" await self._device.set(value) + + +class KnxYamlText(_KnxText, KnxYamlEntity): + """Representation of a KNX text configured from YAML.""" + + _device: XknxNotification + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize a KNX text.""" + super().__init__( + knx_module=knx_module, + device=XknxNotification( + knx_module.xknx, + name=config[CONF_NAME], + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + value_type=config[CONF_TYPE], + ), + ) + self._attr_mode = config[CONF_MODE] + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + +class KnxUiText(_KnxText, KnxUiEntity): + """Representation of a KNX text configured from UI.""" + + _device: XknxNotification + + def __init__( + self, + knx_module: KNXModule, + unique_id: str, + config: ConfigType, + ) -> None: + """Initialize a KNX text.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + knx_conf = ConfigExtractor(config[DOMAIN]) + self._device = XknxNotification( + knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address=knx_conf.get_write(CONF_GA_TEXT), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_TEXT), + respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), + sync_state=knx_conf.get(CONF_SYNC_STATE), + value_type=knx_conf.get_dpt(CONF_GA_TEXT), + ) + self._attr_mode = TextMode(knx_conf.get(CONF_MODE)) diff --git a/tests/components/knx/fixtures/config_store_text.json b/tests/components/knx/fixtures/config_store_text.json new file mode 100644 index 000000000000..99bbb8bce8bd --- /dev/null +++ b/tests/components/knx/fixtures/config_store_text.json @@ -0,0 +1,29 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "text": { + "knx_es_01KCXYT0WEJEQQ13SGCY1NHCYV": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_text": { + "write": "1/1/1", + "dpt": "16.001", + "state": "2/2/2", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true, + "mode": "text" + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index e5b68aba38da..da05f3e2f733 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -2011,6 +2011,69 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[text] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_text', + 'options': dict({ + 'dptClasses': list([ + 'string', + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': 'text', + 'name': 'mode', + 'required': True, + 'selector': dict({ + 'select': dict({ + 'custom_value': False, + 'multiple': False, + 'options': list([ + 'password', + 'text', + ]), + 'sort': False, + 'translation_key': 'component.knx.config_panel.entities.create.text.knx.mode', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': False, + 'name': 'respond_to_read', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[time] dict({ 'id': 1, diff --git a/tests/components/knx/test_text.py b/tests/components/knx/test_text.py index e50f3056979b..b2222ff025b8 100644 --- a/tests/components/knx/test_text.py +++ b/tests/components/knx/test_text.py @@ -2,9 +2,11 @@ from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS from homeassistant.components.knx.schema import TextSchema -from homeassistant.const import CONF_NAME +from homeassistant.components.text import TextMode +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import mock_restore_cache @@ -99,3 +101,44 @@ async def test_text_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> ) state = hass.states.get("text.test") assert state.state == "hallo" + + +async def test_text_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test creating a text.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.TEXT, + entity_data={"name": "test"}, + knx_data={ + "ga_text": {"write": "1/1/1", "dpt": "16.000"}, + "mode": TextMode.PASSWORD, + "sync_state": True, + }, + ) + await hass.services.async_call( + "text", + "set_value", + {"entity_id": "text.test", "value": "hallo"}, + blocking=True, + ) + await knx.assert_write( + "1/1/1", + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + knx.assert_state("text.test", "hallo", mode=TextMode.PASSWORD) + + +async def test_text_ui_load(knx: KNXTestKit) -> None: + """Test loading a text from storage.""" + await knx.setup_integration(config_store_fixture="config_store_text.json") + + await knx.assert_read("2/2/2") + await knx.receive_response( + "2/2/2", + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + knx.assert_state("text.test", "hallo", mode=TextMode.TEXT)