From 65b4690e64b67261a8f5bdcafbce6e8e032ca22e Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 22 Dec 2024 15:49:46 +0100 Subject: [PATCH] issue #683 - incorrect temperature reversion on window close (#734) Co-authored-by: Jean-Marc Collin --- .devcontainer/devcontainer.json | 113 +++++---- .../versatile_thermostat/base_thermostat.py | 42 ++-- tests/test_window.py | 224 +++++++++++++++++- 3 files changed, 304 insertions(+), 75 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 34d85bb..3e842c2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,15 +1,13 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. // "image": "ghcr.io/ludeeus/devcontainer/integration:latest", { - "build": { - "dockerfile": "Dockerfile" - }, - "name": "Versatile Thermostat integration", - "appPort": [ - "8123:8123" - ], - // "postCreateCommand": "container install", - "postCreateCommand": "./container dev-setup", + "build": { + "dockerfile": "Dockerfile" + }, + "name": "Versatile Thermostat integration", + "appPort": ["8123:8123"], + // "postCreateCommand": "container install", + "postCreateCommand": "./container dev-setup", "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", @@ -17,52 +15,53 @@ "source=${localEnv:HOME}/SugarSync/Projets/home-assistant/versatile-thermostat-ui-card/dist,target=/workspaces/versatile_thermostat/config/www/community/versatile-thermostat-ui-card,type=bind,consistency=cached" ], - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.pylint", - // Doesn't work (crash). Default in python is to use Jedi see Settings / Python / Default Language - // "ms-python.vscode-pylance", - "ms-python.isort", - "ms-python.black-formatter", - "visualstudioexptteam.vscodeintellicode", - "redhat.vscode-yaml", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ferrierbenjamin.fold-unfold-all-icone", - "LittleFoxTeam.vscode-python-test-adapter", - "donjayamanne.githistory", - "waderyan.gitblame", - "keesschollaart.vscode-home-assistant", - "vscode.markdown-math", - "yzhang.markdown-all-in-one", - "github.vscode-github-actions", - "azuretools.vscode-docker" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.profiles.linux": { - "bash": { - "path": "bash", - "args": [] - } - }, - "terminal.integrated.defaultProfile.linux": "bash", - // "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": true, - "pylint.lintOnChange": false, - "python.formatting.provider": "black", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - // "python.experiments.optOutFrom": ["pythonTestAdapter"], - // "python.analysis.logLevel": "Trace" - } - } - } + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.pylint", + // Doesn't work (crash). Default in python is to use Jedi see Settings / Python / Default Language + // "ms-python.vscode-pylance", + "ms-python.isort", + "ms-python.black-formatter", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ferrierbenjamin.fold-unfold-all-icone", + "LittleFoxTeam.vscode-python-test-adapter", + "donjayamanne.githistory", + "waderyan.gitblame", + "keesschollaart.vscode-home-assistant", + "vscode.markdown-math", + "yzhang.markdown-all-in-one", + "github.vscode-github-actions", + "azuretools.vscode-docker", + "huizhou.githd", + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.profiles.linux": { + "bash": { + "path": "bash", + "args": [] + } + }, + "terminal.integrated.defaultProfile.linux": "bash", + // "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": true, + "pylint.lintOnChange": false, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + // "python.experiments.optOutFrom": ["pythonTestAdapter"], + // "python.analysis.logLevel": "Trace" + } + } + } } diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index edc6fe5..25b3e08 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1269,7 +1269,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ): """Set new preset mode.""" - # Wer accept a new preset when: + # We accept a new preset when: # 1. last_central_mode is not set, # 2. or last_central_mode is AUTO, # 3. or last_central_mode is CENTRAL_MODE_FROST_PROTECTION and preset_mode is PRESET_FROST_PROTECTION (to be abel to re-set the preset_mode) @@ -1326,6 +1326,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): return old_preset_mode = self._attr_preset_mode + recalculate = True if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE if self._saved_target_temp: @@ -1337,16 +1338,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if self._attr_preset_mode == PRESET_NONE: self._saved_target_temp = self._target_temp self._attr_preset_mode = preset_mode - await self._async_internal_set_temperature( - self.find_preset_temp(preset_mode) - ) + # Switch the temperature if window is not 'on' + if self.window_state != STATE_ON: + await self._async_internal_set_temperature( + self.find_preset_temp(preset_mode) + ) + else: + # Window is on, so we just save the new expected temp + # so that closing the window will restore it + recalculate = False + self._saved_target_temp = self.find_preset_temp(preset_mode) - self.reset_last_temperature_time(old_preset_mode) + if recalculate: + self.reset_last_temperature_time(old_preset_mode) - if overwrite_saved_preset: - self.save_preset_mode() + if overwrite_saved_preset: + self.save_preset_mode() - self.recalculate() + self.recalculate() # Notify only if there was a real change if self._attr_preset_mode != old_preset_mode: self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) @@ -1455,19 +1464,20 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): _LOGGER.info("%s - Set target temp: %s", self, temperature) if temperature is None: return - await self._async_internal_set_temperature(temperature) + self._attr_preset_mode = PRESET_NONE - self.recalculate() - self.reset_last_change_time_from_vtherm() - await self.async_control_heating(force=True) + if self.window_state != STATE_ON: + await self._async_internal_set_temperature(temperature) + self.recalculate() + self.reset_last_change_time_from_vtherm() + await self.async_control_heating(force=True) + else: + self._saved_target_temp = temperature async def _async_internal_set_temperature(self, temperature: float): - """Set the target temperature and the target temperature of underlying climate if any - For testing purpose you can pass an event_timestamp. - """ + """Set the target temperature and the target temperature of underlying climate if any""" if temperature: self._target_temp = temperature - return def get_state_date_or_now(self, state: State) -> datetime: """Extract the last_changed state from State or return now if not available""" diff --git a/tests/test_window.py b/tests/test_window.py index ec3f066..05da471 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1762,7 +1762,7 @@ async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, CONF_USE_PRESENCE_FEATURE: False, - CONF_HEATER: "switch.mock_switch", + CONF_UNDERLYING_LIST: ["switch.mock_switch"], CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_TPI_COEF_INT: 0.3, CONF_TPI_COEF_EXT: 0.01, @@ -1927,7 +1927,7 @@ async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is assert entity.hvac_mode is HVACMode.HEAT # No change on preset assert entity.preset_mode is PRESET_BOOST - # The eco temp + # The Boost temp assert entity.target_temperature == 21 # Clean the entity @@ -2091,3 +2091,223 @@ async def test_bug_66( assert entity.window_state == STATE_OFF assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_frost_temp_preset_change( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Window management with the frost_temp option and change the preset during + the window is open. This should restore the new preset temperature""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_UNDERLYING_LIST: ["switch.mock_switch"], + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_ACTION: CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_SENSOR: "binary_sensor.fake_window_sensor", + CONF_WINDOW_DELAY: 1, + }, + ) + + vtherm: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert vtherm + + await set_all_climate_preset_temp( + hass, vtherm, default_temperatures, "theoverswitchmockname" + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_BOOST) + await hass.async_block_till_done() + + assert vtherm.hvac_mode is HVACMode.HEAT + assert vtherm.preset_mode is PRESET_BOOST + assert vtherm.target_temperature == 21 + + assert vtherm.window_state is STATE_OFF + assert vtherm.is_window_auto_enabled is False + + # 1. Turn on the window sensor + now = now + timedelta(minutes=1) + vtherm._set_now(now) + with patch("homeassistant.helpers.condition.state", return_value=True): + + try_function = await send_window_change_event(vtherm, True, False, now) + + now = now + timedelta(minutes=2) + vtherm._set_now(now) + await try_function(None) + + # VTherm should have taken the window action + assert vtherm.target_temperature == 7 # Frost + # No change + assert vtherm.preset_mode is PRESET_BOOST + assert vtherm.hvac_mode is HVACMode.HEAT + + # 2. Change the preset to comfort + now = now + timedelta(minutes=1) + vtherm._set_now(now) + + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + # VTherm should have taken the new preset temperature + assert vtherm.target_temperature == 7 # frost (window is still open) + assert vtherm.preset_mode is PRESET_COMFORT + assert vtherm.hvac_mode is HVACMode.HEAT + + # 3.Turn off the window sensor + now = now + timedelta(minutes=1) + vtherm._set_now(now) + with patch("homeassistant.helpers.condition.state", return_value=True): + + try_function = await send_window_change_event(vtherm, False, True, now) + + now = now + timedelta(minutes=2) + vtherm._set_now(now) + await try_function(None) + + # VTherm should have restore the Comfort preset temperature + assert vtherm.target_temperature == 19 # restore comfort + # No change + assert vtherm.preset_mode is PRESET_COMFORT + assert vtherm.hvac_mode is HVACMode.HEAT + + # Clean the entity + vtherm.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_frost_temp_temp_change( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Window management with the frost_temp option and change the target temp during + the window is open. This should restore the new temperature""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_UNDERLYING_LIST: ["switch.mock_switch"], + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_ACTION: CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_SENSOR: "binary_sensor.fake_window_sensor", + CONF_WINDOW_DELAY: 1, + }, + ) + + vtherm: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert vtherm + + await set_all_climate_preset_temp( + hass, vtherm, default_temperatures, "theoverswitchmockname" + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_BOOST) + await hass.async_block_till_done() + + assert vtherm.hvac_mode is HVACMode.HEAT + assert vtherm.preset_mode is PRESET_BOOST + assert vtherm.target_temperature == 21 + + assert vtherm.window_state is STATE_OFF + assert vtherm.is_window_auto_enabled is False + + # 1. Turn on the window sensor + now = now + timedelta(minutes=1) + vtherm._set_now(now) + with patch("homeassistant.helpers.condition.state", return_value=True): + + try_function = await send_window_change_event(vtherm, True, False, now) + + now = now + timedelta(minutes=2) + vtherm._set_now(now) + await try_function(None) + + # VTherm should have taken the window action + assert vtherm.target_temperature == 7 # Frost + # No change + assert vtherm.preset_mode is PRESET_BOOST + assert vtherm.hvac_mode is HVACMode.HEAT + + # 2. Change the target temperature + now = now + timedelta(minutes=1) + vtherm._set_now(now) + + await vtherm.async_set_temperature(temperature=18.5) + await hass.async_block_till_done() + + # VTherm should have taken the new preset temperature + assert vtherm.target_temperature == 7 # frost (window is still open) + assert vtherm.preset_mode is PRESET_NONE + assert vtherm.hvac_mode is HVACMode.HEAT + + # 3.Turn off the window sensor + now = now + timedelta(minutes=1) + vtherm._set_now(now) + with patch("homeassistant.helpers.condition.state", return_value=True): + + try_function = await send_window_change_event(vtherm, False, True, now) + + now = now + timedelta(minutes=2) + vtherm._set_now(now) + await try_function(None) + + # VTherm should have restore the new target temperature + assert vtherm.target_temperature == 18.5 # restore new target temperature + # No change + assert vtherm.preset_mode is PRESET_NONE + assert vtherm.hvac_mode is HVACMode.HEAT + + # Clean the entity + vtherm.remove_thermostat()