Refactor set HVAC mode for Plugwise (#172121)

This commit is contained in:
Bouwe Westerdijk
2026-05-25 21:56:44 +02:00
committed by GitHub
parent 74ca79ac28
commit 2b58ef96eb
3 changed files with 428 additions and 78 deletions
+74 -78
View File
@@ -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)
@@ -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"]
}
}
+78
View File
@@ -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"] = []