From 70cf176d93138c3075a99c41acbbf3a2124374eb Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 22 May 2024 00:09:42 +0200 Subject: [PATCH] Add value_template option to KNX expose (#117732) * Add value_template option to KNX expose * template exception handling --- homeassistant/components/knx/expose.py | 46 +++++++++++++-------- homeassistant/components/knx/schema.py | 2 + tests/components/knx/test_expose.py | 55 +++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 12343f0dca7..695fe3b3851 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -13,6 +13,7 @@ from xknx.remote_value import RemoteValueSensor from homeassistant.const import ( CONF_ENTITY_ID, + CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -25,7 +26,9 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.exceptions import TemplateError from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS @@ -79,6 +82,9 @@ class KNXExposeSensor: ) self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] + self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + if self.value_template is not None: + self.value_template.hass = hass self._remove_listener: Callable[[], None] | None = None self.device: ExposeSensor = self.async_register(config) @@ -87,13 +93,10 @@ class KNXExposeSensor: @callback def async_register(self, config: ConfigType) -> ExposeSensor: """Register listener.""" - if self.expose_attribute is not None: - _name = self.entity_id + "__" + self.expose_attribute - else: - _name = self.entity_id + name = f"{self.entity_id}__{self.expose_attribute or "state"}" device = ExposeSensor( xknx=self.xknx, - name=_name, + name=name, group_address=config[KNX_ADDRESS], respond_to_read=config[CONF_RESPOND_TO_READ], value_type=self.expose_type, @@ -132,24 +135,33 @@ class KNXExposeSensor: else: value = state.state + if self.value_template is not None: + try: + value = self.value_template.async_render_with_possible_json_value( + value, error_value=None + ) + except (TemplateError, TypeError, ValueError) as err: + _LOGGER.warning( + "Error rendering value template for KNX expose %s %s: %s", + self.device.name, + self.value_template.template, + err, + ) + return None + if self.expose_type == "binary": if value in (1, STATE_ON, "True"): return True if value in (0, STATE_OFF, "False"): return False - if ( - value is not None - and isinstance(self.device.sensor_value, RemoteValueSensor) - and issubclass(self.device.sensor_value.dpt_class, DPTNumeric) + if value is not None and ( + isinstance(self.device.sensor_value, RemoteValueSensor) ): - return float(value) - if ( - value is not None - and isinstance(self.device.sensor_value, RemoteValueSensor) - and issubclass(self.device.sensor_value.dpt_class, DPTString) - ): - # DPT 16.000 only allows up to 14 Bytes - return str(value)[:14] + if issubclass(self.device.sensor_value.dpt_class, DPTNumeric): + return float(value) + if issubclass(self.device.sensor_value.dpt_class, DPTString): + # DPT 16.000 only allows up to 14 Bytes + return str(value)[:14] return value async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None: diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 462605c3985..34a145eadb3 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD, CONF_TYPE, + CONF_VALUE_TEMPLATE, Platform, ) import homeassistant.helpers.config_validation as cv @@ -559,6 +560,7 @@ class ExposeSchema(KNXPlatformSchema): vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_KNX_EXPOSE_ATTRIBUTE): cv.string, vol.Optional(CONF_KNX_EXPOSE_DEFAULT): cv.match_all, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ) ENTITY_SCHEMA = vol.Any(EXPOSE_SENSOR_SCHEMA, EXPOSE_TIME_SCHEMA) diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index d2b7653cfe8..e0b4c78e322 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -8,7 +8,12 @@ import pytest from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema -from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_ENTITY_ID, + CONF_TYPE, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -237,6 +242,54 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/1/8", (3,)) +async def test_expose_value_template( + hass: HomeAssistant, knx: KNXTestKit, caplog: pytest.LogCaptureFixture +) -> None: + """Test an expose with value_template.""" + entity_id = "fake.entity" + attribute = "brightness" + binary_address = "1/1/1" + percent_address = "2/2/2" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: [ + { + CONF_TYPE: "binary", + KNX_ADDRESS: binary_address, + CONF_ENTITY_ID: entity_id, + CONF_VALUE_TEMPLATE: "{{ not value == 'on' }}", + }, + { + CONF_TYPE: "percentU8", + KNX_ADDRESS: percent_address, + CONF_ENTITY_ID: entity_id, + CONF_ATTRIBUTE: attribute, + CONF_VALUE_TEMPLATE: "{{ 255 - value }}", + }, + ] + }, + ) + + # Change attribute to 0 + hass.states.async_set(entity_id, "on", {attribute: 0}) + await hass.async_block_till_done() + await knx.assert_write(binary_address, False) + await knx.assert_write(percent_address, (255,)) + + # Change attribute to 255 + hass.states.async_set(entity_id, "off", {attribute: 255}) + await hass.async_block_till_done() + await knx.assert_write(binary_address, True) + await knx.assert_write(percent_address, (0,)) + + # Change attribute to null (eg. light brightness) + hass.states.async_set(entity_id, "off", {attribute: None}) + await hass.async_block_till_done() + # without explicit `None`-handling or default value this fails with + # TypeError: unsupported operand type(s) for -: 'int' and 'NoneType' + assert "Error rendering value template for KNX expose" in caplog.text + + async def test_expose_conversion_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, knx: KNXTestKit ) -> None: