Repository: home-assistant/core
Pull request: Add support to consider device holiday and summer mode in AVM Fritz!Smarthome
<!--
  You are amazing! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
-->
## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
The device allows to set a holiday or a summer mode (_they are mutual exclusive_) which has the following impact on the device:

- when the holiday mode is activated, the controls of climate devices are locked and hvac mode is set to "heat" with a defined target temperature (_default 16° but changeable while activating holidy mode via device UI_)
- when the summer mode is activated, the controls of climate devices are locked and hvac mode is set to "off"

## Type of change
<!--
  What type of change does your PR introduce to Home Assistant?
  NOTE: Please, check only 1! box!
  If your PR requires multiple boxes to be checked, you'll most likely need to
  split it into multiple PRs. This makes things easier and faster to code review.
-->

- [ ] Dependency upgrade
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New integration (thank you!)
- [x] New feature (which adds functionality to an existing integration)
- [ ] Deprecation (breaking change to happen in the future)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #119766
- This PR is related to issue: 
- Link to documentation pull request: 

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Local tests pass. **Your PR cannot be merged unless tests pass**
- [x] There is no commented out code in this PR.
- [x] I have followed the [development checklist][dev-checklist]
- [x] I have followed the [perfect PR recommendations][perfect-pr]
- [x] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [x] Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]

If the code communicates with devices, web services, or third-party tools:

- [ ] The [manifest file][manifest-docs] has all fields filled out correctly.  
      Updated and included derived files by running: `python3 -m script.hassfest`.
- [ ] New or updated dependencies have been added to `requirements_all.txt`.  
      Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
- [ ] Untested files have been added to `.coveragerc`.

<!--
  This project is very active and we have a high turnover of pull requests.

  Unfortunately, the number of incoming pull requests is higher than what our
  reviewers can review and merge so there is a long backlog of pull requests
  waiting for review. You can help here!
  
  By reviewing another pull request, you will help raise the code quality of
  that pull request and the final review will be faster. This way the general
  pace of pull request reviews will go up and your wait time will go down.
  
  When picking a pull request to review, try to choose one that hasn't yet
  been reviewed.

  Thanks for helping out!
-->

To help with the load of incoming pull requests:

- [x] I have reviewed two other [open pull requests][prs] in this repository.

[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure

<!--
  Thank you for contributing <3

  Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr


Problem statement:
FRITZ!DECT 301 vacation mode
### The problem

Hello, I own the radiator thermostats FRITZ!DECT 301 from AVM. The Fritzbox allows you to activate a vacation periode switch during this period, the manual switching of the thermostats is deactivated, it would be nice if HomeAssistant would recognize this setting and it would not be possible to set the temperature via the UI, because the setting has no effect in this time

### What version of Home Assistant Core has the issue?

2024.6.1

### What was the last working version of Home Assistant Core?

2024.6.1

### What type of installation are you running?

Home Assistant Core

### Integration causing the issue

_No response_

### Link to integration documentation on our website

_No response_

### Diagnostics information

_No response_

### Example YAML snippet

_No response_

### Anything in the logs that might be useful for us?

_No response_

### Additional information

_No response_

Hints from issue discussion:
Hey there @mib1185, @flabbamann, mind taking a look at this issue as it has been labeled with an integration (`fritzbox`) you are listed as a [code owner](https://github.com/home-assistant/core/blob/dev/CODEOWNERS#L480) for? Thanks!

<details>
<summary>Code owner commands</summary>


Code owners of `fritzbox` can trigger bot actions by commenting:

- `@home-assistant close` Closes the issue.
- `@home-assistant rename Awesome new title` Renames the issue.
- `@home-assistant reopen` Reopen the issue.
- `@home-assistant unassign fritzbox` Removes the current integration label and assignees on the issue, add the integration domain after the command.
- `@home-assistant add-label needs-more-information` Add a label (needs-more-information, problem in dependency, problem in custom component) to the issue.
- `@home-assistant remove-label needs-more-information` Remove a label (needs-more-information, problem in dependency, problem in custom component) on the issue.

</details>


<sub><sup>(message by CodeOwnersMention)</sup></sub>

---

[fritzbox documentation](https://www.home-assistant.io/integrations/fritzbox)
[fritzbox source](https://github.com/home-assistant/core/tree/dev/homeassistant/components/fritzbox)
<sub><sup>(message by IssueLinks)</sup></sub>
I can look on the issue but i could take some time because I need some time to read and understand the code base
> it would be nice if HomeAssistant would recognize this setting and it would not be possible to set the temperature via the UI, because the setting has no effect in this time

even this sounds like a feature request (_for which we have a special place in out [feature request](https://community.home-assistant.io/c/feature-requests)_), what happens currently when you enable the vacation mode in the Fritzbox and than try to control the thermostat via home assistant? 


You can control but changes not applied 
are there any error messages shown in the HA ui or in the log file?
do the state of the entities change/change back in HA after you have set some values?
how does it look like on the Fritzbox UI, when controlling these entities from HA during they are in vacation mode?

p.s.: please don't use full quotes when direct answering a comment/question :wink: 
@mib1185 Thanks for your reply, in the ui there no errors.
The state of the entites will change if it is changend in HA 

![image](https://github.com/home-assistant/core/assets/54689374/c438e8ab-168c-4912-954e-d6d5e8a43ba3)
for example i changed "Wohnzimer-2" to 24 C ° but its not applyed to the sensors

![image](https://github.com/home-assistant/core/assets/54689374/a198d37c-eea7-4f02-9c8b-8da516504d31)
 if you want to change from ui its blocked
i hope its okay for you thats in german cant't change the interface to englisch 

German is ok :wink: 
about the screenshots - I guess Wohnzimmer-2 in HA is just Wohnzimmer in your Fritzbox? If yes, you say "set it to 24°C in HA, but does not apply to the sensor" but the second screenshot shows the 24°C on Wohnzimmer :thinking: or what exactly do you mean with "ts not applied to the sensors" ?
I think it is confusing, the thermostat does not heat as long as the vacation circuit is activated so in my opinion you should deactivate the temperature setting.

Commit message: add preset modes, improve hvac mode handling

Patch to review:
diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py
index cfaa7a298ad6a..b4d0c147ea68e 100644
--- a/homeassistant/components/fritzbox/climate.py
+++ b/homeassistant/components/fritzbox/climate.py
@@ -29,16 +29,23 @@
     ATTR_STATE_WINDOW_OPEN,
     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_MODES = [PRESET_ECO, PRESET_COMFORT]
+PRESET_HOLIDAY = "holiday"
+PRESET_SUMMER = "summer"
+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 +83,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 +146,10 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
     @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 +158,10 @@ def hvac_mode(self) -> HVACMode:
 
         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:
+            return
         if self.hvac_mode == hvac_mode:
             LOGGER.debug(
                 "%s is already in requested hvac mode %s", self.name, hvac_mode
@@ -144,19 +175,20 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
     @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:
+            return
         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 0000000000000..5eb819cdde878
--- /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 755cc97d7d883..1a755ac48be7c 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" },
diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py
index 5fb9c853bf566..2bd8f26d73b0e 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 54d222c68999b..9ea307741953a 100644
--- a/tests/components/fritzbox/test_climate.py
+++ b/tests/components/fritzbox/test_climate.py
@@ -3,6 +3,7 @@
 from datetime import timedelta
 from unittest.mock import Mock, call
 
+from freezegun.api import FrozenDateTimeFactory
 from requests.exceptions import HTTPError
 
 from homeassistant.components.climate import (
@@ -21,6 +22,7 @@
     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,
@@ -68,8 +70,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 not state.attributes[ATTR_STATE_HOLIDAY_MODE]
+    assert not state.attributes[ATTR_STATE_SUMMER_MODE]
     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 +446,67 @@ 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 not state.attributes[ATTR_STATE_HOLIDAY_MODE]
+    assert not state.attributes[ATTR_STATE_SUMMER_MODE]
+    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 not state.attributes[ATTR_STATE_SUMMER_MODE]
+    assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT]
+    assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOLIDAY
+    assert state.attributes[ATTR_PRESET_MODES] == [PRESET_HOLIDAY]
+
+    # 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 not state.attributes[ATTR_STATE_HOLIDAY_MODE]
+    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]
+
+    # 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 not state.attributes[ATTR_STATE_HOLIDAY_MODE]
+    assert not state.attributes[ATTR_STATE_SUMMER_MODE]
+    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]


Code review comments:
diff_hunk: @@ -68,8 +70,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 not state.attributes[ATTR_STATE_HOLIDAY_MODE]
+    assert not state.attributes[ATTR_STATE_SUMMER_MODE]
path: tests/components/fritzbox/test_climate.py
text: @user1:
Could we use `is False` instead as I read it initially as the attributes was removed while they are not