diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index cfaa7a298ad..5288682c388 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -19,6 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity @@ -27,18 +28,26 @@ from .const import ( ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN, + DOMAIN, LOGGER, ) -from .coordinator import FritzboxConfigEntry +from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator from .model import ClimateExtraAttributes -OPERATION_LIST = [HVACMode.HEAT, HVACMode.OFF] +HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] +PRESET_HOLIDAY = "holiday" +PRESET_SUMMER = "summer" +PRESET_MODES = [PRESET_ECO, PRESET_COMFORT] +SUPPORTED_FEATURES = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) MIN_TEMPERATURE = 8 MAX_TEMPERATURE = 28 -PRESET_MANUAL = "manual" - # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 @@ -76,15 +85,38 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): """The thermostat class for FRITZ!SmartHome thermostats.""" _attr_precision = PRECISION_HALVES - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "thermostat" _enable_turn_on_off_backwards_compatibility = False + def __init__( + self, + coordinator: FritzboxDataUpdateCoordinator, + ain: str, + ) -> None: + """Initialize the thermostat.""" + self._attr_supported_features = SUPPORTED_FEATURES + self._attr_hvac_modes = HVAC_MODES + self._attr_preset_modes = PRESET_MODES + super().__init__(coordinator, ain) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the HASS state machine.""" + if self.data.holiday_active: + self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_hvac_modes = [HVACMode.HEAT] + self._attr_preset_modes = [PRESET_HOLIDAY] + elif self.data.summer_active: + self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_hvac_modes = [HVACMode.OFF] + self._attr_preset_modes = [PRESET_SUMMER] + else: + self._attr_supported_features = SUPPORTED_FEATURES + self._attr_hvac_modes = HVAC_MODES + self._attr_preset_modes = PRESET_MODES + return super().async_write_ha_state() + @property def current_temperature(self) -> float: """Return the current temperature.""" @@ -116,6 +148,10 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return the current operation mode.""" + if self.data.holiday_active: + return HVACMode.HEAT + if self.data.summer_active: + return HVACMode.OFF if self.data.target_temperature in ( OFF_REPORT_SET_TEMPERATURE, OFF_API_TEMPERATURE, @@ -124,13 +160,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return HVACMode.HEAT - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return OPERATION_LIST - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_hvac_while_active_mode", + ) if self.hvac_mode == hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode @@ -144,19 +180,23 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): @property def preset_mode(self) -> str | None: """Return current preset mode.""" + if self.data.holiday_active: + return PRESET_HOLIDAY + if self.data.summer_active: + return PRESET_SUMMER if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT if self.data.target_temperature == self.data.eco_temperature: return PRESET_ECO return None - @property - def preset_modes(self) -> list[str]: - """Return supported preset modes.""" - return [PRESET_ECO, PRESET_COMFORT] - async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_preset_while_active_mode", + ) if preset_mode == PRESET_COMFORT: await self.async_set_temperature(temperature=self.data.comfort_temperature) elif preset_mode == PRESET_ECO: diff --git a/homeassistant/components/fritzbox/icons.json b/homeassistant/components/fritzbox/icons.json new file mode 100644 index 00000000000..5eb819cdde8 --- /dev/null +++ b/homeassistant/components/fritzbox/icons.json @@ -0,0 +1,16 @@ +{ + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "holiday": "mdi:bag-suitcase-outline", + "summer": "mdi:radiator-off" + } + } + } + } + } + } +} diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 755cc97d7d8..cee0afa26c1 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -56,6 +56,21 @@ "device_lock": { "name": "Button lock via UI" }, "lock": { "name": "Button lock on device" } }, + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", + "state": { + "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "holiday": "Holiday", + "summer": "Summer" + } + } + } + } + }, "sensor": { "comfort_temperature": { "name": "Comfort temperature" }, "eco_temperature": { "name": "Eco temperature" }, @@ -64,5 +79,13 @@ "nextchange_time": { "name": "Next scheduled change time" }, "scheduled_preset": { "name": "Current scheduled preset" } } + }, + "exceptions": { + "change_preset_while_active_mode": { + "message": "Can't change preset while holiday or summer mode is active on the device." + }, + "change_hvac_while_active_mode": { + "message": "Can't change hvac mode while holiday or summer mode is active on the device." + } } } diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 5fb9c853bf5..2bd8f26d73b 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -103,10 +103,10 @@ class FritzDeviceClimateMock(FritzEntityBaseMock): has_temperature_sensor = True has_thermostat = True has_blind = False - holiday_active = "fake_holiday" + holiday_active = False lock = "fake_locked" present = True - summer_active = "fake_summer" + summer_active = False target_temperature = 19.5 window_open = "fake_window" nextchange_temperature = 22.0 diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 54d222c6899..8d1da9d09d5 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -3,6 +3,8 @@ from datetime import timedelta from unittest.mock import Mock, call +from freezegun.api import FrozenDateTimeFactory +import pytest from requests.exceptions import HTTPError from homeassistant.components.climate import ( @@ -21,6 +23,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.fritzbox.climate import PRESET_HOLIDAY, PRESET_SUMMER from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -40,6 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util from . import FritzDeviceClimateMock, set_devices, setup_config_entry @@ -68,8 +72,8 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_PRESET_MODE] is None assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] == "fake_holiday" - assert state.attributes[ATTR_STATE_SUMMER_MODE] == "fake_summer" + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" assert state.attributes[ATTR_TEMPERATURE] == 19.5 assert ATTR_STATE_CLASS not in state.attributes @@ -444,3 +448,109 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: state = hass.states.get(f"{DOMAIN}.new_climate") assert state + + +async def test_holidy_summer_mode( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, fritz: Mock +) -> None: + """Test holiday and summer mode.""" + device = FritzDeviceClimateMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + # initial state + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] is None + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + + # test holiday mode + device.holiday_active = True + device.summer_active = False + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOLIDAY + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_HOLIDAY] + + with pytest.raises( + HomeAssistantError, + match="Can't change hvac mode while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {"entity_id": ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + with pytest.raises( + HomeAssistantError, + match="Can't change preset while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_PRESET_MODE, + {"entity_id": ENTITY_ID, ATTR_PRESET_MODE: PRESET_HOLIDAY}, + blocking=True, + ) + + # test summer mode + device.holiday_active = False + device.summer_active = True + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_SUMMER + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_SUMMER] + + with pytest.raises( + HomeAssistantError, + match="Can't change hvac mode while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {"entity_id": ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + with pytest.raises( + HomeAssistantError, + match="Can't change preset while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_PRESET_MODE, + {"entity_id": ENTITY_ID, ATTR_PRESET_MODE: PRESET_SUMMER}, + blocking=True, + ) + + # back to normal state + device.holiday_active = False + device.summer_active = False + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] is None + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT]