diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1a53499..83c5440f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Versions from 0.40 and up +## v0.63.1 + +- Implement Core PR [#163713](https://github.com/home-assistant/core/pull/163713) via PR [#1024](https://github.com/plugwise/plugwise-beta/pull/1024) + ## v0.63.0 - Remove climate home-kit emulation option via PR [#1023](https://github.com/plugwise/plugwise-beta/pull/1023) diff --git a/custom_components/plugwise/climate.py b/custom_components/plugwise/climate.py index a362170ee..71bcf4cbd 100644 --- a/custom_components/plugwise/climate.py +++ b/custom_components/plugwise/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Any from homeassistant.components.climate import ( @@ -100,10 +100,7 @@ class PlugwiseClimateExtraStoredData(ExtraStoredData): 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, - } + return asdict(self) @classmethod def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData: @@ -124,7 +121,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): _enable_turn_on_off_backwards_compatibility = False _last_active_schedule: str | None = None - _previous_action_mode: str | None = HVACAction.HEATING.value # Upstream + _previous_action_mode: str | None = HVACAction.HEATING.value async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" @@ -135,7 +132,9 @@ async def async_added_to_hass(self) -> None: extra_data.as_dict() ) self._last_active_schedule = plugwise_extra_data.last_active_schedule - self._previous_action_mode = plugwise_extra_data.previous_action_mode + self._previous_action_mode = ( + plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value + ) def __init__( self, @@ -145,7 +144,8 @@ def __init__( """Set up the Plugwise API.""" super().__init__(coordinator, device_id) - 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._location = device_id if (location := self.device.get(LOCATION)) is not None: @@ -162,8 +162,8 @@ def __init__( # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE if ( - self.coordinator.api.cooling_present - and coordinator.api.smile.name != "Adam" + self._api.cooling_present + and self._api.smile.name != "Adam" ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -239,13 +239,12 @@ def hvac_modes(self) -> list[HVACMode]: 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: - selected = self._gateway_data.get(SELECT_REGULATION_MODE) - if selected == HVACAction.COOLING.value: - hvac_modes.append(HVACMode.COOL) - if selected == HVACAction.HEATING.value: + if "heating" in self._gateway_data[REGULATION_MODES]: hvac_modes.append(HVACMode.HEAT) + if "cooling" in self._gateway_data[REGULATION_MODES]: + hvac_modes.append(HVACMode.COOL) else: hvac_modes.append(HVACMode.HEAT_COOL) else: @@ -292,43 +291,84 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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.""" + if hvac_mode == HVACMode.HEAT: + return HVACAction.HEATING.value + if hvac_mode == HVACMode.COOL: + return HVACAction.COOLING.value + return None @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the hvac mode.""" + """Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule).""" if hvac_mode == self.hvac_mode: return + current_schedule = self.device.get("select_schedule") + # OFF: single API call if hvac_mode == HVACMode.OFF: - await self.coordinator.api.set_regulation_mode(hvac_mode.value) - else: - 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( - translation_domain=DOMAIN, - translation_key=ERROR_NO_SCHEDULE, - ) + await self._api.set_regulation_mode(hvac_mode.value) + return - await self.coordinator.api.set_schedule_state( - self._location, - STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF, - desired, + # 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 ) - if self.hvac_mode == HVACMode.OFF and self._previous_action_mode: - await self.coordinator.api.set_regulation_mode( - self._previous_action_mode - ) + ): + await self._api.set_regulation_mode(regulation) + return + + # Manual mode: ensure regulation and turn off schedule when needed + if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL): + await self._set_manual_hvac_mode(hvac_mode, current_schedule) + 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, + ) + + await self._set_auto_hvac_mode(desired_schedule) + + async def _set_manual_hvac_mode(self, mode: HVACMode, schedule: str | None) -> None: + """Execute relevant api-functions based on the requested manual and present mode.""" + if ( + regulation := self._regulation_mode_for_hvac(mode) or ( + self._previous_action_mode + if self.hvac_mode in (HVACMode.HEAT_COOL, HVACMode.OFF) + else None + ) + ): + await self._api.set_regulation_mode(regulation) + + if ( + self.hvac_mode == HVACMode.OFF and schedule not in (None, "off") + ) or (self.hvac_mode == HVACMode.AUTO and schedule is not None): + await self._api.set_schedule_state(self._location, STATE_OFF, schedule) + + async def _set_auto_hvac_mode(self, schedule: str) -> None: + """Execute relevant api-functions based on the requested auto and present mode.""" + if self._previous_action_mode: + if self.hvac_mode == HVACMode.OFF: + await self._api.set_regulation_mode(self._previous_action_mode) + await self._api.set_schedule_state(self._location, STATE_ON, 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/custom_components/plugwise/manifest.json b/custom_components/plugwise/manifest.json index 825e0d762..3d5bb03fc 100644 --- a/custom_components/plugwise/manifest.json +++ b/custom_components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "requirements": ["plugwise==1.11.2"], - "version": "0.63.0", + "version": "0.63.1", "zeroconf": ["_plugwise._tcp.local."] } diff --git a/pyproject.toml b/pyproject.toml index 5be9f8167..801e2daf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "plugwise-beta" -version = "0.63.0" +version = "0.63.1" 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 9a348bc67..56af94292 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -167,7 +167,7 @@ async def test_adam_restore_state_climate( State("climate.bathroom", "heat"), PlugwiseClimateExtraStoredData( last_active_schedule="Badkamer", - previous_action_mode=None, + previous_action_mode="heating", ).as_dict(), ), ], @@ -207,7 +207,6 @@ async def test_adam_restore_state_climate( {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", ) @@ -222,7 +221,7 @@ async def test_adam_restore_state_climate( assert (state := hass.states.get("climate.bathroom")) assert state.state == "heat" - # Verify restoration is used when setting a schedule + # Verify restoration is used when setting the schedule, schedule == "off" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -233,7 +232,27 @@ async def test_adam_restore_state_climate( mock_smile_adam_heat_cool.set_schedule_state.assert_called_with( "f871b8c4d63549319221e294e4f88074", STATE_ON, "Badkamer" ) + assert mock_smile_adam_heat_cool.set_schedule_state.call_count == 1 + data = mock_smile_adam_heat_cool.async_update.return_value + data["f871b8c4d63549319221e294e4f88074"]["climate_mode"] = "heat" + data["f871b8c4d63549319221e294e4f88074"]["select_schedule"] = "Badkamer" + 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 the active schedule is used + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + assert mock_smile_adam_heat_cool.set_schedule_state.call_count == 2 @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @@ -255,10 +274,33 @@ async def test_adam_2_climate_snapshot( async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, + mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test creation of adam climate device environment.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State("climate.living_room", "heat"), + PlugwiseClimateExtraStoredData( + last_active_schedule="Weekschema", + 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() + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL @@ -266,8 +308,10 @@ async def test_adam_3_climate_entity_attributes( assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, + HVACMode.HEAT, HVACMode.COOL, ] + data = mock_smile_adam_heat_cool.async_update.return_value data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "heat" @@ -283,11 +327,6 @@ async def test_adam_3_climate_entity_attributes( assert state assert state.state == HVACMode.HEAT assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] data = mock_smile_adam_heat_cool.async_update.return_value data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" @@ -304,12 +343,64 @@ async def test_adam_3_climate_entity_attributes( assert state assert state.state == HVACMode.COOL assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "off" + data["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "off" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.OFF + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False + 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" + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + + # Test setting regulation_mode to cooling, from off, ignoring the restored previous_action_mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + # Verify set_regulation_mode was called with the user-selected HVACMode + mock_smile_adam_heat_cool.set_regulation_mode.assert_called_with( + "cooling", + ) + + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "off" + data["f871b8c4d63549319221e294e4f88074"]["climate_mode"] = "off" + data["f871b8c4d63549319221e294e4f88074"]["control_state"] = HVACAction.OFF + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False + 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 == "off" + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + + # Test setting to AUTO, from OFF + 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_regulation_mode was called with the user-selected HVACMode + mock_smile_adam_heat_cool.set_regulation_mode.assert_called_with( + "cooling", + ) + # And set_schedule_state was called with the restored last_active_schedule + mock_smile_adam_heat_cool.set_schedule_state.assert_called_with( + "f871b8c4d63549319221e294e4f88074", STATE_ON, "Badkamer", + ) async def test_adam_climate_off_mode_change( hass: HomeAssistant, @@ -329,7 +420,7 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_schedule_state.call_count == 0 assert mock_smile_adam_jip.set_regulation_mode.call_count == 1 mock_smile_adam_jip.set_regulation_mode.assert_called_with("heating") @@ -345,7 +436,7 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_schedule_state.call_count == 0 assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 mock_smile_adam_jip.set_regulation_mode.assert_called_with("off") @@ -361,7 +452,7 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_schedule_state.call_count == 0 assert mock_smile_adam_jip.set_regulation_mode.call_count == 2