diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 43d98ad6bbd..7e3cb027506 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_TENTHS, PRECISION_WHOLE, + SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -166,6 +167,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_turn_off", [ClimateEntityFeature.TURN_OFF], ) + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.TURN_ON], + ) component.async_register_entity_service( SERVICE_SET_HVAC_MODE, {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, @@ -756,7 +763,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if mode not in self.hvac_modes: continue await self.async_set_hvac_mode(mode) - break + return + + raise NotImplementedError def turn_off(self) -> None: """Turn the entity off.""" @@ -772,6 +781,26 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Fake turn off if HVACMode.OFF in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.OFF) + return + + raise NotImplementedError + + def toggle(self) -> None: + """Toggle the entity.""" + raise NotImplementedError + + async def async_toggle(self) -> None: + """Toggle the entity.""" + # Forward to self.toggle if it's been overridden. + if type(self).toggle is not ClimateEntity.toggle: + await self.hass.async_add_executor_job(self.toggle) + return + + # We assume that since turn_off is supported, HVACMode.OFF is as well. + if self.hvac_mode == HVACMode.OFF: + await self.async_turn_on() + else: + await self.async_turn_off() @cached_property def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 62952c5aae3..12a8e6f001f 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -148,3 +148,11 @@ turn_off: domain: climate supported_features: - climate.ClimateEntityFeature.TURN_OFF + +toggle: + target: + entity: + domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_OFF + - climate.ClimateEntityFeature.TURN_ON diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index ef87f287430..eb9285b0c4f 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -219,6 +219,10 @@ "turn_off": { "name": "[%key:common::action::turn_off%]", "description": "Turns climate device off." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles climate device, from on to off, or off to on." } }, "selector": { diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 0e4e70796f0..11ec825f96c 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from types import ModuleType -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest import voluptuous as vol @@ -110,12 +110,6 @@ class MockClimateEntity(MockEntity, ClimateEntity): """ return [HVACMode.OFF, HVACMode.HEAT] - def turn_on(self) -> None: - """Turn on.""" - - def turn_off(self) -> None: - """Turn off.""" - def set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" self._attr_preset_mode = preset_mode @@ -129,9 +123,19 @@ class MockClimateEntity(MockEntity, ClimateEntity): self._attr_swing_mode = swing_mode +class MockClimateEntityTestMethods(MockClimateEntity): + """Mock Climate device.""" + + def turn_on(self) -> None: + """Turn on.""" + + def turn_off(self) -> None: + """Turn off.""" + + async def test_sync_turn_on(hass: HomeAssistant) -> None: """Test if async turn_on calls sync turn_on.""" - climate = MockClimateEntity() + climate = MockClimateEntityTestMethods() climate.hass = hass climate.turn_on = MagicMock() @@ -142,7 +146,7 @@ async def test_sync_turn_on(hass: HomeAssistant) -> None: async def test_sync_turn_off(hass: HomeAssistant) -> None: """Test if async turn_off calls sync turn_off.""" - climate = MockClimateEntity() + climate = MockClimateEntityTestMethods() climate.hass = hass climate.turn_off = MagicMock() @@ -684,3 +688,80 @@ async def test_no_warning_integration_has_migrated( " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" not in caplog.text ) + + +async def test_turn_on_off_toggle(hass: HomeAssistant) -> None: + """Test turn_on/turn_off/toggle methods.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _attr_hvac_mode = HVACMode.OFF + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac mode.""" + return self._attr_hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + self._attr_hvac_mode = hvac_mode + + climate = MockClimateEntityTest() + climate.hass = hass + + await climate.async_turn_on() + assert climate.hvac_mode == HVACMode.HEAT + + await climate.async_turn_off() + assert climate.hvac_mode == HVACMode.OFF + + await climate.async_toggle() + assert climate.hvac_mode == HVACMode.HEAT + await climate.async_toggle() + assert climate.hvac_mode == HVACMode.OFF + + +async def test_sync_toggle(hass: HomeAssistant) -> None: + """Test if async toggle calls sync toggle.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVACMode.*. + """ + return HVACMode.HEAT + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVACMode.OFF, HVACMode.HEAT] + + def turn_on(self) -> None: + """Turn on.""" + + def turn_off(self) -> None: + """Turn off.""" + + def toggle(self) -> None: + """Toggle.""" + + climate = MockClimateEntityTest() + climate.hass = hass + + climate.toggle = Mock() + await climate.async_toggle() + + assert climate.toggle.called diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 0b44153ed93..8d513b98179 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -504,10 +504,10 @@ async def test_turn_on_and_off_without_power_command( assert state.state == "cool" mqtt_mock.async_publish.reset_mock() - await common.async_turn_off(hass, ENTITY_CLIMATE) - state = hass.states.get(ENTITY_CLIMATE) - assert climate_off is None or state.state == climate_off if climate_off: + await common.async_turn_off(hass, ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert climate_off is None or state.state == climate_off assert state.state == "off" mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) else: