diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index f7e9b161962..99b461dda1b 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import contextlib import logging from pathlib import Path @@ -225,7 +224,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module: KNXModule = hass.data[DOMAIN] for exposure in knx_module.exposures: - exposure.shutdown() + exposure.async_remove() unload_ok = await hass.config_entries.async_unload_platforms( entry, @@ -439,13 +438,13 @@ class KNXModule: threaded=True, ) - async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" self.connected = state == XknxConnectionState.CONNECTED - if tasks := [device.after_update() for device in self.xknx.devices]: - await asyncio.gather(*tasks) + for device in self.xknx.devices: + device.after_update() - async def telegram_received_cb(self, telegram: Telegram) -> None: + def telegram_received_cb(self, telegram: Telegram) -> None: """Call invoked after a KNX telegram was received.""" # Not all telegrams have serializable data. data: int | tuple[int, ...] | None = None @@ -504,10 +503,7 @@ class KNXModule: transcoder := DPTBase.parse_transcoder(dpt) ): self._address_filter_transcoder.update( - { - _filter: transcoder # type: ignore[type-abstract] - for _filter in _filters - } + {_filter: transcoder for _filter in _filters} ) return self.xknx.telegram_queue.register_telegram_received_cb( diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index dee56608421..0423c1d7b32 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -75,7 +75,7 @@ class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity): if ( last_state := await self.async_get_last_state() ) and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): - await self._device.remote_value.update_value(last_state.state == STATE_ON) + self._device.remote_value.update_value(last_state.state == STATE_ON) @property def is_on(self) -> bool: diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index e1179641cdc..26be6a03a79 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -6,7 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import Climate as XknxClimate, ClimateMode as XknxClimateMode -from xknx.dpt.dpt_hvac_mode import HVACControllerMode, HVACOperationMode +from xknx.dpt.dpt_20 import HVACControllerMode from homeassistant import config_entries from homeassistant.components.climate import ( @@ -80,7 +80,7 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: group_address_operation_mode_protection=config.get( ClimateSchema.CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS ), - group_address_operation_mode_night=config.get( + group_address_operation_mode_economy=config.get( ClimateSchema.CONF_OPERATION_MODE_NIGHT_ADDRESS ), group_address_operation_mode_comfort=config.get( @@ -199,10 +199,12 @@ class KNXClimate(KnxEntity, ClimateEntity): self.async_write_ha_state() return - if self._device.mode is not None and self._device.mode.supports_controller_mode: - knx_controller_mode = HVACControllerMode( - CONTROLLER_MODES_INV.get(self._last_hvac_mode) - ) + if ( + self._device.mode is not None + and self._device.mode.supports_controller_mode + and (knx_controller_mode := CONTROLLER_MODES_INV.get(self._last_hvac_mode)) + is not None + ): await self._device.mode.set_controller_mode(knx_controller_mode) self.async_write_ha_state() @@ -234,7 +236,7 @@ class KNXClimate(KnxEntity, ClimateEntity): return HVACMode.OFF if self._device.mode is not None and self._device.mode.supports_controller_mode: hvac_mode = CONTROLLER_MODES.get( - self._device.mode.controller_mode.value, self.default_hvac_mode + self._device.mode.controller_mode, self.default_hvac_mode ) if hvac_mode is not HVACMode.OFF: self._last_hvac_mode = hvac_mode @@ -247,7 +249,7 @@ class KNXClimate(KnxEntity, ClimateEntity): ha_controller_modes: list[HVACMode | None] = [] if self._device.mode is not None: ha_controller_modes.extend( - CONTROLLER_MODES.get(knx_controller_mode.value) + CONTROLLER_MODES.get(knx_controller_mode) for knx_controller_mode in self._device.mode.controller_modes ) @@ -278,9 +280,7 @@ class KNXClimate(KnxEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set controller mode.""" if self._device.mode is not None and self._device.mode.supports_controller_mode: - knx_controller_mode = HVACControllerMode( - CONTROLLER_MODES_INV.get(hvac_mode) - ) + knx_controller_mode = CONTROLLER_MODES_INV.get(hvac_mode) if knx_controller_mode in self._device.mode.controller_modes: await self._device.mode.set_controller_mode(knx_controller_mode) @@ -298,7 +298,7 @@ class KNXClimate(KnxEntity, ClimateEntity): Requires ClimateEntityFeature.PRESET_MODE. """ if self._device.mode is not None and self._device.mode.supports_operation_mode: - return PRESET_MODES.get(self._device.mode.operation_mode.value, PRESET_AWAY) + return PRESET_MODES.get(self._device.mode.operation_mode, PRESET_AWAY) return None @property @@ -311,15 +311,18 @@ class KNXClimate(KnxEntity, ClimateEntity): return None presets = [ - PRESET_MODES.get(operation_mode.value) + PRESET_MODES.get(operation_mode) for operation_mode in self._device.mode.operation_modes ] return list(filter(None, presets)) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self._device.mode is not None and self._device.mode.supports_operation_mode: - knx_operation_mode = HVACOperationMode(PRESET_MODES_INV.get(preset_mode)) + if ( + self._device.mode is not None + and self._device.mode.supports_operation_mode + and (knx_operation_mode := PRESET_MODES_INV.get(preset_mode)) is not None + ): await self._device.mode.set_operation_mode(knx_operation_mode) self.async_write_ha_state() @@ -333,7 +336,15 @@ class KNXClimate(KnxEntity, ClimateEntity): return attr async def async_added_to_hass(self) -> None: - """Store register state change callback.""" + """Store register state change callback and start device object.""" await super().async_added_to_hass() if self._device.mode is not None: self._device.mode.register_device_updated_cb(self.after_update_callback) + self._device.mode.xknx.devices.async_add(self._device.mode) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + if self._device.mode is not None: + self._device.mode.unregister_device_updated_cb(self.after_update_callback) + self._device.mode.xknx.devices.async_remove(self._device.mode) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 0b7b517dca5..6400f0fe466 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable from enum import Enum from typing import Final, TypedDict +from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode from xknx.telegram import Telegram from homeassistant.components.climate import ( @@ -158,12 +159,12 @@ SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { # Map DPT 20.105 HVAC control modes - "Auto": HVACMode.AUTO, - "Heat": HVACMode.HEAT, - "Cool": HVACMode.COOL, - "Off": HVACMode.OFF, - "Fan only": HVACMode.FAN_ONLY, - "Dry": HVACMode.DRY, + HVACControllerMode.AUTO: HVACMode.AUTO, + HVACControllerMode.HEAT: HVACMode.HEAT, + HVACControllerMode.COOL: HVACMode.COOL, + HVACControllerMode.OFF: HVACMode.OFF, + HVACControllerMode.FAN_ONLY: HVACMode.FAN_ONLY, + HVACControllerMode.DEHUMIDIFICATION: HVACMode.DRY, } CURRENT_HVAC_ACTIONS: Final = { @@ -176,9 +177,9 @@ CURRENT_HVAC_ACTIONS: Final = { PRESET_MODES: Final = { # Map DPT 20.102 HVAC operating modes to HA presets - "Auto": PRESET_NONE, - "Frost Protection": PRESET_ECO, - "Night": PRESET_SLEEP, - "Standby": PRESET_AWAY, - "Comfort": PRESET_COMFORT, + HVACOperationMode.AUTO: PRESET_NONE, + HVACOperationMode.BUILDING_PROTECTION: PRESET_ECO, + HVACOperationMode.ECONOMY: PRESET_SLEEP, + HVACOperationMode.STANDBY: PRESET_AWAY, + HVACOperationMode.COMFORT: PRESET_COMFORT, } diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index fa20a8d04c5..98cd22e0751 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -3,11 +3,10 @@ from __future__ import annotations from datetime import date as dt_date -import time -from typing import Final from xknx import XKNX -from xknx.devices import DateTime as XknxDateTime +from xknx.devices import DateDevice as XknxDateDevice +from xknx.dpt.dpt_11 import KNXDate as XKNXDate from homeassistant import config_entries from homeassistant.components.date import DateEntity @@ -33,8 +32,6 @@ from .const import ( ) from .knx_entity import KnxEntity -_DATE_TRANSLATION_FORMAT: Final = "%Y-%m-%d" - async def async_setup_entry( hass: HomeAssistant, @@ -45,15 +42,14 @@ async def async_setup_entry( xknx: XKNX = hass.data[DOMAIN].xknx config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE] - async_add_entities(KNXDate(xknx, entity_config) for entity_config in config) + async_add_entities(KNXDateEntity(xknx, entity_config) for entity_config in config) -def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice: """Return a XKNX DateTime object to be used within XKNX.""" - return XknxDateTime( + return XknxDateDevice( xknx, name=config[CONF_NAME], - broadcast_type="DATE", localtime=False, group_address=config[KNX_ADDRESS], group_address_state=config.get(CONF_STATE_ADDRESS), @@ -62,10 +58,10 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: ) -class KNXDate(KnxEntity, DateEntity, RestoreEntity): +class KNXDateEntity(KnxEntity, DateEntity, RestoreEntity): """Representation of a KNX date.""" - _device: XknxDateTime + _device: XknxDateDevice def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize a KNX time.""" @@ -81,21 +77,15 @@ class KNXDate(KnxEntity, DateEntity, RestoreEntity): and (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): - self._device.remote_value.value = time.strptime( - last_state.state, _DATE_TRANSLATION_FORMAT + self._device.remote_value.value = XKNXDate.from_date( + dt_date.fromisoformat(last_state.state) ) @property def native_value(self) -> dt_date | None: """Return the latest value.""" - if (time_struct := self._device.remote_value.value) is None: - return None - return dt_date( - year=time_struct.tm_year, - month=time_struct.tm_mon, - day=time_struct.tm_mday, - ) + return self._device.value async def async_set_value(self, value: dt_date) -> None: """Change the value.""" - await self._device.set(value.timetuple()) + await self._device.set(value) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 2a1a9e2f9c9..d4a25b522eb 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -5,7 +5,8 @@ from __future__ import annotations from datetime import datetime from xknx import XKNX -from xknx.devices import DateTime as XknxDateTime +from xknx.devices import DateTimeDevice as XknxDateTimeDevice +from xknx.dpt.dpt_19 import KNXDateTime as XKNXDateTime from homeassistant import config_entries from homeassistant.components.datetime import DateTimeEntity @@ -42,15 +43,16 @@ async def async_setup_entry( xknx: XKNX = hass.data[DOMAIN].xknx config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATETIME] - async_add_entities(KNXDateTime(xknx, entity_config) for entity_config in config) + async_add_entities( + KNXDateTimeEntity(xknx, entity_config) for entity_config in config + ) -def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice: """Return a XKNX DateTime object to be used within XKNX.""" - return XknxDateTime( + return XknxDateTimeDevice( xknx, name=config[CONF_NAME], - broadcast_type="DATETIME", localtime=False, group_address=config[KNX_ADDRESS], group_address_state=config.get(CONF_STATE_ADDRESS), @@ -59,10 +61,10 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: ) -class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity): +class KNXDateTimeEntity(KnxEntity, DateTimeEntity, RestoreEntity): """Representation of a KNX datetime.""" - _device: XknxDateTime + _device: XknxDateTimeDevice def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize a KNX time.""" @@ -78,29 +80,19 @@ class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity): and (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): - self._device.remote_value.value = ( - datetime.fromisoformat(last_state.state) - .astimezone(dt_util.get_default_time_zone()) - .timetuple() + self._device.remote_value.value = XKNXDateTime.from_datetime( + datetime.fromisoformat(last_state.state).astimezone( + dt_util.get_default_time_zone() + ) ) @property def native_value(self) -> datetime | None: """Return the latest value.""" - if (time_struct := self._device.remote_value.value) is None: + if (naive_dt := self._device.value) is None: return None - return datetime( - year=time_struct.tm_year, - month=time_struct.tm_mon, - day=time_struct.tm_mday, - hour=time_struct.tm_hour, - minute=time_struct.tm_min, - second=min(time_struct.tm_sec, 59), # account for leap seconds - tzinfo=dt_util.get_default_time_zone(), - ) + return naive_dt.replace(tzinfo=dt_util.get_default_time_zone()) async def async_set_value(self, value: datetime) -> None: """Change the value.""" - await self._device.set( - value.astimezone(dt_util.get_default_time_zone()).timetuple() - ) + await self._device.set(value.astimezone(dt_util.get_default_time_zone())) diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index fd5abc6a072..b43b5926d86 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -19,6 +19,7 @@ class KNXInterfaceDevice: def __init__(self, hass: HomeAssistant, entry: ConfigEntry, xknx: XKNX) -> None: """Initialize interface device class.""" + self.hass = hass self.device_registry = dr.async_get(hass) self.gateway_descriptor: GatewayDescriptor | None = None self.xknx = xknx @@ -46,7 +47,7 @@ class KNXInterfaceDevice: else None, ) - async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" if state is XknxConnectionState.CONNECTED: - await self.update() + self.hass.async_create_task(self.update()) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 695fe3b3851..29d0be998b6 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -6,7 +6,7 @@ from collections.abc import Callable import logging from xknx import XKNX -from xknx.devices import DateTime, ExposeSensor +from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice from xknx.dpt import DPTNumeric, DPTString from xknx.exceptions import ConversionError from xknx.remote_value import RemoteValueSensor @@ -60,6 +60,7 @@ def create_knx_exposure( xknx=xknx, config=config, ) + exposure.async_register() return exposure @@ -87,25 +88,23 @@ class KNXExposeSensor: self.value_template.hass = hass self._remove_listener: Callable[[], None] | None = None - self.device: ExposeSensor = self.async_register(config) - self._init_expose_state() - - @callback - def async_register(self, config: ConfigType) -> ExposeSensor: - """Register listener.""" - name = f"{self.entity_id}__{self.expose_attribute or "state"}" - device = ExposeSensor( + self.device: ExposeSensor = ExposeSensor( xknx=self.xknx, - name=name, + name=f"{self.entity_id}__{self.expose_attribute or "state"}", 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], ) + + @callback + def async_register(self) -> None: + """Register listener.""" self._remove_listener = async_track_state_change_event( self.hass, [self.entity_id], self._async_entity_changed ) - return device + self.xknx.devices.async_add(self.device) + self._init_expose_state() @callback def _init_expose_state(self) -> None: @@ -118,12 +117,12 @@ class KNXExposeSensor: _LOGGER.exception("Error during sending of expose sensor value") @callback - def shutdown(self) -> None: + def async_remove(self) -> None: """Prepare for deletion.""" if self._remove_listener is not None: self._remove_listener() self._remove_listener = None - self.device.shutdown() + self.xknx.devices.async_remove(self.device) def _get_expose_value(self, state: State | None) -> bool | int | float | str | None: """Extract value from state.""" @@ -196,21 +195,28 @@ class KNXExposeTime: def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of Expose class.""" self.xknx = xknx - self.device: DateTime = self.async_register(config) - - @callback - def async_register(self, config: ConfigType) -> DateTime: - """Register listener.""" expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] - return DateTime( + xknx_device_cls: type[DateDevice | DateTimeDevice | TimeDevice] + match expose_type: + case ExposeSchema.CONF_DATE: + xknx_device_cls = DateDevice + case ExposeSchema.CONF_DATETIME: + xknx_device_cls = DateTimeDevice + case ExposeSchema.CONF_TIME: + xknx_device_cls = TimeDevice + self.device = xknx_device_cls( self.xknx, name=expose_type.capitalize(), - broadcast_type=expose_type.upper(), localtime=True, group_address=config[KNX_ADDRESS], ) @callback - def shutdown(self) -> None: + def async_register(self) -> None: + """Register listener.""" + self.xknx.devices.async_add(self.device) + + @callback + def async_remove(self) -> None: """Prepare for deletion.""" - self.device.shutdown() + self.xknx.devices.async_remove(self.device) diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/knx_entity.py index b03c59486e5..eebddbb0623 100644 --- a/homeassistant/components/knx/knx_entity.py +++ b/homeassistant/components/knx/knx_entity.py @@ -36,12 +36,16 @@ class KnxEntity(Entity): """Request a state update from KNX bus.""" await self._device.sync() - async def after_update_callback(self, device: XknxDevice) -> None: + def after_update_callback(self, _device: XknxDevice) -> None: """Call after device was updated.""" self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Store register state change callback.""" + """Store register state change callback and start device object.""" self._device.register_device_updated_cb(self.after_update_callback) - # will remove all callbacks and xknx tasks - self.async_on_remove(self._device.shutdown) + self._device.xknx.devices.async_add(self._device) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self._device.unregister_device_updated_cb(self.after_update_callback) + self._device.xknx.devices.async_remove(self._device) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 425640a9915..8ec42f3ee56 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -312,8 +312,7 @@ class _KnxLight(KnxEntity, LightEntity): if self._device.supports_brightness: return self._device.current_brightness if self._device.current_xyy_color is not None: - _, brightness = self._device.current_xyy_color - return brightness + return self._device.current_xyy_color.brightness if self._device.supports_color or self._device.supports_rgbw: rgb, white = self._device.current_color if rgb is None: @@ -363,8 +362,7 @@ class _KnxLight(KnxEntity, LightEntity): def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" if self._device.current_xyy_color is not None: - xy_color, _ = self._device.current_xyy_color - return xy_color + return self._device.current_xyy_color.color return None @property diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 5035239d1fb..0f96970f3ae 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.12.2", + "xknx==3.0.0", "xknxproject==3.7.1", "knx-frontend==2024.7.25.204106" ], diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 34a145eadb3..43037ad8188 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -9,6 +9,7 @@ from typing import ClassVar, Final import voluptuous as vol from xknx.devices.climate import SetpointShiftMode from xknx.dpt import DPTBase, DPTNumeric +from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode from xknx.exceptions import ConversionError, CouldNotParseTelegram from homeassistant.components.binary_sensor import ( @@ -51,12 +52,11 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - CONTROLLER_MODES, KNX_ADDRESS, - PRESET_MODES, ColorTempModes, ) from .validation import ( + dpt_base_type_validator, ga_list_validator, ga_validator, numeric_type_validator, @@ -173,7 +173,7 @@ class EventSchema: KNX_EVENT_FILTER_SCHEMA = vol.Schema( { vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_TYPE): sensor_type_validator, + vol.Optional(CONF_TYPE): dpt_base_type_validator, } ) @@ -409,10 +409,10 @@ class ClimateSchema(KNXPlatformSchema): CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT ): cv.boolean, vol.Optional(CONF_OPERATION_MODES): vol.All( - cv.ensure_list, [vol.In(PRESET_MODES)] + cv.ensure_list, [vol.All(vol.Upper, cv.enum(HVACOperationMode))] ), vol.Optional(CONF_CONTROLLER_MODES): vol.All( - cv.ensure_list, [vol.In(CONTROLLER_MODES)] + cv.ensure_list, [vol.All(vol.Upper, cv.enum(HVACControllerMode))] ), vol.Optional( CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT @@ -535,11 +535,10 @@ class ExposeSchema(KNXPlatformSchema): CONF_KNX_EXPOSE_BINARY = "binary" CONF_KNX_EXPOSE_COOLDOWN = "cooldown" CONF_KNX_EXPOSE_DEFAULT = "default" - EXPOSE_TIME_TYPES: Final = [ - "time", - "date", - "datetime", - ] + CONF_TIME = "time" + CONF_DATE = "date" + CONF_DATETIME = "datetime" + EXPOSE_TIME_TYPES: Final = [CONF_TIME, CONF_DATE, CONF_DATETIME] EXPOSE_TIME_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 5d7532e0e5d..f338bf9feaf 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -81,17 +81,18 @@ class KNXSelect(KnxEntity, SelectEntity, RestoreEntity): if not self._device.remote_value.readable and ( last_state := await self.async_get_last_state() ): - if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): - await self._device.remote_value.update_value( - self._option_payloads.get(last_state.state) - ) + if ( + last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and (option := self._option_payloads.get(last_state.state)) is not None + ): + self._device.remote_value.update_value(option) - async def after_update_callback(self, device: XknxDevice) -> None: + def after_update_callback(self, device: XknxDevice) -> None: """Call after device was updated.""" self._attr_current_option = self.option_from_payload( self._device.remote_value.value ) - await super().after_update_callback(device) + super().after_update_callback(device) def option_from_payload(self, payload: int | None) -> str | None: """Return the option a given payload is assigned to.""" diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 173979f78dc..5a09a921901 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -208,7 +208,7 @@ class KNXSystemSensor(SensorEntity): return True return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED - async def after_update_callback(self, _: XknxConnectionState) -> None: + def after_update_callback(self, _: XknxConnectionState) -> None: """Call after device was updated.""" self.async_write_ha_state() diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 24b9452cf60..8b82671deaa 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING import voluptuous as vol from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.exceptions import ConversionError from xknx.telegram import Telegram from xknx.telegram.address import parse_device_group_address from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite @@ -31,7 +32,7 @@ from .const import ( SERVICE_KNX_SEND, ) from .expose import create_knx_exposure -from .schema import ExposeSchema, ga_validator, sensor_type_validator +from .schema import ExposeSchema, dpt_base_type_validator, ga_validator if TYPE_CHECKING: from . import KNXModule @@ -95,7 +96,7 @@ SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( cv.ensure_list, [ga_validator], ), - vol.Optional(CONF_TYPE): sensor_type_validator, + vol.Optional(CONF_TYPE): dpt_base_type_validator, vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, } ) @@ -125,10 +126,7 @@ async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) transcoder := DPTBase.parse_transcoder(dpt) ): knx_module.group_address_transcoder.update( - { - _address: transcoder # type: ignore[type-abstract] - for _address in group_addresses - } + {_address: transcoder for _address in group_addresses} ) for group_address in group_addresses: if group_address in knx_module.knx_event_callback.group_addresses: @@ -173,7 +171,7 @@ async def service_exposure_register_modify( f"Could not find exposure for '{group_address}' to remove." ) from err - removed_exposure.shutdown() + removed_exposure.async_remove() return if group_address in knx_module.service_exposures: @@ -186,7 +184,7 @@ async def service_exposure_register_modify( group_address, replaced_exposure.device.name, ) - replaced_exposure.shutdown() + replaced_exposure.async_remove() exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data) knx_module.service_exposures[group_address] = exposure _LOGGER.debug( @@ -204,7 +202,7 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any( [ga_validator], ), vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, - vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator, + vol.Required(SERVICE_KNX_ATTR_TYPE): dpt_base_type_validator, vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, } ), @@ -237,8 +235,15 @@ async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> Non if attr_type is not None: transcoder = DPTBase.parse_transcoder(attr_type) if transcoder is None: - raise ValueError(f"Invalid type for knx.send service: {attr_type}") - payload = transcoder.to_knx(attr_payload) + raise ServiceValidationError( + f"Invalid type for knx.send service: {attr_type}" + ) + try: + payload = transcoder.to_knx(attr_payload) + except ConversionError as err: + raise ServiceValidationError( + f"Invalid payload for knx.send service: {err}" + ) from err elif isinstance(attr_payload, int): payload = DPTBinary(attr_payload) else: diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 82df78e748e..2ad46326b8e 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -7,6 +7,7 @@ from typing import Final, TypedDict from xknx import XKNX from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.dpt.dpt import DPTComplexData, DPTEnumData from xknx.exceptions import XKNXException from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite @@ -93,7 +94,7 @@ class Telegrams: if self.recent_telegrams: await self._history_store.async_save(list(self.recent_telegrams)) - async def _xknx_telegram_cb(self, telegram: Telegram) -> None: + def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) self.recent_telegrams.append(telegram_dict) @@ -157,6 +158,11 @@ def decode_telegram_payload( except XKNXException: value = "Error decoding value" + if isinstance(value, DPTComplexData): + value = value.as_dict() + elif isinstance(value, DPTEnumData): + value = value.name.lower() + return DecodedTelegramPayload( dpt_main=transcoder.dpt_main_number, dpt_sub=transcoder.dpt_sub_number, diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index c11b40d13dc..28e1419233c 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -3,11 +3,11 @@ from __future__ import annotations from datetime import time as dt_time -import time as time_time from typing import Final from xknx import XKNX -from xknx.devices import DateTime as XknxDateTime +from xknx.devices import TimeDevice as XknxTimeDevice +from xknx.dpt.dpt_10 import KNXTime as XknxTime from homeassistant import config_entries from homeassistant.components.time import TimeEntity @@ -45,15 +45,14 @@ async def async_setup_entry( xknx: XKNX = hass.data[DOMAIN].xknx config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TIME] - async_add_entities(KNXTime(xknx, entity_config) for entity_config in config) + async_add_entities(KNXTimeEntity(xknx, entity_config) for entity_config in config) -def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice: """Return a XKNX DateTime object to be used within XKNX.""" - return XknxDateTime( + return XknxTimeDevice( xknx, name=config[CONF_NAME], - broadcast_type="TIME", localtime=False, group_address=config[KNX_ADDRESS], group_address_state=config.get(CONF_STATE_ADDRESS), @@ -62,10 +61,10 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: ) -class KNXTime(KnxEntity, TimeEntity, RestoreEntity): +class KNXTimeEntity(KnxEntity, TimeEntity, RestoreEntity): """Representation of a KNX time.""" - _device: XknxDateTime + _device: XknxTimeDevice def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize a KNX time.""" @@ -81,25 +80,15 @@ class KNXTime(KnxEntity, TimeEntity, RestoreEntity): and (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): - self._device.remote_value.value = time_time.strptime( - last_state.state, _TIME_TRANSLATION_FORMAT + self._device.remote_value.value = XknxTime.from_time( + dt_time.fromisoformat(last_state.state) ) @property def native_value(self) -> dt_time | None: """Return the latest value.""" - if (time_struct := self._device.remote_value.value) is None: - return None - return dt_time( - hour=time_struct.tm_hour, - minute=time_struct.tm_min, - second=min(time_struct.tm_sec, 59), # account for leap seconds - ) + return self._device.value async def async_set_value(self, value: dt_time) -> None: """Change the value.""" - time_struct = time_time.strptime( - value.strftime(_TIME_TRANSLATION_FORMAT), - _TIME_TRANSLATION_FORMAT, - ) - await self._device.set(time_struct) + await self._device.set(value) diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py index 82149b21561..ae3ba088357 100644 --- a/homeassistant/components/knx/trigger.py +++ b/homeassistant/components/knx/trigger.py @@ -18,7 +18,7 @@ from homeassistant.helpers.typing import ConfigType, VolDictType from .const import DOMAIN from .schema import ga_validator from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload -from .validation import sensor_type_validator +from .validation import dpt_base_type_validator TRIGGER_TELEGRAM: Final = "telegram" @@ -44,7 +44,7 @@ TELEGRAM_TRIGGER_SCHEMA: VolDictType = { TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): PLATFORM_TYPE_TRIGGER_TELEGRAM, - vol.Optional(CONF_TYPE, default=None): vol.Any(sensor_type_validator, None), + vol.Optional(CONF_TYPE, default=None): vol.Any(dpt_base_type_validator, None), **TELEGRAM_TRIGGER_SCHEMA, } ) @@ -99,7 +99,7 @@ async def async_attach_trigger( ): decoded_payload = decode_telegram_payload( payload=telegram.payload.value, # type: ignore[union-attr] # checked via payload_apci - transcoder=trigger_transcoder, # type: ignore[type-abstract] # parse_transcoder don't return abstract classes + transcoder=trigger_transcoder, ) # overwrite decoded payload values in telegram_dict telegram_trigger_data = {**trigger_data, **telegram_dict, **decoded_payload} diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 9ed4f32c920..422b8474fd9 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -30,9 +30,10 @@ def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str return dpt_value_validator +dpt_base_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract] numeric_type_validator = dpt_subclass_validator(DPTNumeric) # type: ignore[type-abstract] -sensor_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract] string_type_validator = dpt_subclass_validator(DPTString) +sensor_type_validator = vol.Any(numeric_type_validator, string_type_validator) def ga_validator(value: Any) -> str | int: diff --git a/requirements_all.txt b/requirements_all.txt index 8aebf250fd4..2473bdf14b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2927,7 +2927,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.30.2 # homeassistant.components.knx -xknx==2.12.2 +xknx==3.0.0 # homeassistant.components.knx xknxproject==3.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 859154b2837..e5f8ec1bc43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.30.2 # homeassistant.components.knx -xknx==2.12.2 +xknx==3.0.0 # homeassistant.components.knx xknxproject==3.7.1 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 76f1b6f3ebc..19f2bc4d845 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -83,7 +83,7 @@ class KNXTestKit: self.xknx.rate_limit = 0 # set XknxConnectionState.CONNECTED to avoid `unavailable` entities at startup # and start StateUpdater. This would be awaited on normal startup too. - await self.xknx.connection_manager.connection_state_changed( + self.xknx.connection_manager.connection_state_changed( state=XknxConnectionState.CONNECTED, connection_type=XknxConnectionType.TUNNEL_TCP, ) @@ -93,6 +93,7 @@ class KNXTestKit: mock = Mock() mock.start = AsyncMock(side_effect=patch_xknx_start) mock.stop = AsyncMock() + mock.gateway_info = AsyncMock() return mock def fish_xknx(*args, **kwargs): @@ -151,8 +152,6 @@ class KNXTestKit: ) -> None: """Assert outgoing telegram. One by one in timely order.""" await self.xknx.telegrams.join() - await self.hass.async_block_till_done() - await self.hass.async_block_till_done() try: telegram = self._outgoing_telegrams.get_nowait() except asyncio.QueueEmpty as err: @@ -247,6 +246,7 @@ class KNXTestKit: GroupValueResponse(payload_value), source=source, ) + await asyncio.sleep(0) # advance loop to allow StateUpdater to process async def receive_write( self, diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index b9216aa149a..1b304293a86 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -123,25 +123,21 @@ async def test_binary_sensor_ignore_internal_state( # receive initial ON telegram await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) - await hass.async_block_till_done() assert len(events) == 2 # receive second ON telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) - await hass.async_block_till_done() assert len(events) == 3 # receive first OFF telegram await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) - await hass.async_block_till_done() assert len(events) == 5 # receive second OFF telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) - await hass.async_block_till_done() assert len(events) == 6 @@ -166,21 +162,17 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No # receive initial ON telegram await knx.receive_write("2/2/2", True) - await hass.async_block_till_done() # no change yet - still in 1 sec context (additional async_block_till_done needed for time change) assert len(events) == 0 state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF assert state.attributes.get("counter") == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) - await hass.async_block_till_done() await knx.xknx.task_registry.block_till_done() # state changed twice after context timeout - once to ON with counter 1 and once to counter 0 state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON assert state.attributes.get("counter") == 0 - # additional async_block_till_done needed event capture - await hass.async_block_till_done() assert len(events) == 2 event = events.pop(0).data assert event.get("new_state").attributes.get("counter") == 1 @@ -198,7 +190,6 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No assert state.attributes.get("counter") == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) await knx.xknx.task_registry.block_till_done() - await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON assert state.attributes.get("counter") == 0 @@ -230,12 +221,10 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None # receive ON telegram await knx.receive_write("2/2/2", True) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - await hass.async_block_till_done() # state reset after after timeout state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF @@ -265,7 +254,6 @@ async def test_binary_sensor_restore_and_respond(hass: HomeAssistant, knx) -> No await knx.assert_telegram_count(0) await knx.receive_write(_ADDRESS, False) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF @@ -296,6 +284,5 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: # inverted is on, make sure the state is off after it await knx.receive_write(_ADDRESS, True) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 9c431386b43..77eeeef3559 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -80,12 +80,6 @@ async def test_climate_on_off( ) } ) - - await hass.async_block_till_done() - # read heat/cool state - if heat_cool_ga: - await knx.assert_read("1/2/11") - await knx.receive_response("1/2/11", 0) # cool # read temperature state await knx.assert_read("1/2/3") await knx.receive_response("1/2/3", RAW_FLOAT_20_0) @@ -95,6 +89,10 @@ async def test_climate_on_off( # read on/off state await knx.assert_read("1/2/9") await knx.receive_response("1/2/9", 1) + # read heat/cool state + if heat_cool_ga: + await knx.assert_read("1/2/11") + await knx.receive_response("1/2/11", 0) # cool # turn off await hass.services.async_call( @@ -171,18 +169,15 @@ async def test_climate_hvac_mode( ) } ) - - await hass.async_block_till_done() # read states state updater - await knx.assert_read("1/2/7") - await knx.assert_read("1/2/3") - # StateUpdater initialize state - await knx.receive_response("1/2/7", (0x01,)) - await knx.receive_response("1/2/3", RAW_FLOAT_20_0) # StateUpdater semaphore allows 2 concurrent requests - # read target temperature state + await knx.assert_read("1/2/3") await knx.assert_read("1/2/5") + # StateUpdater initialize state + await knx.receive_response("1/2/3", RAW_FLOAT_20_0) await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) # turn hvac mode to off - set_hvac_mode() doesn't send to on_off if dedicated hvac mode is available await hass.services.async_call( @@ -254,17 +249,14 @@ async def test_climate_preset_mode( ) events = async_capture_events(hass, "state_changed") - await hass.async_block_till_done() - # read states state updater - await knx.assert_read("1/2/7") - await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", (0x01,)) - await knx.receive_response("1/2/3", RAW_FLOAT_21_0) # StateUpdater semaphore allows 2 concurrent requests - # read target temperature state + await knx.assert_read("1/2/3") await knx.assert_read("1/2/5") + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) events.clear() # set preset mode @@ -294,8 +286,6 @@ async def test_climate_preset_mode( assert len(knx.xknx.devices[1].device_updated_cbs) == 2 # test removing also removes hooks entity_registry.async_remove("climate.test") - await hass.async_block_till_done() - # If we remove the entity the underlying devices should disappear too assert len(knx.xknx.devices) == 0 @@ -315,18 +305,15 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: } ) assert await async_setup_component(hass, "homeassistant", {}) - await hass.async_block_till_done() - await hass.async_block_till_done() # read states state updater - await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") - # StateUpdater initialize state - await knx.receive_response("1/2/7", (0x01,)) - await knx.receive_response("1/2/3", RAW_FLOAT_21_0) - # StateUpdater semaphore allows 2 concurrent requests await knx.assert_read("1/2/5") + # StateUpdater initialize state + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) # verify update entity retriggers group value reads to the bus await hass.services.async_call( @@ -354,8 +341,6 @@ async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> } } ) - - await hass.async_block_till_done() # read states state updater await knx.assert_read("1/2/3") await knx.assert_read("1/2/5") diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 3dad9320e21..a7da2d26600 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -184,7 +184,6 @@ async def test_routing_setup( CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", }, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "Routing as 1.1.110" assert result3["data"] == { @@ -259,7 +258,6 @@ async def test_routing_setup_advanced( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "Routing as 1.1.110" assert result3["data"] == { @@ -350,7 +348,6 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, }, ) - await hass.async_block_till_done() assert secure_routing_manual["type"] is FlowResultType.CREATE_ENTRY assert secure_routing_manual["title"] == "Secure Routing as 0.0.123" assert secure_routing_manual["data"] == { @@ -419,7 +416,6 @@ async def test_routing_secure_keyfile( CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - await hass.async_block_till_done() assert routing_secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY assert routing_secure_knxkeys["title"] == "Secure Routing as 0.0.123" assert routing_secure_knxkeys["data"] == { @@ -552,7 +548,6 @@ async def test_tunneling_setup_manual( result2["flow_id"], user_input, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == title assert result3["data"] == config_entry_data @@ -681,7 +676,6 @@ async def test_tunneling_setup_manual_request_description_error( CONF_PORT: 3671, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Tunneling TCP @ 192.168.0.1" assert result["data"] == { @@ -772,7 +766,6 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "Tunneling UDP @ 192.168.0.2" assert result3["data"] == { @@ -821,7 +814,6 @@ async def test_tunneling_setup_for_multiple_found_gateways( tunnel_flow["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { **DEFAULT_ENTRY_DATA, @@ -905,7 +897,6 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() assert result2["data"] == { @@ -1040,7 +1031,6 @@ async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_auth", }, ) - await hass.async_block_till_done() assert secure_tunnel_manual["type"] is FlowResultType.CREATE_ENTRY assert secure_tunnel_manual["data"] == { **DEFAULT_ENTRY_DATA, @@ -1086,7 +1076,6 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: {CONF_KNX_TUNNEL_ENDPOINT_IA: CONF_KNX_AUTOMATIC}, ) - await hass.async_block_till_done() assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY assert secure_knxkeys["data"] == { **DEFAULT_ENTRY_DATA, @@ -1201,7 +1190,6 @@ async def test_options_flow_connection_type( CONF_KNX_GATEWAY: str(gateway), }, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert not result3["data"] assert mock_config_entry.data == { @@ -1307,7 +1295,6 @@ async def test_options_flow_secure_manual_to_keyfile( {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) - await hass.async_block_till_done() assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, @@ -1352,7 +1339,6 @@ async def test_options_communication_settings( CONF_KNX_TELEGRAM_LOG_SIZE: 3000, }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert not result2.get("data") assert mock_config_entry.data == { @@ -1405,7 +1391,6 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert not result2.get("data") assert mock_config_entry.data == { @@ -1463,7 +1448,6 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", }, ) - await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY assert not result3.get("data") assert mock_config_entry.data == { diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index c8c6bd4f346..4b66769a8a3 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -34,7 +34,8 @@ async def test_datetime(hass: HomeAssistant, knx: KNXTestKit) -> None: ) await knx.assert_write( test_address, - (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80), + # service call in UTC, telegram in local time + (0x78, 0x01, 0x01, 0x13, 0x04, 0x05, 0x24, 0x00), ) state = hass.states.get("datetime.test") assert state.state == "2020-01-02T03:04:05+00:00" @@ -74,7 +75,7 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> await knx.receive_read(test_address) await knx.assert_response( test_address, - (0x7A, 0x03, 0x03, 0x84, 0x04, 0x05, 0x20, 0x80), + (0x7A, 0x03, 0x03, 0x04, 0x04, 0x05, 0x24, 0x00), ) # don't respond to passive address diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index 9b49df080f5..e5f776a9404 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -391,7 +391,6 @@ async def test_invalid_device_trigger( ] }, ) - await hass.async_block_till_done() assert ( "Unnamed automation failed to setup triggers and has been disabled: " "extra keys not allowed @ data['invalid']. Got None" diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index ddb9d50240c..2228781ba89 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -31,7 +31,6 @@ async def test_knx_event( events = async_capture_events(hass, "knx_event") async def test_event_data(address, payload, value=None): - await hass.async_block_till_done() assert len(events) == 1 event = events.pop() assert event.data["data"] == payload @@ -69,7 +68,6 @@ async def test_knx_event( ) # no event received - await hass.async_block_till_done() assert len(events) == 0 # receive telegrams for group addresses matching the filter @@ -101,7 +99,6 @@ async def test_knx_event( await knx.receive_write("0/5/0", True) await knx.receive_write("1/7/0", True) await knx.receive_write("2/6/6", True) - await hass.async_block_till_done() assert len(events) == 0 # receive telegrams with wrong payload length diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index e0b4c78e322..96b00241ab6 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -1,9 +1,8 @@ """Test KNX expose.""" from datetime import timedelta -import time -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS @@ -327,25 +326,32 @@ async def test_expose_conversion_exception( ) -@patch("time.localtime") +@freeze_time("2022-1-7 9:13:14") +@pytest.mark.parametrize( + ("time_type", "raw"), + [ + ("time", (0xA9, 0x0D, 0x0E)), # localtime includes day of week + ("date", (0x07, 0x01, 0x16)), + ("datetime", (0x7A, 0x1, 0x7, 0xA9, 0xD, 0xE, 0x20, 0xC0)), + ], +) async def test_expose_with_date( - localtime, hass: HomeAssistant, knx: KNXTestKit + hass: HomeAssistant, knx: KNXTestKit, time_type: str, raw: tuple[int, ...] ) -> None: """Test an expose with a date.""" - localtime.return_value = time.struct_time([2022, 1, 7, 9, 13, 14, 6, 0, 0]) await knx.setup_integration( { CONF_KNX_EXPOSE: { - CONF_TYPE: "datetime", + CONF_TYPE: time_type, KNX_ADDRESS: "1/1/8", } } ) - await knx.assert_write("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) + await knx.assert_write("1/1/8", raw) await knx.receive_read("1/1/8") - await knx.assert_response("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) + await knx.assert_response("1/1/8", raw) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index a317a6a298c..48cc46ef1ee 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -284,7 +284,6 @@ async def test_async_remove_entry( assert await hass.config_entries.async_remove(config_entry.entry_id) assert unlink_mock.call_count == 3 rmdir_mock.assert_called_once() - await hass.async_block_till_done() assert hass.config_entries.async_entries() == [] assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index c21c25b6fad..8010496ef0d 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -66,25 +66,19 @@ async def test_diagnostic_entities( ): assert hass.states.get(entity_id).state == test_state - await knx.xknx.connection_manager.connection_state_changed( + knx.xknx.connection_manager.connection_state_changed( state=XknxConnectionState.DISCONNECTED ) await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() assert len(events) == 4 # 3 not always_available + 3 force_update - 2 disabled events.clear() knx.xknx.current_address = IndividualAddress("1.1.1") - await knx.xknx.connection_manager.connection_state_changed( + knx.xknx.connection_manager.connection_state_changed( state=XknxConnectionState.CONNECTED, connection_type=XknxConnectionType.TUNNEL_UDP, ) await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() assert len(events) == 6 # all diagnostic sensors - counters are reset on connect for entity_id, test_state in ( @@ -111,7 +105,6 @@ async def test_removed_entity( "sensor.knx_interface_connection_established", disabled_by=er.RegistryEntryDisabler.USER, ) - await hass.async_block_till_done() unregister_mock.assert_called_once() diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 0c7a37979a8..8c966a77a0b 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -92,9 +92,7 @@ async def test_light_brightness(hass: HomeAssistant, knx: KNXTestKit) -> None: ) # StateUpdater initialize state await knx.assert_read(test_brightness_state) - await knx.xknx.connection_manager.connection_state_changed( - XknxConnectionState.CONNECTED - ) + knx.xknx.connection_manager.connection_state_changed(XknxConnectionState.CONNECTED) # turn on light via brightness await hass.services.async_call( "light", diff --git a/tests/components/knx/test_notify.py b/tests/components/knx/test_notify.py index 94f2d579fc8..b481675140b 100644 --- a/tests/components/knx/test_notify.py +++ b/tests/components/knx/test_notify.py @@ -21,17 +21,13 @@ async def test_legacy_notify_service_simple( } } ) - await hass.async_block_till_done() - await hass.services.async_call( "notify", "notify", {"target": "test", "message": "I love KNX"}, blocking=True ) - await knx.assert_write( "1/0/0", (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 0, 0, 0, 0), ) - await hass.services.async_call( "notify", "notify", @@ -41,7 +37,6 @@ async def test_legacy_notify_service_simple( }, blocking=True, ) - await knx.assert_write( "1/0/0", (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 44, 32, 98, 117), @@ -68,12 +63,9 @@ async def test_legacy_notify_service_multiple_sends_to_all_with_different_encodi ] } ) - await hass.async_block_till_done() - await hass.services.async_call( "notify", "notify", {"message": "Gänsefüßchen"}, blocking=True ) - await knx.assert_write( "1/0/0", # "G?nsef??chen" @@ -95,7 +87,6 @@ async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - await hass.services.async_call( notify.DOMAIN, notify.SERVICE_SEND_MESSAGE, diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 22d9993b58f..41ffcfcb5c7 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -68,25 +68,21 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: # receive initial telegram await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) - await hass.async_block_till_done() assert len(events) == 2 # receive second telegram with identical payload # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) - await hass.async_block_till_done() assert len(events) == 3 # receive telegram with different payload await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) - await hass.async_block_till_done() assert len(events) == 5 # receive telegram with second payload again # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) - await hass.async_block_till_done() assert len(events) == 6 diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index 7f748af5ceb..f70389dbc92 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -154,7 +154,6 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: # no event registered await knx.receive_write(test_address, True) - await hass.async_block_till_done() assert len(events) == 0 # register event with `type` @@ -165,7 +164,6 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: blocking=True, ) await knx.receive_write(test_address, (0x04, 0xD2)) - await hass.async_block_till_done() assert len(events) == 1 typed_event = events.pop() assert typed_event.data["data"] == (0x04, 0xD2) @@ -179,7 +177,6 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: blocking=True, ) await knx.receive_write(test_address, True) - await hass.async_block_till_done() assert len(events) == 0 # register event without `type` @@ -188,7 +185,6 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: ) await knx.receive_write(test_address, True) await knx.receive_write(test_address, False) - await hass.async_block_till_done() assert len(events) == 2 untyped_event_2 = events.pop() assert untyped_event_2.data["data"] is False diff --git a/tests/components/knx/test_trigger.py b/tests/components/knx/test_trigger.py index 4565122aba6..73e8b10840e 100644 --- a/tests/components/knx/test_trigger.py +++ b/tests/components/knx/test_trigger.py @@ -334,7 +334,6 @@ async def test_invalid_trigger( ] }, ) - await hass.async_block_till_done() assert ( "Unnamed automation failed to setup triggers and has been disabled: " "extra keys not allowed @ data['invalid']. Got None" diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py index 0adcc309252..5ba38d6cdf8 100644 --- a/tests/components/knx/test_weather.py +++ b/tests/components/knx/test_weather.py @@ -45,12 +45,12 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit) -> None: # brightness await knx.assert_read("1/1/6") - await knx.receive_response("1/1/6", (0x7C, 0x5E)) await knx.assert_read("1/1/8") + await knx.receive_response("1/1/6", (0x7C, 0x5E)) await knx.receive_response("1/1/8", (0x7C, 0x5E)) + await knx.assert_read("1/1/5") await knx.assert_read("1/1/7") await knx.receive_response("1/1/7", (0x7C, 0x5E)) - await knx.assert_read("1/1/5") await knx.receive_response("1/1/5", (0x7C, 0x5E)) # wind speed @@ -64,10 +64,10 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit) -> None: # alarms await knx.assert_read("1/1/2") await knx.receive_response("1/1/2", False) - await knx.assert_read("1/1/3") - await knx.receive_response("1/1/3", False) await knx.assert_read("1/1/1") + await knx.assert_read("1/1/3") await knx.receive_response("1/1/1", False) + await knx.receive_response("1/1/3", False) # day night await knx.assert_read("1/1/12")