From acd31d4ae36b631ce838603fbcb30d476192dd43 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 27 Dec 2022 20:36:02 +0100 Subject: [PATCH] Add `cooldown` and `respond_to_read` options for KNX expose (#84613) Add cooldown option for KNX expose --- homeassistant/components/knx/expose.py | 70 ++++++++++++-------------- homeassistant/components/knx/schema.py | 3 ++ tests/components/knx/test_expose.py | 35 +++++++++++++ 3 files changed, 71 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 0963ec3be43..05e367faeec 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -21,7 +21,7 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, StateType -from .const import KNX_ADDRESS +from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS from .schema import ExposeSchema _LOGGER = logging.getLogger(__name__) @@ -32,27 +32,23 @@ def create_knx_exposure( hass: HomeAssistant, xknx: XKNX, config: ConfigType ) -> KNXExposeSensor | KNXExposeTime: """Create exposures from config.""" - address = config[KNX_ADDRESS] + expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] - attribute = config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE) - default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) exposure: KNXExposeSensor | KNXExposeTime if ( isinstance(expose_type, str) and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES ): - exposure = KNXExposeTime(xknx, expose_type, address) + exposure = KNXExposeTime( + xknx=xknx, + config=config, + ) else: - entity_id = config[CONF_ENTITY_ID] exposure = KNXExposeSensor( hass, - xknx, - expose_type, - entity_id, - attribute, - default, - address, + xknx=xknx, + config=config, ) return exposure @@ -64,36 +60,37 @@ class KNXExposeSensor: self, hass: HomeAssistant, xknx: XKNX, - expose_type: int | str, - entity_id: str, - attribute: str | None, - default: StateType, - address: str, + config: ConfigType, ) -> None: """Initialize of Expose class.""" self.hass = hass self.xknx = xknx - self.type = expose_type - self.entity_id = entity_id - self.expose_attribute = attribute - self.expose_default = default - self.address = address + + self.entity_id: str = config[CONF_ENTITY_ID] + self.expose_attribute: str | None = config.get( + ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE + ) + self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) + self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] + self._remove_listener: Callable[[], None] | None = None - self.device: ExposeSensor = self.async_register() + self.device: ExposeSensor = self.async_register(config) self._init_expose_state() @callback - def async_register(self) -> ExposeSensor: + 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 device = ExposeSensor( - self.xknx, + xknx=self.xknx, name=_name, - group_address=self.address, - value_type=self.type, + group_address=config[KNX_ADDRESS], + respond_to_read=config[CONF_RESPOND_TO_READ], + value_type=self.expose_type, + cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN], ) self._remove_listener = async_track_state_change_event( self.hass, [self.entity_id], self._async_entity_changed @@ -118,7 +115,7 @@ class KNXExposeSensor: self._remove_listener = None self.device.shutdown() - def _get_expose_value(self, state: State | None) -> StateType: + def _get_expose_value(self, state: State | None) -> bool | int | float | str | None: """Extract value from state.""" if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): value = self.expose_default @@ -128,7 +125,7 @@ class KNXExposeSensor: if self.expose_attribute is None else state.attributes.get(self.expose_attribute, self.expose_default) ) - if self.type == "binary": + if self.expose_type == "binary": if value in (1, STATE_ON, "True"): return True if value in (0, STATE_OFF, "False"): @@ -171,22 +168,21 @@ class KNXExposeSensor: class KNXExposeTime: """Object to Expose Time/Date object to KNX bus.""" - def __init__(self, xknx: XKNX, expose_type: str, address: str) -> None: + def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of Expose class.""" self.xknx = xknx - self.expose_type = expose_type - self.address = address - self.device: DateTime = self.async_register() + self.device: DateTime = self.async_register(config) @callback - def async_register(self) -> DateTime: + def async_register(self, config: ConfigType) -> DateTime: """Register listener.""" + expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] return DateTime( self.xknx, - name=self.expose_type.capitalize(), - broadcast_type=self.expose_type.upper(), + name=expose_type.capitalize(), + broadcast_type=expose_type.upper(), localtime=True, - group_address=self.address, + group_address=config[KNX_ADDRESS], ) @callback diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index e756fc5e466..b1ae55ba9dc 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -561,6 +561,7 @@ class ExposeSchema(KNXPlatformSchema): CONF_KNX_EXPOSE_TYPE = CONF_TYPE CONF_KNX_EXPOSE_ATTRIBUTE = "attribute" CONF_KNX_EXPOSE_BINARY = "binary" + CONF_KNX_EXPOSE_COOLDOWN = "cooldown" CONF_KNX_EXPOSE_DEFAULT = "default" EXPOSE_TIME_TYPES: Final = [ "time", @@ -578,6 +579,8 @@ class ExposeSchema(KNXPlatformSchema): ) EXPOSE_SENSOR_SCHEMA = vol.Schema( { + vol.Optional(CONF_KNX_EXPOSE_COOLDOWN, default=0): cv.positive_float, + vol.Optional(CONF_RESPOND_TO_READ, default=True): cv.boolean, vol.Required(CONF_KNX_EXPOSE_TYPE): vol.Any( CONF_KNX_EXPOSE_BINARY, sensor_type_validator ), diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index e5030eef461..3cad2cf008e 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -1,4 +1,5 @@ """Test KNX expose.""" +from datetime import timedelta import time from unittest.mock import patch @@ -6,9 +7,12 @@ 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.core import HomeAssistant +from homeassistant.util import dt from .conftest import KNXTestKit +from tests.common import async_fire_time_changed_exact + async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit): """Test a binary expose to only send telegrams on state change.""" @@ -163,6 +167,37 @@ async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit): ) +async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit): + """Test an expose with cooldown.""" + cooldown_time = 2 + entity_id = "fake.entity" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "percentU8", + KNX_ADDRESS: "1/1/8", + CONF_ENTITY_ID: entity_id, + ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN: cooldown_time, + } + }, + ) + assert not hass.states.async_all() + # Change state to 1 + hass.states.async_set(entity_id, "1", {}) + await knx.assert_write("1/1/8", (1,)) + # Change state to 2 - skip because of cooldown + hass.states.async_set(entity_id, "2", {}) + await knx.assert_no_telegram() + + # Change state to 3 + hass.states.async_set(entity_id, "3", {}) + await knx.assert_no_telegram() + # Wait for cooldown to pass + async_fire_time_changed_exact(hass, dt.utcnow() + timedelta(seconds=cooldown_time)) + await hass.async_block_till_done() + await knx.assert_write("1/1/8", (3,)) + + async def test_expose_conversion_exception(hass: HomeAssistant, knx: KNXTestKit): """Test expose throws exception."""