From 2b58ef96eb928d2d291af0f68293f3248204c748 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 25 May 2026 21:56:44 +0200 Subject: [PATCH] Refactor set HVAC mode for Plugwise (#172121) --- homeassistant/components/plugwise/climate.py | 152 +++++----- .../m_adam_heating_off_schedule/data.json | 276 ++++++++++++++++++ tests/components/plugwise/test_climate.py | 78 +++++ 3 files changed, 428 insertions(+), 78 deletions(-) create mode 100644 tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 579c84ee6778..6fd2e14d0265 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -27,6 +27,15 @@ ERROR_NO_SCHEDULE = "set_schedule_first" PARALLEL_UPDATES = 0 +def _check_for_schedule(active: bool, last_active: str | None) -> None: + """Raise a HAError when no thermostat schedule has been set.""" + if not active and last_active is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=ERROR_NO_SCHEDULE, + ) + + @dataclass class PlugwiseClimateExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" @@ -85,22 +94,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _last_active_schedule: str | None = None - _previous_action_mode: str | None = HVACAction.HEATING.value - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added.""" - await super().async_added_to_hass() - - if extra_data := await self.async_get_last_extra_data(): - plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict( - extra_data.as_dict() - ) - self._last_active_schedule = plugwise_extra_data.last_active_schedule - self._previous_action_mode = ( - plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value - ) - def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, @@ -110,18 +103,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): super().__init__(coordinator, device_id) self._attr_unique_id = f"{device_id}-climate" - gateway_id: str = coordinator.api.gateway_id + self._api = coordinator.api + gateway_id: str = self._api.gateway_id self._gateway_data = coordinator.data[gateway_id] + self._last_active_schedule: str | None = None self._location = device_id if (location := self.device.get("location")) is not None: self._location = location + self._previous_action_mode = HVACAction.HEATING.value # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if ( - self.coordinator.api.cooling_present - and coordinator.api.smile.name != "Adam" - ): + if self._api.cooling_present and self._api.smile.name != "Adam": self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -140,10 +133,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): self.device["thermostat"]["resolution"], 0.1 ) - @property - def current_temperature(self) -> float: - """Return the current temperature.""" - return self.device["sensors"]["temperature"] + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if extra_data := await self.async_get_last_extra_data(): + plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict( + extra_data.as_dict() + ) + self._last_active_schedule = plugwise_extra_data.last_active_schedule + self._previous_action_mode = ( + plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value + ) @property def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData: @@ -153,6 +154,11 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): previous_action_mode=self._previous_action_mode, ) + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self.device["sensors"]["temperature"] + @property def target_temperature(self) -> float: """Return the temperature we try to reach. @@ -197,7 +203,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): if self.device.get("available_schedules"): hvac_modes.append(HVACMode.AUTO) - if self.coordinator.api.cooling_present: + if self._api.cooling_present: if "regulation_modes" in self._gateway_data: if "heating" in self._gateway_data["regulation_modes"]: hvac_modes.append(HVACMode.HEAT) @@ -247,79 +253,69 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): if mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(mode) - await self.coordinator.api.set_temperature(self._location, data) + await self._api.set_temperature(self._location, data) - def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str | None: - """Return the API regulation value for a manual HVAC mode, or None.""" + def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str: + """Return the API regulation value for a manual HVAC mode, or None. + + The function inputs are limited to the HVACModes HEAT and COOL. + """ if hvac_mode == HVACMode.HEAT: - return HVACAction.HEATING.value + mode = HVACAction.HEATING.value if hvac_mode == HVACMode.COOL: - return HVACAction.COOLING.value - return None + mode = HVACAction.COOLING.value + return mode @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule).""" + # Early exit if no mode change if hvac_mode == self.hvac_mode: return - api = self.coordinator.api - current_schedule = self.device.get("select_schedule") - - # OFF: single API call + # Adam only: set to HVACMode.OFF if hvac_mode == HVACMode.OFF: - await api.set_regulation_mode(hvac_mode.value) + await self._api.set_regulation_mode(hvac_mode.value) return - # Manual mode (heat/cool/heat_cool) without a schedule: set regulation only - if ( - current_schedule is None - and hvac_mode != HVACMode.AUTO - and ( - regulation := self._regulation_mode_for_hvac(hvac_mode) - or self._previous_action_mode - ) - ): - await api.set_regulation_mode(regulation) - return + current_schedule = self.device.get("select_schedule") + schedule_is_active = current_schedule not in (None, "off") + desired_schedule = ( + current_schedule if schedule_is_active else self._last_active_schedule + ) + # Adam only: transition from HVACMode.OFF + if self.hvac_mode == HVACMode.OFF: + if hvac_mode == HVACMode.AUTO: + _check_for_schedule(schedule_is_active, self._last_active_schedule) + await self._api.set_schedule_state( + self._location, STATE_ON, desired_schedule + ) + await self._api.set_regulation_mode(self._previous_action_mode) + return - # Manual mode: ensure regulation and turn off schedule when needed - if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL): - regulation = self._regulation_mode_for_hvac(hvac_mode) or ( - self._previous_action_mode - if self.hvac_mode in (HVACMode.HEAT_COOL, HVACMode.OFF) - else None - ) - if regulation: - await api.set_regulation_mode(regulation) - - if ( - self.hvac_mode == HVACMode.OFF and current_schedule not in (None, "off") - ) or (self.hvac_mode == HVACMode.AUTO and current_schedule is not None): - await api.set_schedule_state( + # Transition to manual mode + if schedule_is_active: + await self._api.set_schedule_state( self._location, STATE_OFF, current_schedule ) + self._last_active_schedule = current_schedule + regulation = self._regulation_mode_for_hvac(hvac_mode) + await self._api.set_regulation_mode(regulation) return - # AUTO: restore schedule and regulation - desired_schedule = current_schedule - if desired_schedule and desired_schedule != "off": - self._last_active_schedule = desired_schedule - elif desired_schedule == "off": - desired_schedule = self._last_active_schedule - - if not desired_schedule: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=ERROR_NO_SCHEDULE, + # Common - transition from auto = schedule off + if self.hvac_mode == HVACMode.AUTO: + await self._api.set_schedule_state( + self._location, STATE_OFF, current_schedule ) + self._last_active_schedule = current_schedule + return - if self._previous_action_mode: - if self.hvac_mode == HVACMode.OFF: - await api.set_regulation_mode(self._previous_action_mode) - await api.set_schedule_state(self._location, STATE_ON, desired_schedule) + # Common - transition to auto = schedule on + _check_for_schedule(schedule_is_active, self._last_active_schedule) + await self._api.set_schedule_state(self._location, STATE_ON, desired_schedule) @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - await self.coordinator.api.set_preset(self._location, preset_mode) + await self._api.set_preset(self._location, preset_mode) diff --git a/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json b/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json new file mode 100644 index 000000000000..5f539719c9b7 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json @@ -0,0 +1,276 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "water_temperature": 37.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2025-11-10T01:00:00+01:00", + "hardware": "1", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "Emma Pro", + "model_id": "170-01", + "name": "Emma", + "sensors": { + "battery": 100, + "humidity": 65.0, + "setpoint": 20.0, + "temperature": 19.5 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "60EFABFFFE89CBA0" + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 60, + "setpoint": 25.0, + "temperature": 18.6, + "temperature_difference": -0.4, + "valve_position": 100.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FCBA0" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 20.0, + "temperature": 19.1 + }, + "vendor": "Plugwise" + }, + "c9293d1d68ee48fc8843c6f0dee2b6be": { + "dev_class": "pumping", + "members": [ + "854f8a9b0e7e425db97f1f110e1ce4b3", + "ad4838d7d35c4d6ea796ee12ae5aedf8" + ], + "model": "Group", + "name": "Vloerverwarming", + "sensors": { + "electricity_consumed": 45.0, + "electricity_produced": 0.0, + "temperature": 20.1 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.9.0", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "D40FB201CBA0", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["bleeding_cold", "heating", "off", "bleeding_hot"], + "select_gateway_mode": "full", + "select_regulation_mode": "off", + "sensors": { + "outdoor_temperature": -1.25 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5ACBA0" + }, + "da575e9e09b947e281fb6e3ebce3b174": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermometer", + "firmware": "2020-09-01T02:00:00+02:00", + "hardware": "1", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "Jip", + "model_id": "168-01", + "name": "Jip", + "sensors": { + "battery": 100, + "humidity": 65.8, + "setpoint": 20.0, + "temperature": 19.3 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "70AC08FFFEE1CBA0" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 71, + "setpoint": 15.0, + "temperature": 17.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C86CBA0" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Group", + "name": "Test", + "sensors": { + "electricity_consumed": 16.5, + "electricity_produced": 0.0 + }, + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Vakantie", + "Weekschema", + "Test", + "off" + ], + "climate_mode": "off", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["vacation", "no_frost", "asleep", "home", "away"], + "select_schedule": "off", + "select_zone_profile": "active", + "sensors": { + "electricity_consumed": 60.8, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": [ + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6", + "da575e9e09b947e281fb6e3ebce3b174" + ], + "secondary": [] + }, + "vendor": "Plugwise", + "zone_profiles": ["active", "off", "passive"] + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "vacation", + "available_schedules": [ + "Badkamer", + "Vakantie", + "Weekschema", + "Test", + "off" + ], + "climate_mode": "off", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["vacation", "no_frost", "asleep", "home", "away"], + "select_schedule": "Badkamer", + "select_zone_profile": "passive", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise", + "zone_profiles": ["active", "off", "passive"] + } +} diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 6f90576909c9..8d7e15fc404b 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -275,6 +275,64 @@ async def test_adam_2_climate_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("chosen_env", ["m_adam_heating_off_schedule"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_off_regulation_mode_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test changing from regulation off mode.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State("climate.living_room", "heat"), + PlugwiseClimateExtraStoredData( + last_active_schedule=None, + previous_action_mode="heating", + ).as_dict(), + ), + ( + State("climate.bathroom", "heat"), + PlugwiseClimateExtraStoredData( + last_active_schedule="Badkamer", + previous_action_mode="heating", + ).as_dict(), + ), + ], + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("climate.living_room")) + assert state.state == "off" + + # Verify a HomeAssistantError is raised setting a schedule from regulation-off-mode with last_active_schedule = None + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + + # Verify that the active schedule is turned off when transitioning from regulation-off-mode to a manual mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + mock_smile_adam_heat_cool.set_schedule_state.assert_called_with( + "f871b8c4d63549319221e294e4f88074", STATE_OFF, "Badkamer" + ) + + @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_3_climate_entity_attributes( @@ -561,6 +619,26 @@ async def test_anna_climate_entity_climate_changes( "standaard", ) + data = mock_smile_anna.async_update.return_value + data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat" + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + assert mock_smile_anna.set_schedule_state.call_count == 2 + mock_smile_anna.set_schedule_state.assert_called_with( + "c784ee9fdab44e1395b8dee7d7a497d5", + STATE_ON, + "standaard", + ) + # Mock user deleting last schedule from app or browser data = mock_smile_anna.async_update.return_value data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = []