issue #683 - incorrect temperature reversion on window close (#734)

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
This commit is contained in:
Jean-Marc Collin
2024-12-22 15:49:46 +01:00
committed by GitHub
parent 6c5ddc315c
commit 65b4690e64
3 changed files with 304 additions and 75 deletions

View File

@@ -1,15 +1,13 @@
// See https://aka.ms/vscode-remote/devcontainer.json for format details. // See https://aka.ms/vscode-remote/devcontainer.json for format details.
// "image": "ghcr.io/ludeeus/devcontainer/integration:latest", // "image": "ghcr.io/ludeeus/devcontainer/integration:latest",
{ {
"build": { "build": {
"dockerfile": "Dockerfile" "dockerfile": "Dockerfile"
}, },
"name": "Versatile Thermostat integration", "name": "Versatile Thermostat integration",
"appPort": [ "appPort": ["8123:8123"],
"8123:8123" // "postCreateCommand": "container install",
], "postCreateCommand": "./container dev-setup",
// "postCreateCommand": "container install",
"postCreateCommand": "./container dev-setup",
"mounts": [ "mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", "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" "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": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
"ms-python.python", "ms-python.python",
"ms-python.pylint", "ms-python.pylint",
// Doesn't work (crash). Default in python is to use Jedi see Settings / Python / Default Language // Doesn't work (crash). Default in python is to use Jedi see Settings / Python / Default Language
// "ms-python.vscode-pylance", // "ms-python.vscode-pylance",
"ms-python.isort", "ms-python.isort",
"ms-python.black-formatter", "ms-python.black-formatter",
"visualstudioexptteam.vscodeintellicode", "visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"github.vscode-pull-request-github", "github.vscode-pull-request-github",
"ryanluker.vscode-coverage-gutters", "ryanluker.vscode-coverage-gutters",
"ferrierbenjamin.fold-unfold-all-icone", "ferrierbenjamin.fold-unfold-all-icone",
"LittleFoxTeam.vscode-python-test-adapter", "LittleFoxTeam.vscode-python-test-adapter",
"donjayamanne.githistory", "donjayamanne.githistory",
"waderyan.gitblame", "waderyan.gitblame",
"keesschollaart.vscode-home-assistant", "keesschollaart.vscode-home-assistant",
"vscode.markdown-math", "vscode.markdown-math",
"yzhang.markdown-all-in-one", "yzhang.markdown-all-in-one",
"github.vscode-github-actions", "github.vscode-github-actions",
"azuretools.vscode-docker" "azuretools.vscode-docker",
], "huizhou.githd",
"settings": { ],
"files.eol": "\n", "settings": {
"editor.tabSize": 4, "files.eol": "\n",
"terminal.integrated.profiles.linux": { "editor.tabSize": 4,
"bash": { "terminal.integrated.profiles.linux": {
"path": "bash", "bash": {
"args": [] "path": "bash",
} "args": []
}, }
"terminal.integrated.defaultProfile.linux": "bash", },
// "terminal.integrated.shell.linux": "/bin/bash", "terminal.integrated.defaultProfile.linux": "bash",
"python.pythonPath": "/usr/bin/python3", // "terminal.integrated.shell.linux": "/bin/bash",
"python.analysis.autoSearchPaths": true, "python.pythonPath": "/usr/bin/python3",
"pylint.lintOnChange": false, "python.analysis.autoSearchPaths": true,
"python.formatting.provider": "black", "pylint.lintOnChange": false,
"python.formatting.blackPath": "/usr/local/py-utils/bin/black", "python.formatting.provider": "black",
"editor.formatOnPaste": false, "python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"editor.formatOnSave": true, "editor.formatOnPaste": false,
"editor.formatOnType": true, "editor.formatOnSave": true,
"files.trimTrailingWhitespace": true "editor.formatOnType": true,
// "python.experiments.optOutFrom": ["pythonTestAdapter"], "files.trimTrailingWhitespace": true
// "python.analysis.logLevel": "Trace" // "python.experiments.optOutFrom": ["pythonTestAdapter"],
} // "python.analysis.logLevel": "Trace"
} }
} }
}
} }

View File

@@ -1269,7 +1269,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
): ):
"""Set new preset mode.""" """Set new preset mode."""
# Wer accept a new preset when: # We accept a new preset when:
# 1. last_central_mode is not set, # 1. last_central_mode is not set,
# 2. or last_central_mode is AUTO, # 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) # 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 return
old_preset_mode = self._attr_preset_mode old_preset_mode = self._attr_preset_mode
recalculate = True
if preset_mode == PRESET_NONE: if preset_mode == PRESET_NONE:
self._attr_preset_mode = PRESET_NONE self._attr_preset_mode = PRESET_NONE
if self._saved_target_temp: if self._saved_target_temp:
@@ -1337,16 +1338,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
if self._attr_preset_mode == PRESET_NONE: if self._attr_preset_mode == PRESET_NONE:
self._saved_target_temp = self._target_temp self._saved_target_temp = self._target_temp
self._attr_preset_mode = preset_mode self._attr_preset_mode = preset_mode
await self._async_internal_set_temperature( # Switch the temperature if window is not 'on'
self.find_preset_temp(preset_mode) 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: if overwrite_saved_preset:
self.save_preset_mode() self.save_preset_mode()
self.recalculate() self.recalculate()
# Notify only if there was a real change # Notify only if there was a real change
if self._attr_preset_mode != old_preset_mode: if self._attr_preset_mode != old_preset_mode:
self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_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) _LOGGER.info("%s - Set target temp: %s", self, temperature)
if temperature is None: if temperature is None:
return return
await self._async_internal_set_temperature(temperature)
self._attr_preset_mode = PRESET_NONE self._attr_preset_mode = PRESET_NONE
self.recalculate() if self.window_state != STATE_ON:
self.reset_last_change_time_from_vtherm() await self._async_internal_set_temperature(temperature)
await self.async_control_heating(force=True) 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): async def _async_internal_set_temperature(self, temperature: float):
"""Set the target temperature and the target temperature of underlying climate if any """Set the target temperature and the target temperature of underlying climate if any"""
For testing purpose you can pass an event_timestamp.
"""
if temperature: if temperature:
self._target_temp = temperature self._target_temp = temperature
return
def get_state_date_or_now(self, state: State) -> datetime: def get_state_date_or_now(self, state: State) -> datetime:
"""Extract the last_changed state from State or return now if not available""" """Extract the last_changed state from State or return now if not available"""

View File

@@ -1762,7 +1762,7 @@ async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_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_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3, CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01, 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 assert entity.hvac_mode is HVACMode.HEAT
# No change on preset # No change on preset
assert entity.preset_mode is PRESET_BOOST assert entity.preset_mode is PRESET_BOOST
# The eco temp # The Boost temp
assert entity.target_temperature == 21 assert entity.target_temperature == 21
# Clean the entity # Clean the entity
@@ -2091,3 +2091,223 @@ async def test_bug_66(
assert entity.window_state == STATE_OFF assert entity.window_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST 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()