Skip to content

Commit 901558b

Browse files
bouwewfrenck
authored andcommitted
Bugfix: implement RestoreState and bump backend for Plugwise climate (home-assistant#155126)
1 parent c09cf36 commit 901558b

File tree

6 files changed

+201
-34
lines changed

6 files changed

+201
-34
lines changed

homeassistant/components/plugwise/climate.py

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import dataclass
56
from typing import Any
67

78
from homeassistant.components.climate import (
@@ -13,18 +14,44 @@
1314
HVACAction,
1415
HVACMode,
1516
)
16-
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
17+
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature
1718
from homeassistant.core import HomeAssistant, callback
19+
from homeassistant.exceptions import HomeAssistantError
1820
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
21+
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
1922

2023
from .const import DOMAIN, MASTER_THERMOSTATS
2124
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
2225
from .entity import PlugwiseEntity
2326
from .util import plugwise_command
2427

28+
ERROR_NO_SCHEDULE = "set_schedule_first"
2529
PARALLEL_UPDATES = 0
2630

2731

32+
@dataclass
33+
class PlugwiseClimateExtraStoredData(ExtraStoredData):
34+
"""Object to hold extra stored data."""
35+
36+
last_active_schedule: str | None
37+
previous_action_mode: str | None
38+
39+
def as_dict(self) -> dict[str, Any]:
40+
"""Return a dict representation of the text data."""
41+
return {
42+
"last_active_schedule": self.last_active_schedule,
43+
"previous_action_mode": self.previous_action_mode,
44+
}
45+
46+
@classmethod
47+
def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData:
48+
"""Initialize a stored data object from a dict."""
49+
return cls(
50+
last_active_schedule=restored.get("last_active_schedule"),
51+
previous_action_mode=restored.get("previous_action_mode"),
52+
)
53+
54+
2855
async def async_setup_entry(
2956
hass: HomeAssistant,
3057
entry: PlugwiseConfigEntry,
@@ -56,14 +83,26 @@ def _add_entities() -> None:
5683
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
5784

5885

59-
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
86+
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
6087
"""Representation of a Plugwise thermostat."""
6188

6289
_attr_name = None
6390
_attr_temperature_unit = UnitOfTemperature.CELSIUS
6491
_attr_translation_key = DOMAIN
6592

66-
_previous_mode: str = "heating"
93+
_last_active_schedule: str | None = None
94+
_previous_action_mode: str | None = HVACAction.HEATING.value
95+
96+
async def async_added_to_hass(self) -> None:
97+
"""Run when entity about to be added."""
98+
await super().async_added_to_hass()
99+
100+
if extra_data := await self.async_get_last_extra_data():
101+
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
102+
extra_data.as_dict()
103+
)
104+
self._last_active_schedule = plugwise_extra_data.last_active_schedule
105+
self._previous_action_mode = plugwise_extra_data.previous_action_mode
67106

68107
def __init__(
69108
self,
@@ -76,7 +115,6 @@ def __init__(
76115

77116
gateway_id: str = coordinator.api.gateway_id
78117
self._gateway_data = coordinator.data[gateway_id]
79-
80118
self._location = device_id
81119
if (location := self.device.get("location")) is not None:
82120
self._location = location
@@ -105,25 +143,19 @@ def __init__(
105143
self.device["thermostat"]["resolution"], 0.1
106144
)
107145

108-
def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None:
109-
"""Return the previous action-mode when the regulation-mode is not heating or cooling.
110-
111-
Helper for set_hvac_mode().
112-
"""
113-
# When no cooling available, _previous_mode is always heating
114-
if (
115-
"regulation_modes" in self._gateway_data
116-
and "cooling" in self._gateway_data["regulation_modes"]
117-
):
118-
mode = self._gateway_data["select_regulation_mode"]
119-
if mode in ("cooling", "heating"):
120-
self._previous_mode = mode
121-
122146
@property
123147
def current_temperature(self) -> float:
124148
"""Return the current temperature."""
125149
return self.device["sensors"]["temperature"]
126150

151+
@property
152+
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
153+
"""Return text specific state data to be restored."""
154+
return PlugwiseClimateExtraStoredData(
155+
last_active_schedule=self._last_active_schedule,
156+
previous_action_mode=self._previous_action_mode,
157+
)
158+
127159
@property
128160
def target_temperature(self) -> float:
129161
"""Return the temperature we try to reach.
@@ -170,9 +202,10 @@ def hvac_modes(self) -> list[HVACMode]:
170202

171203
if self.coordinator.api.cooling_present:
172204
if "regulation_modes" in self._gateway_data:
173-
if self._gateway_data["select_regulation_mode"] == "cooling":
205+
selected = self._gateway_data.get("select_regulation_mode")
206+
if selected == HVACAction.COOLING.value:
174207
hvac_modes.append(HVACMode.COOL)
175-
if self._gateway_data["select_regulation_mode"] == "heating":
208+
if selected == HVACAction.HEATING.value:
176209
hvac_modes.append(HVACMode.HEAT)
177210
else:
178211
hvac_modes.append(HVACMode.HEAT_COOL)
@@ -184,8 +217,16 @@ def hvac_modes(self) -> list[HVACMode]:
184217
@property
185218
def hvac_action(self) -> HVACAction:
186219
"""Return the current running hvac operation if supported."""
187-
# Keep track of the previous action-mode
188-
self._previous_action_mode(self.coordinator)
220+
# Keep track of the previous hvac_action mode.
221+
# When no cooling available, _previous_action_mode is always heating
222+
if (
223+
"regulation_modes" in self._gateway_data
224+
and HVACAction.COOLING.value in self._gateway_data["regulation_modes"]
225+
):
226+
mode = self._gateway_data["select_regulation_mode"]
227+
if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value):
228+
self._previous_action_mode = mode
229+
189230
if (action := self.device.get("control_state")) is not None:
190231
return HVACAction(action)
191232

@@ -219,14 +260,33 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
219260
return
220261

221262
if hvac_mode == HVACMode.OFF:
222-
await self.coordinator.api.set_regulation_mode(hvac_mode)
263+
await self.coordinator.api.set_regulation_mode(hvac_mode.value)
223264
else:
265+
current = self.device.get("select_schedule")
266+
desired = current
267+
268+
# Capture the last valid schedule
269+
if desired and desired != "off":
270+
self._last_active_schedule = desired
271+
elif desired == "off":
272+
desired = self._last_active_schedule
273+
274+
# Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring
275+
if hvac_mode == HVACMode.AUTO and not desired:
276+
raise HomeAssistantError(
277+
translation_domain=DOMAIN,
278+
translation_key=ERROR_NO_SCHEDULE,
279+
)
280+
224281
await self.coordinator.api.set_schedule_state(
225282
self._location,
226-
"on" if hvac_mode == HVACMode.AUTO else "off",
283+
STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF,
284+
desired,
227285
)
228-
if self.hvac_mode == HVACMode.OFF:
229-
await self.coordinator.api.set_regulation_mode(self._previous_mode)
286+
if self.hvac_mode == HVACMode.OFF and self._previous_action_mode:
287+
await self.coordinator.api.set_regulation_mode(
288+
self._previous_action_mode
289+
)
230290

231291
@plugwise_command
232292
async def async_set_preset_mode(self, preset_mode: str) -> None:

homeassistant/components/plugwise/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"iot_class": "local_polling",
99
"loggers": ["plugwise"],
1010
"quality_scale": "platinum",
11-
"requirements": ["plugwise==1.8.2"],
11+
"requirements": ["plugwise==1.8.3"],
1212
"zeroconf": ["_plugwise._tcp.local."]
1313
}

homeassistant/components/plugwise/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@
314314
"invalid_xml_data": {
315315
"message": "[%key:component::plugwise::config::error::response_error%]"
316316
},
317+
"set_schedule_first": {
318+
"message": "Failed setting HVACMode, set a schedule first."
319+
},
317320
"unsupported_firmware": {
318321
"message": "[%key:component::plugwise::config::error::unsupported%]"
319322
}

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/components/plugwise/test_climate.py

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,18 @@
2323
HVACAction,
2424
HVACMode,
2525
)
26-
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
27-
from homeassistant.core import HomeAssistant
26+
from homeassistant.components.plugwise.climate import PlugwiseClimateExtraStoredData
27+
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_OFF, STATE_ON
28+
from homeassistant.core import HomeAssistant, State
2829
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
2930
from homeassistant.helpers import entity_registry as er
3031

31-
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
32+
from tests.common import (
33+
MockConfigEntry,
34+
async_fire_time_changed,
35+
mock_restore_cache_with_extra_data,
36+
snapshot_platform,
37+
)
3238

3339
HA_PLUGWISE_SMILE_ASYNC_UPDATE = (
3440
"homeassistant.components.plugwise.coordinator.Smile.async_update"
@@ -105,7 +111,9 @@ async def test_adam_climate_entity_climate_changes(
105111
)
106112
assert mock_smile_adam.set_schedule_state.call_count == 2
107113
mock_smile_adam.set_schedule_state.assert_called_with(
108-
"c50f167537524366a5af7aa3942feb1e", HVACMode.OFF
114+
"c50f167537524366a5af7aa3942feb1e",
115+
STATE_OFF,
116+
"GF7 Woonkamer",
109117
)
110118

111119
with pytest.raises(
@@ -138,6 +146,98 @@ async def test_adam_climate_adjust_negative_testing(
138146
)
139147

140148

149+
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
150+
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
151+
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
152+
async def test_adam_restore_state_climate(
153+
hass: HomeAssistant,
154+
mock_smile_adam_heat_cool: MagicMock,
155+
mock_config_entry: MockConfigEntry,
156+
freezer: FrozenDateTimeFactory,
157+
) -> None:
158+
"""Test restore_state for climate with restored schedule."""
159+
mock_restore_cache_with_extra_data(
160+
hass,
161+
[
162+
(
163+
State("climate.living_room", "heat"),
164+
PlugwiseClimateExtraStoredData(
165+
last_active_schedule=None,
166+
previous_action_mode="heating",
167+
).as_dict(),
168+
),
169+
(
170+
State("climate.bathroom", "heat"),
171+
PlugwiseClimateExtraStoredData(
172+
last_active_schedule="Badkamer",
173+
previous_action_mode=None,
174+
).as_dict(),
175+
),
176+
],
177+
)
178+
179+
mock_config_entry.add_to_hass(hass)
180+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
181+
await hass.async_block_till_done()
182+
183+
assert (state := hass.states.get("climate.living_room"))
184+
assert state.state == "heat"
185+
186+
# Verify a HomeAssistantError is raised setting a schedule with last_active_schedule = None
187+
with pytest.raises(HomeAssistantError):
188+
await hass.services.async_call(
189+
CLIMATE_DOMAIN,
190+
SERVICE_SET_HVAC_MODE,
191+
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.AUTO},
192+
blocking=True,
193+
)
194+
195+
data = mock_smile_adam_heat_cool.async_update.return_value
196+
data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "off"
197+
data["da224107914542988a88561b4452b0f6"]["selec_regulation_mode"] = "off"
198+
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
199+
freezer.tick(timedelta(minutes=1))
200+
async_fire_time_changed(hass)
201+
await hass.async_block_till_done()
202+
203+
assert (state := hass.states.get("climate.living_room"))
204+
assert state.state == "off"
205+
206+
# Verify restoration of previous_action_mode = heating
207+
await hass.services.async_call(
208+
CLIMATE_DOMAIN,
209+
SERVICE_SET_HVAC_MODE,
210+
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.HEAT},
211+
blocking=True,
212+
)
213+
# Verify set_schedule_state was called with the restored schedule
214+
mock_smile_adam_heat_cool.set_regulation_mode.assert_called_with(
215+
"heating",
216+
)
217+
218+
data = mock_smile_adam_heat_cool.async_update.return_value
219+
data["f871b8c4d63549319221e294e4f88074"]["climate_mode"] = "heat"
220+
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
221+
freezer.tick(timedelta(minutes=1))
222+
async_fire_time_changed(hass)
223+
await hass.async_block_till_done()
224+
225+
assert (state := hass.states.get("climate.bathroom"))
226+
assert state.state == "heat"
227+
228+
# Verify restoration is used when setting a schedule
229+
await hass.services.async_call(
230+
CLIMATE_DOMAIN,
231+
SERVICE_SET_HVAC_MODE,
232+
{ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.AUTO},
233+
blocking=True,
234+
)
235+
# Verify set_schedule_state was called with the restored schedule
236+
mock_smile_adam_heat_cool.set_schedule_state.assert_called_with(
237+
"f871b8c4d63549319221e294e4f88074", STATE_ON, "Badkamer"
238+
)
239+
240+
141241
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
142242
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
143243
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@@ -173,6 +273,7 @@ async def test_adam_3_climate_entity_attributes(
173273
]
174274
data = mock_smile_adam_heat_cool.async_update.return_value
175275
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating"
276+
data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "heat"
176277
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING
177278
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False
178279
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True
@@ -193,6 +294,7 @@ async def test_adam_3_climate_entity_attributes(
193294

194295
data = mock_smile_adam_heat_cool.async_update.return_value
195296
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling"
297+
data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "cool"
196298
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING
197299
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True
198300
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False
@@ -334,7 +436,9 @@ async def test_anna_climate_entity_climate_changes(
334436
)
335437
assert mock_smile_anna.set_schedule_state.call_count == 1
336438
mock_smile_anna.set_schedule_state.assert_called_with(
337-
"c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF
439+
"c784ee9fdab44e1395b8dee7d7a497d5",
440+
STATE_OFF,
441+
"standaard",
338442
)
339443

340444
# Mock user deleting last schedule from app or browser

0 commit comments

Comments
 (0)