diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e631d3c..7d777774d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ Versions from 0.40 and up -## Ongoing +## v0.59.0 +- New Feature: use RestoreState in climate to save schedule en regulation status, also via plugwise [v1.8.3](https://github.com/plugwise/python-plugwise/releases/tag/v1.8.3) - More Emma-related updates via plugwise [v1.8.2](https://github.com/plugwise/python-plugwise/releases/tag/v1.8.2) ## v0.58.1 diff --git a/custom_components/plugwise/__init__.py b/custom_components/plugwise/__init__.py index 3d509da98..72bee2f5d 100644 --- a/custom_components/plugwise/__init__.py +++ b/custom_components/plugwise/__init__.py @@ -146,7 +146,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) - """Migrate back to v1.1 config entry.""" if entry.version > 1: # This means the user has downgraded from a future version - return False + return False #pragma: no cover if entry.version == 1 and entry.minor_version == 2: new_data = {**entry.data} diff --git a/custom_components/plugwise/climate.py b/custom_components/plugwise/climate.py index aa008f66a..b8246530c 100644 --- a/custom_components/plugwise/climate.py +++ b/custom_components/plugwise/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from homeassistant.components.climate import ( @@ -23,7 +24,9 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from .const import ( ACTIVE_PRESET, @@ -51,6 +54,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command +ERROR_NO_SCHEDULE = "Failed setting HVACMode, set a schedule first" PARALLEL_UPDATES = 0 @@ -97,7 +101,30 @@ def _add_entities() -> None: entry.async_on_unload(coordinator.async_add_listener(_add_entities)) -class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): +@dataclass +class PlugwiseClimateExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_active_schedule: str | None + previous_action_mode: str | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the text data.""" + return { + "last_active_schedule": self.last_active_schedule, + "previous_action_mode": self.previous_action_mode, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData: + """Initialize a stored data object from a dict.""" + return cls( + last_active_schedule=restored.get("last_active_schedule"), + previous_action_mode=restored.get("previous_action_mode"), + ) + + +class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): """Representation of a Plugwise thermostat.""" _attr_has_entity_name = True @@ -106,9 +133,21 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_translation_key = DOMAIN _enable_turn_on_off_backwards_compatibility = False - _previous_mode: str = HVACAction.HEATING # Upstream + _last_active_schedule: str | None = None + _previous_action_mode: str | None = HVACAction.HEATING.value # Upstream _homekit_mode: HVACMode | None = None # pw-beta homekit emulation + intentional unsort + 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 + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, @@ -121,7 +160,6 @@ def __init__( gateway_id: str = coordinator.api.gateway_id self._gateway_data = coordinator.data[gateway_id] self._homekit_enabled = homekit_enabled # pw-beta homekit emulation - self._location = device_id if (location := self.device.get(LOCATION)) is not None: self._location = location @@ -151,25 +189,19 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets - def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None: - """Return the previous action-mode when the regulation-mode is not heating or cooling. - - Helper for set_hvac_mode(). - """ - # When no cooling available, _previous_mode is always heating - if ( - REGULATION_MODES in self._gateway_data - and HVACAction.COOLING in self._gateway_data[REGULATION_MODES] - ): - mode = self._gateway_data[SELECT_REGULATION_MODE] - if mode in (HVACAction.COOLING, HVACAction.HEATING): - self._previous_mode = mode - @property def current_temperature(self) -> float | None: """Return the current temperature.""" return self.device.get(SENSORS, {}).get(ATTR_TEMPERATURE) + @property + def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData: + """Return text specific state data to be restored.""" + return PlugwiseClimateExtraStoredData( + last_active_schedule=self._last_active_schedule, + previous_action_mode=self._previous_action_mode, + ) + @property def target_temperature(self) -> float | None: """Return the temperature we try to reach. @@ -203,13 +235,14 @@ def hvac_mode(self) -> HVACMode: return HVACMode.HEAT # pragma: no cover try: hvac = HVACMode(mode) - except ValueError: + except ValueError: # pragma: no cover return HVACMode.HEAT # pragma: no cover if hvac not in self.hvac_modes: return HVACMode.HEAT # pragma: no cover # pw-beta homekit emulation if self._homekit_enabled and self._homekit_mode == HVACMode.OFF: return HVACMode.OFF # pragma: no cover + return hvac @property @@ -228,9 +261,9 @@ def hvac_modes(self) -> list[HVACMode]: if self.coordinator.api.cooling_present: if REGULATION_MODES in self._gateway_data: selected = self._gateway_data.get(SELECT_REGULATION_MODE) - if selected == HVACAction.COOLING: + if selected == HVACAction.COOLING.value: hvac_modes.append(HVACMode.COOL) - if selected == HVACAction.HEATING: + if selected == HVACAction.HEATING.value: hvac_modes.append(HVACMode.HEAT) else: hvac_modes.append(HVACMode.HEAT_COOL) @@ -242,8 +275,15 @@ def hvac_modes(self) -> list[HVACMode]: @property def hvac_action(self) -> HVACAction: # pw-beta add to Core """Return the current running hvac operation if supported.""" - # Keep track of the previous action-mode - self._previous_action_mode(self.coordinator) + # Keep track of the previous hvac_action mode. + # When no cooling available, _previous_action_mode is always heating + if ( + REGULATION_MODES in self._gateway_data + and HVACAction.COOLING.value in self._gateway_data[REGULATION_MODES] + ): + mode = self._gateway_data[SELECT_REGULATION_MODE] + if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value): + self._previous_action_mode = mode if (action := self.device.get(CONTROL_STATE)) is not None: return HVACAction(action) @@ -280,20 +320,38 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: return if hvac_mode != HVACMode.OFF: + current = self.device.get("select_schedule") + desired = current + + # Capture the last valid schedule + if desired and desired != "off": + self._last_active_schedule = desired + elif desired == "off": + desired = self._last_active_schedule + + # Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring + if hvac_mode == HVACMode.AUTO and not desired: + raise HomeAssistantError(ERROR_NO_SCHEDULE) + await self.coordinator.api.set_schedule_state( self._location, STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF, + desired, ) + await self._homekit_translate_or_not(hvac_mode) # pw-beta + + async def _homekit_translate_or_not(self, mode: HVACMode) -> None: + """Mimic HomeKit by setting a suitable preset, when homekit mode is enabled.""" if ( - not self._homekit_enabled - ): # pw-beta: feature request - mimic HomeKit behavior - if hvac_mode == HVACMode.OFF: - await self.coordinator.api.set_regulation_mode(hvac_mode) - elif self.hvac_mode == HVACMode.OFF: - await self.coordinator.api.set_regulation_mode(self._previous_mode) - else: - self._homekit_mode = hvac_mode # pragma: no cover + not self._homekit_enabled # pw-beta + ): + if mode == HVACMode.OFF: + await self.coordinator.api.set_regulation_mode(mode.value) + elif self.hvac_mode == HVACMode.OFF and self._previous_action_mode: + await self.coordinator.api.set_regulation_mode(self._previous_action_mode) + else: # pw-beta + self._homekit_mode = mode # pragma: no cover if self._homekit_mode == HVACMode.OFF: # pragma: no cover await self.async_set_preset_mode(PRESET_AWAY) # pragma: no cover if ( diff --git a/custom_components/plugwise/manifest.json b/custom_components/plugwise/manifest.json index 84e982501..5bf957fc5 100644 --- a/custom_components/plugwise/manifest.json +++ b/custom_components/plugwise/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.8.2"], - "version": "0.58.1", + "requirements": ["plugwise==1.8.3"], + "version": "0.59.0", "zeroconf": ["_plugwise._tcp.local."] } diff --git a/pyproject.toml b/pyproject.toml index 6bf270b54..591801c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "plugwise-beta" -version = "0.58.2" +version = "0.59.0" description = "Plugwise beta custom-component" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index f7d70308e..e3cb4fa20 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -22,13 +22,19 @@ HVACAction, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -from homeassistant.core import HomeAssistant +from homeassistant.components.plugwise.climate import PlugwiseClimateExtraStoredData +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from syrupy.assertion import SnapshotAssertion -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, + snapshot_platform, +) HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( "homeassistant.components.plugwise.coordinator.Smile.async_update" @@ -105,7 +111,7 @@ async def test_adam_climate_entity_climate_changes( ) assert mock_smile_adam.set_schedule_state.call_count == 2 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", HVACMode.OFF + "c50f167537524366a5af7aa3942feb1e", STATE_OFF, "GF7 Woonkamer", ) with pytest.raises( @@ -122,7 +128,6 @@ async def test_adam_climate_entity_climate_changes( blocking=True, ) - async def test_adam_climate_adjust_negative_testing( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -138,6 +143,98 @@ async def test_adam_climate_adjust_negative_testing( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_restore_state_climate( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test restore_state for climate with restored schedule.""" + 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=None, + ).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 == "heat" + + # Verify a HomeAssistantError is raised setting a schedule 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, + ) + + data = mock_smile_adam_heat_cool.async_update.return_value + data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "off" + data["da224107914542988a88561b4452b0f6"]["selec_regulation_mode"] = "off" + 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() + + assert (state := hass.states.get("climate.living_room")) + assert state.state == "off" + + # Verify restoration of previous_action_mode = heating + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + # Verify set_schedule_state was called with the restored schedule + mock_smile_adam_heat_cool.set_regulation_mode.assert_called_with( + "heating", + ) + + data = mock_smile_adam_heat_cool.async_update.return_value + data["f871b8c4d63549319221e294e4f88074"]["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() + + assert (state := hass.states.get("climate.bathroom")) + assert state.state == "heat" + + # Verify restoration is used when setting a schedule + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + # Verify set_schedule_state was called with the restored schedule + mock_smile_adam_heat_cool.set_schedule_state.assert_called_with( + "f871b8c4d63549319221e294e4f88074", STATE_ON, "Badkamer" + ) + + @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) @@ -173,6 +270,7 @@ async def test_adam_3_climate_entity_attributes( ] data = mock_smile_adam_heat_cool.async_update.return_value data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "heat" data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True @@ -193,6 +291,7 @@ async def test_adam_3_climate_entity_attributes( data = mock_smile_adam_heat_cool.async_update.return_value data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "cool" data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False @@ -334,7 +433,7 @@ async def test_anna_climate_entity_climate_changes( ) assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF + "c784ee9fdab44e1395b8dee7d7a497d5", STATE_OFF, "standaard", ) # Mock user deleting last schedule from app or browser