FIX A thermostat stays with security_default_on_percent when the preset change during security mode #49

This commit is contained in:
Jean-Marc Collin
2023-02-15 23:44:09 +01:00
parent 81b4f7e5f6
commit 08d08e52de
5 changed files with 276 additions and 44 deletions

View File

@@ -13,6 +13,8 @@ from homeassistant.core import (
callback,
CoreState,
DOMAIN as HA_DOMAIN,
Event,
State,
)
from homeassistant.components.climate import ClimateEntity
@@ -197,6 +199,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# The list of VersatileThermostat entities
# No more needed
# _registry: dict[str, object] = {}
_last_temperature_mesure: datetime
_last_ext_temperature_mesure: datetime
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
@@ -343,8 +347,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._presets_away,
)
# Will be restored if possible
self._attr_preset_mode = None
self._saved_preset_mode = None
self._attr_preset_mode = PRESET_NONE
self._saved_preset_mode = PRESET_NONE
# Power management
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
@@ -1076,6 +1080,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if preset_mode == self._attr_preset_mode and not force:
# I don't think we need to call async_write_ha_state if we didn't change the state
return
# In security mode don't change preset but memorise the new expected preset when security will be off
if preset_mode != PRESET_SECURITY and self._security_state:
_LOGGER.debug(
"%s - is in security mode. Just memorise the new expected ", self
)
if preset_mode not in HIDDEN_PRESETS:
self._saved_preset_mode = preset_mode
return
old_preset_mode = self._attr_preset_mode
if preset_mode == PRESET_NONE:
self._attr_preset_mode = PRESET_NONE
@@ -1213,9 +1227,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
@callback
async def _async_temperature_changed(self, event):
"""Handle temperature changes."""
new_state = event.data.get("new_state")
async def _async_temperature_changed(self, event: Event):
"""Handle temperature of the temperature sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - Temperature changed. Event.new_state is %s",
self,
@@ -1228,9 +1242,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.recalculate()
await self._async_control_heating(force=False)
async def _async_ext_temperature_changed(self, event):
"""Handle external temperature changes."""
new_state = event.data.get("new_state")
async def _async_ext_temperature_changed(self, event: Event):
"""Handle external temperature opf the sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - external Temperature changed. Event.new_state is %s",
self,
@@ -1409,14 +1423,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self._async_control_heating(True)
@callback
async def _async_update_temp(self, state):
async def _async_update_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_temp = float(state.state)
if math.isnan(cur_temp) or math.isinf(cur_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_temp = cur_temp
self._last_temperature_mesure = datetime.now()
self._last_temperature_mesure = (
state.last_changed if state.last_changed is not None else datetime.now()
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
@@ -1425,14 +1441,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
@callback
async def _async_update_ext_temp(self, state):
async def _async_update_ext_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_ext_temp = float(state.state)
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_ext_temp = cur_ext_temp
self._last_ext_temperature_mesure = datetime.now()
self._last_ext_temperature_mesure = (
state.last_changed if state.last_changed is not None else datetime.now()
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
@@ -1713,9 +1731,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def check_security(self) -> bool:
"""Check if last temperature date is too long"""
now = datetime.now()
delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0
delta_temp = (
now - self._last_temperature_mesure.replace(tzinfo=None)
).total_seconds() / 60.0
delta_ext_temp = (
now - self._last_ext_temperature_mesure
now - self._last_ext_temperature_mesure.replace(tzinfo=None)
).total_seconds() / 60.0
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
@@ -1735,6 +1755,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
>= self._security_min_on_percent
)
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
ret = False
if mode_cond and temp_cond and climate_cond:
if not self._security_state:
@@ -1748,17 +1779,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
ret = True
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
if mode_cond and temp_cond and switch_cond:
if not self._security_state:
_LOGGER.warning(

View File

@@ -1,7 +1,7 @@
""" Some common resources """
from unittest.mock import patch
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
from homeassistant.const import UnitOfTemperature
from homeassistant.config_entries import ConfigEntryState
@@ -9,7 +9,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from homeassistant.helpers.entity_component import EntityComponent
from ..climate import VersatileThermostat
from ..const import DOMAIN
from ..const import DOMAIN, PRESET_SECURITY, PRESET_POWER, EventType
from homeassistant.components.climate import (
ClimateEntity,
@@ -32,6 +32,11 @@ from .const import (
MOCK_PRESENCE_CONFIG,
MOCK_ADVANCED_CONFIG,
# MOCK_DEFAULT_FEATURE_CONFIG,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_NONE,
PRESET_ECO,
PRESET_ACTIVITY,
)
FULL_SWITCH_CONFIG = (
@@ -93,3 +98,19 @@ async def create_thermostat(
entity = find_my_entity(entity_id)
return entity
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
"""Sending a new temperature event simulating a change on temperature sensor"""
temp_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_temp,
last_changed=date,
last_updated=date,
)
},
)
await entity._async_temperature_changed(temp_event)

View File

@@ -2,6 +2,8 @@ from homeassistant.components.climate.const import (
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
PRESET_ACTIVITY,
)
from custom_components.versatile_thermostat.const import (
CONF_NAME,

View File

@@ -0,0 +1,180 @@
""" Test the Security featrure """
from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import timedelta, datetime
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the security feature and https://github.com/jmcollin78/versatile_thermostat/issues/49:
1. creates a thermostat and check that security is off
2. activate security feature when date is expired
3. change the preset to boost
4. check that security is still on
5. resolve the date issue
6. check that security is off and preset is changed to boost
"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
"name": "TheOverSwitchMockName",
"thermostat_type": "thermostat_over_switch",
"temperature_sensor_entity_id": "sensor.mock_temp_sensor",
"external_temperature_sensor_entity_id": "sensor.mock_ext_temp_sensor",
"cycle_min": 5,
"temp_min": 15,
"temp_max": 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"use_window_feature": False,
"use_motion_feature": False,
"use_power_feature": False,
"use_presence_feature": False,
"heater_entity_id": "switch.mock_switch",
"proportional_function": "tpi",
"tpi_coef_int": 0.3,
"tpi_coef_ext": 0.01,
"minimal_activation_delay": 30,
"security_delay_min": 5, # 5 minutes
"security_min_on_percent": 0.2,
"security_default_on_percent": 0.1,
},
)
# 1. creates a thermostat and check that security is off
now: datetime = datetime.now()
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
assert entity._security_state is False
assert entity.preset_mode is not PRESET_SECURITY
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity._last_ext_temperature_mesure is not None
assert entity._last_temperature_mesure is not None
assert (entity._last_temperature_mesure - now).total_seconds() < 1
assert (entity._last_ext_temperature_mesure - now).total_seconds() < 1
# set a preset
assert entity.preset_mode is PRESET_NONE
await entity.async_set_preset_mode(PRESET_COMFORT)
assert entity.preset_mode is PRESET_COMFORT
# Turn On the thermostat
assert entity.hvac_mode == HVACMode.OFF
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode == HVACMode.HEAT
# 2. activate security feature when date is expired
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
event_timestamp = now - timedelta(minutes=6)
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
await send_temperature_change_event(entity, 15, event_timestamp)
assert entity._security_state is True
assert entity.preset_mode == PRESET_SECURITY
assert entity._saved_preset_mode == PRESET_COMFORT
assert entity._prop_algorithm.on_percent == 0.1
assert entity._prop_algorithm.calculated_on_percent == 0.9
assert mock_send_event.call_count == 3
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_SECURITY}),
call.send_event(
EventType.TEMPERATURE_EVENT,
{
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"current_temp": 15,
"current_ext_temp": None,
"target_temp": 18,
},
),
call.send_event(
EventType.SECURITY_EVENT,
{
"type": "start",
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"current_temp": 15,
"current_ext_temp": None,
"target_temp": 18,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 1
# 3. Change the preset to Boost (we should stay in SECURITY)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
await entity.async_set_preset_mode(PRESET_BOOST)
# 4. check that security is still on
assert entity._security_state is True
assert entity._prop_algorithm.on_percent == 0.1
assert entity._prop_algorithm.calculated_on_percent == 0.9
assert entity._saved_preset_mode == PRESET_BOOST
assert entity.preset_mode is PRESET_SECURITY
# 5. resolve the datetime issue
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
event_timestamp = datetime.now()
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
await send_temperature_change_event(entity, 15.2, event_timestamp)
assert entity._security_state is False
assert entity.preset_mode == PRESET_BOOST
assert entity._saved_preset_mode == PRESET_BOOST
assert entity._prop_algorithm.on_percent == 1.0
assert entity._prop_algorithm.calculated_on_percent == 1.0
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_BOOST}),
call.send_event(
EventType.SECURITY_EVENT,
{
"type": "end",
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"current_temp": 15.2,
"current_ext_temp": None,
"target_temp": 19,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0

View File

@@ -1,6 +1,5 @@
""" Test the normal start of a Thermostat """
from unittest.mock import patch, call
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
@@ -13,9 +12,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..const import DOMAIN, EventType
from .commons import MockClimate, FULL_SWITCH_CONFIG, PARTIAL_CLIMATE_CONFIG
from .commons import *
async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
@@ -51,7 +48,14 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_mode is None
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
PRESET_ACTIVITY,
]
assert entity.preset_mode is PRESET_NONE
assert entity._security_state is False
assert entity._window_state is None
assert entity._motion_state is None
@@ -61,16 +65,15 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
# Impossible to make this work, but it works...
# assert mock_send_event.assert_has_calls(
# [
# call.send_event(EventType.PRESET_EVENT, {"preset": None}),
# call.send_event(
# EventType.HVAC_MODE_EVENT,
# {"hvac_mode": HVACMode.OFF},
# ),
# ]
# )
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.OFF},
),
]
)
async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_state):
@@ -111,7 +114,13 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_mode is None
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
assert entity._security_state is False
assert entity._window_state is None
assert entity._motion_state is None
@@ -121,7 +130,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": None}),
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.OFF},