diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2d6a6686408..674e76d66e3 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -141,11 +141,20 @@ class KNXClimate(KnxEntity, ClimateEntity): """Initialize of a KNX climate device.""" super().__init__(_create_climate(xknx, config)) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON - ) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE if self._device.supports_on_off: - self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if ( + self._device.mode is not None + and len(self._device.mode.controller_modes) >= 2 + and HVACControllerMode.OFF in self._device.mode.controller_modes + ): + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if self.preset_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_target_temperature_step = self._device.temperature_step @@ -158,6 +167,8 @@ class KNXClimate(KnxEntity, ClimateEntity): self.default_hvac_mode: HVACMode = config[ ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE ] + # non-OFF HVAC mode to be used when turning on the device without on_off address + self._last_hvac_mode: HVACMode = self.default_hvac_mode @property def current_temperature(self) -> float | None: @@ -181,6 +192,34 @@ class KNXClimate(KnxEntity, ClimateEntity): temp = self._device.target_temperature_max return temp if temp is not None else super().max_temp + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if self._device.supports_on_off: + await self._device.turn_on() + 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) + ) + await self._device.mode.set_controller_mode(knx_controller_mode) + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if self._device.supports_on_off: + await self._device.turn_off() + self.async_write_ha_state() + return + + if ( + self._device.mode is not None + and HVACControllerMode.OFF in self._device.mode.controller_modes + ): + await self._device.mode.set_controller_mode(HVACControllerMode.OFF) + self.async_write_ha_state() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) @@ -194,9 +233,12 @@ class KNXClimate(KnxEntity, ClimateEntity): if self._device.supports_on_off and not self._device.is_on: return HVACMode.OFF if self._device.mode is not None and self._device.mode.supports_controller_mode: - return CONTROLLER_MODES.get( + hvac_mode = CONTROLLER_MODES.get( self._device.mode.controller_mode.value, self.default_hvac_mode ) + if hvac_mode is not HVACMode.OFF: + self._last_hvac_mode = hvac_mode + return hvac_mode return self.default_hvac_mode @property @@ -234,21 +276,23 @@ class KNXClimate(KnxEntity, ClimateEntity): return None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - if self._device.supports_on_off and hvac_mode == HVACMode.OFF: - await self._device.turn_off() - else: - if self._device.supports_on_off and not self._device.is_on: - await self._device.turn_on() - if ( - self._device.mode is not None - and self._device.mode.supports_controller_mode - ): - knx_controller_mode = HVACControllerMode( - CONTROLLER_MODES_INV.get(hvac_mode) - ) + """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) + ) + if knx_controller_mode in self._device.mode.controller_modes: await self._device.mode.set_controller_mode(knx_controller_mode) - self.async_write_ha_state() + self.async_write_ha_state() + return + + if self._device.supports_on_off: + if hvac_mode == HVACMode.OFF: + await self._device.turn_off() + elif not self._device.is_on: + # for default hvac mode, otherwise above would have triggered + await self._device.turn_on() + self.async_write_ha_state() @property def preset_mode(self) -> str | None: diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 240fde9ee8b..c81a6fccf15 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,5 +1,7 @@ """Test KNX climate.""" +import pytest + from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE @@ -52,6 +54,94 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 +@pytest.mark.parametrize("heat_cool", [False, True]) +async def test_climate_on_off( + hass: HomeAssistant, knx: KNXTestKit, heat_cool: bool +) -> None: + """Test KNX climate on/off.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_ON_OFF_ADDRESS: "1/2/8", + ClimateSchema.CONF_ON_OFF_STATE_ADDRESS: "1/2/9", + } + | ( + { + ClimateSchema.CONF_HEAT_COOL_ADDRESS: "1/2/10", + ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS: "1/2/11", + } + if heat_cool + else {} + ) + } + ) + + await hass.async_block_till_done() + # read heat/cool state + if heat_cool: + 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) + # read target temperature state + await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + # read on/off state + await knx.assert_read("1/2/9") + await knx.receive_response("1/2/9", 1) + + # turn off + await hass.services.async_call( + "climate", + "turn_off", + {"entity_id": "climate.test"}, + blocking=True, + ) + await knx.assert_write("1/2/8", 0) + assert hass.states.get("climate.test").state == "off" + + # turn on + await hass.services.async_call( + "climate", + "turn_on", + {"entity_id": "climate.test"}, + blocking=True, + ) + await knx.assert_write("1/2/8", 1) + if heat_cool: + # does not fall back to default hvac mode after turn_on + assert hass.states.get("climate.test").state == "cool" + else: + assert hass.states.get("climate.test").state == "heat" + + # set hvac mode to off triggers turn_off if no controller_mode is available + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + await knx.assert_write("1/2/8", 0) + + # set hvac mode to heat + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + if heat_cool: + # only set new hvac_mode without changing on/off - actuator shall handle that + await knx.assert_write("1/2/10", 1) + else: + await knx.assert_write("1/2/8", 1) + + async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" await knx.setup_integration( @@ -68,7 +158,6 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -82,14 +171,14 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/5") await knx.receive_response("1/2/5", RAW_FLOAT_22_0) - # turn hvac off + # turn hvac mode to off await hass.services.async_call( "climate", "set_hvac_mode", {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, blocking=True, ) - await knx.assert_write("1/2/8", False) + await knx.assert_write("1/2/6", (0x06,)) # turn hvac on await hass.services.async_call( @@ -98,7 +187,6 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, blocking=True, ) - await knx.assert_write("1/2/8", True) await knx.assert_write("1/2/6", (0x01,)) @@ -182,7 +270,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: ) assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater