Implement a keep-alive feature for directly controlled heater switches (#345)
* Add keep_alive feature for directly controlled switches * Add test cases for the switch keep-alive feature * Add documentation (readme) and translations for the keep-alive feature
This commit is contained in:
committed by
GitHub
parent
4f349d6f6f
commit
1f13eb4f37
@@ -10,7 +10,7 @@
|
|||||||
"postCreateCommand": "./container dev-setup",
|
"postCreateCommand": "./container dev-setup",
|
||||||
|
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=/Users/jmcollin/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
|
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
|
||||||
// uncomment this to get the versatile-thermostat-ui-card
|
// uncomment this to get the versatile-thermostat-ui-card
|
||||||
"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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ En fonction de votre choix sur le type de thermostat, vous devrez choisir une ou
|
|||||||
|
|
||||||
### Pour un thermostat de type ```thermostat_over_switch```
|
### Pour un thermostat de type ```thermostat_over_switch```
|
||||||

|

|
||||||
|
|
||||||
|
Certains équipements nécessitent d'être périodiquement sollicités pour empêcher un arrêt de sécurité. Connu sous le nom de "keep-alive" cette fonction est activable en entrant un nombre de secondes non nul dans le champ d'intervalle keep-alive du thermostat. Pour désactiver la fonction ou en cas de doute, laissez-le vide ou entrez zéro (valeur par défaut).
|
||||||
|
|
||||||
L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme).
|
L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme).
|
||||||
Si plusieurs entités de type sont configurées, la thermostat décale les activations afin de minimiser le nombre de switch actif à un instant t. Ca permet une meilleure répartition de la puissance puisque chaque radiateur va s'allumer à son tour.
|
Si plusieurs entités de type sont configurées, la thermostat décale les activations afin de minimiser le nombre de switch actif à un instant t. Ca permet une meilleure répartition de la puissance puisque chaque radiateur va s'allumer à son tour.
|
||||||
Exemple de déclenchement synchronisé :
|
Exemple de déclenchement synchronisé :
|
||||||
@@ -689,6 +692,7 @@ context:
|
|||||||
| ``heater_entity2_id`` | 2ème radiateur | X | - | - | - |
|
| ``heater_entity2_id`` | 2ème radiateur | X | - | - | - |
|
||||||
| ``heater_entity3_id`` | 3ème radiateur | X | - | - | - |
|
| ``heater_entity3_id`` | 3ème radiateur | X | - | - | - |
|
||||||
| ``heater_entity4_id`` | 4ème radiateur | X | - | - | - |
|
| ``heater_entity4_id`` | 4ème radiateur | X | - | - | - |
|
||||||
|
| ``heater_keep_alive`` | Intervalle de rafraichissement du switch | X | - | - | - |
|
||||||
| ``proportional_function`` | Algorithme | X | - | - | - |
|
| ``proportional_function`` | Algorithme | X | - | - | - |
|
||||||
| ``climate_entity1_id`` | Thermostat sous-jacent | - | X | - | - |
|
| ``climate_entity1_id`` | Thermostat sous-jacent | - | X | - | - |
|
||||||
| ``climate_entity2_id`` | 2ème thermostat sous-jacent | - | X | - | - |
|
| ``climate_entity2_id`` | 2ème thermostat sous-jacent | - | X | - | - |
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -41,7 +41,7 @@
|
|||||||
- [How to find the right service?](#how-to-find-the-right-service)
|
- [How to find the right service?](#how-to-find-the-right-service)
|
||||||
- [The events](#the-events)
|
- [The events](#the-events)
|
||||||
- [Warning](#warning)
|
- [Warning](#warning)
|
||||||
- [Parameters synthesis](#parameters-synthesis)
|
- [Parameter summary](#parameter-summary)
|
||||||
- [Examples tuning](#examples-tuning)
|
- [Examples tuning](#examples-tuning)
|
||||||
- [Electrical heater](#electrical-heater)
|
- [Electrical heater](#electrical-heater)
|
||||||
- [Central heating (gaz or fuel heating system)](#central-heating-gaz-or-fuel-heating-system)
|
- [Central heating (gaz or fuel heating system)](#central-heating-gaz-or-fuel-heating-system)
|
||||||
@@ -244,7 +244,10 @@ Depending on your choice of thermostat type, you will need to choose one or more
|
|||||||
It is possible to choose an over switch thermostat which controls air conditioning by checking the "AC Mode" box. In this case, only the cooling mode will be visible.
|
It is possible to choose an over switch thermostat which controls air conditioning by checking the "AC Mode" box. In this case, only the cooling mode will be visible.
|
||||||
|
|
||||||
### For a ```thermostat_over_switch``` type thermostat
|
### For a ```thermostat_over_switch``` type thermostat
|
||||||

|

|
||||||
|
|
||||||
|
Some heater switches require regular "keep-alive messages" to prevent them from triggering a failsafe switch off. This feature can be enabled through the switch keep-alive interval configuration field.
|
||||||
|
|
||||||
The algorithm to use is currently limited to TPI is available. See [algorithm](#algorithm).
|
The algorithm to use is currently limited to TPI is available. See [algorithm](#algorithm).
|
||||||
If several type entities are configured, the thermostat shifts the activations in order to minimize the number of switches active at a time t. This allows for better power distribution since each radiator will turn on in turn.
|
If several type entities are configured, the thermostat shifts the activations in order to minimize the number of switches active at a time t. This allows for better power distribution since each radiator will turn on in turn.
|
||||||
Example of synchronized triggering:
|
Example of synchronized triggering:
|
||||||
@@ -654,9 +657,9 @@ context:
|
|||||||
>  _*Notes*_
|
>  _*Notes*_
|
||||||
> Controlling a central boiler using software or hardware such as home automation can pose risks to its proper functioning. Before using these functions, make sure that your boiler has safety functions and that they are working. Turning on a boiler if all the taps are closed can generate excess pressure, for example.
|
> Controlling a central boiler using software or hardware such as home automation can pose risks to its proper functioning. Before using these functions, make sure that your boiler has safety functions and that they are working. Turning on a boiler if all the taps are closed can generate excess pressure, for example.
|
||||||
|
|
||||||
## Parameters synthesis
|
## Parameter summary
|
||||||
|
|
||||||
| Paramètre | Libellé | "over switch" | "over climate" | "over valve" | "central configuration" |
|
| Parameter | Description | "over switch" | "over climate" | "over valve" | "central configuration" |
|
||||||
| ----------------------------------------- | ----------------------------------------------------------------------------- | ------------- | ------------------- | ------------ | ----------------------- |
|
| ----------------------------------------- | ----------------------------------------------------------------------------- | ------------- | ------------------- | ------------ | ----------------------- |
|
||||||
| ``name`` | Name | X | X | X | - |
|
| ``name`` | Name | X | X | X | - |
|
||||||
| ``thermostat_type`` | Thermostat type | X | X | X | - |
|
| ``thermostat_type`` | Thermostat type | X | X | X | - |
|
||||||
@@ -675,6 +678,7 @@ context:
|
|||||||
| ``heater_entity2_id`` | 2nd heater switch | X | - | - | - |
|
| ``heater_entity2_id`` | 2nd heater switch | X | - | - | - |
|
||||||
| ``heater_entity3_id`` | 3rd heater switch | X | - | - | - |
|
| ``heater_entity3_id`` | 3rd heater switch | X | - | - | - |
|
||||||
| ``heater_entity4_id`` | 4th heater switch | X | - | - | - |
|
| ``heater_entity4_id`` | 4th heater switch | X | - | - | - |
|
||||||
|
| ``heater_keep_alive`` | Switch keep-alive interval | X | - | - | - |
|
||||||
| ``proportional_function`` | Algorithm | X | - | X | - |
|
| ``proportional_function`` | Algorithm | X | - | X | - |
|
||||||
| ``climate_entity1_id`` | 1rst underlying climate | - | X | - | - |
|
| ``climate_entity1_id`` | 1rst underlying climate | - | X | - | - |
|
||||||
| ``climate_entity2_id`` | 2nd underlying climate | - | X | - | - |
|
| ``climate_entity2_id`` | 2nd underlying climate | - | X | - | - |
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ STEP_THERMOSTAT_SWITCH = vol.Schema( # pylint: disable=invalid-name
|
|||||||
vol.Optional(CONF_HEATER_4): selector.EntitySelector(
|
vol.Optional(CONF_HEATER_4): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]),
|
selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]),
|
||||||
),
|
),
|
||||||
|
vol.Optional(CONF_HEATER_KEEP_ALIVE): cv.positive_int,
|
||||||
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In(
|
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In(
|
||||||
[
|
[
|
||||||
PROPORTIONAL_FUNCTION_TPI,
|
PROPORTIONAL_FUNCTION_TPI,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ CONF_HEATER = "heater_entity_id"
|
|||||||
CONF_HEATER_2 = "heater_entity2_id"
|
CONF_HEATER_2 = "heater_entity2_id"
|
||||||
CONF_HEATER_3 = "heater_entity3_id"
|
CONF_HEATER_3 = "heater_entity3_id"
|
||||||
CONF_HEATER_4 = "heater_entity4_id"
|
CONF_HEATER_4 = "heater_entity4_id"
|
||||||
|
CONF_HEATER_KEEP_ALIVE = "heater_keep_alive"
|
||||||
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
|
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
|
||||||
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
|
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
|
||||||
CONF_POWER_SENSOR = "power_sensor_entity_id"
|
CONF_POWER_SENSOR = "power_sensor_entity_id"
|
||||||
@@ -211,6 +212,7 @@ ALL_CONF = (
|
|||||||
CONF_HEATER_2,
|
CONF_HEATER_2,
|
||||||
CONF_HEATER_3,
|
CONF_HEATER_3,
|
||||||
CONF_HEATER_4,
|
CONF_HEATER_4,
|
||||||
|
CONF_HEATER_KEEP_ALIVE,
|
||||||
CONF_TEMP_SENSOR,
|
CONF_TEMP_SENSOR,
|
||||||
CONF_EXTERNAL_TEMP_SENSOR,
|
CONF_EXTERNAL_TEMP_SENSOR,
|
||||||
CONF_POWER_SENSOR,
|
CONF_POWER_SENSOR,
|
||||||
|
|||||||
53
custom_components/versatile_thermostat/keep_alive.py
Normal file
53
custom_components/versatile_thermostat/keep_alive.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Building blocks for the heater switch keep-alive feature.
|
||||||
|
|
||||||
|
The heater switch keep-alive feature consists of regularly refreshing the state
|
||||||
|
of directly controlled switches at a configurable interval (regularly turning the
|
||||||
|
switch 'on' or 'off' again even if it is already turned 'on' or 'off'), just like
|
||||||
|
the keep_alive setting of Home Assistant's Generic Thermostat integration:
|
||||||
|
https://www.home-assistant.io/integrations/generic_thermostat/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, CALLBACK_TYPE
|
||||||
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalCaller:
|
||||||
|
"""Repeatedly call a given async action function at a given regular interval.
|
||||||
|
|
||||||
|
Convenience wrapper around Home Assistant's `async_track_time_interval` function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, interval_sec: int) -> None:
|
||||||
|
self._hass = hass
|
||||||
|
self._interval_sec = interval_sec
|
||||||
|
self._remove_handle: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""Cancel the regular calls to the action function."""
|
||||||
|
if self._remove_handle:
|
||||||
|
self._remove_handle()
|
||||||
|
self._remove_handle = None
|
||||||
|
|
||||||
|
def set_async_action(self, action: Callable[[], Awaitable[None]]):
|
||||||
|
"""Set the async action function to be called at regular intervals."""
|
||||||
|
if not self._interval_sec:
|
||||||
|
return
|
||||||
|
self.cancel()
|
||||||
|
|
||||||
|
async def callback(_time: datetime):
|
||||||
|
try:
|
||||||
|
await action()
|
||||||
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.error(e)
|
||||||
|
self.cancel()
|
||||||
|
|
||||||
|
self._remove_handle = async_track_time_interval(
|
||||||
|
self._hass, callback, timedelta(seconds=self._interval_sec)
|
||||||
|
)
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"heater_entity2_id": "2nd heater switch",
|
"heater_entity2_id": "2nd heater switch",
|
||||||
"heater_entity3_id": "3rd heater switch",
|
"heater_entity3_id": "3rd heater switch",
|
||||||
"heater_entity4_id": "4th heater switch",
|
"heater_entity4_id": "4th heater switch",
|
||||||
|
"heater_keep_alive": "Switch keep-alive interval in seconds",
|
||||||
"proportional_function": "Algorithm",
|
"proportional_function": "Algorithm",
|
||||||
"climate_entity_id": "1st underlying climate",
|
"climate_entity_id": "1st underlying climate",
|
||||||
"climate_entity2_id": "2nd underlying climate",
|
"climate_entity2_id": "2nd underlying climate",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||||
|
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
|
||||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||||
"climate_entity_id": "Underlying climate entity id",
|
"climate_entity_id": "Underlying climate entity id",
|
||||||
"climate_entity2_id": "2nd underlying climate entity id",
|
"climate_entity2_id": "2nd underlying climate entity id",
|
||||||
@@ -281,6 +283,7 @@
|
|||||||
"heater_entity2_id": "2nd heater switch",
|
"heater_entity2_id": "2nd heater switch",
|
||||||
"heater_entity3_id": "3rd heater switch",
|
"heater_entity3_id": "3rd heater switch",
|
||||||
"heater_entity4_id": "4th heater switch",
|
"heater_entity4_id": "4th heater switch",
|
||||||
|
"heater_keep_alive": "Switch keep-alive interval in seconds",
|
||||||
"proportional_function": "Algorithm",
|
"proportional_function": "Algorithm",
|
||||||
"climate_entity_id": "1st underlying climate",
|
"climate_entity_id": "1st underlying climate",
|
||||||
"climate_entity2_id": "2nd underlying climate",
|
"climate_entity2_id": "2nd underlying climate",
|
||||||
@@ -302,6 +305,7 @@
|
|||||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||||
|
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
|
||||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||||
"climate_entity_id": "Underlying climate entity id",
|
"climate_entity_id": "Underlying climate entity id",
|
||||||
"climate_entity2_id": "2nd underlying climate entity id",
|
"climate_entity2_id": "2nd underlying climate entity id",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .const import (
|
|||||||
CONF_HEATER_2,
|
CONF_HEATER_2,
|
||||||
CONF_HEATER_3,
|
CONF_HEATER_3,
|
||||||
CONF_HEATER_4,
|
CONF_HEATER_4,
|
||||||
|
CONF_HEATER_KEEP_ALIVE,
|
||||||
CONF_INVERSE_SWITCH,
|
CONF_INVERSE_SWITCH,
|
||||||
overrides,
|
overrides,
|
||||||
)
|
)
|
||||||
@@ -105,6 +106,7 @@ class ThermostatOverSwitch(BaseThermostat):
|
|||||||
thermostat=self,
|
thermostat=self,
|
||||||
switch_entity_id=switch,
|
switch_entity_id=switch,
|
||||||
initial_delay_sec=idx * delta_cycle,
|
initial_delay_sec=idx * delta_cycle,
|
||||||
|
keep_alive_sec=config_entry.get(CONF_HEATER_KEEP_ALIVE, 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ class ThermostatOverSwitch(BaseThermostat):
|
|||||||
self.hass, [switch.entity_id], self._async_switch_changed
|
self.hass, [switch.entity_id], self._async_switch_changed
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
switch.startup()
|
||||||
|
|
||||||
self.hass.create_task(self.async_control_heating())
|
self.hass.create_task(self.async_control_heating())
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"heater_entity2_id": "2nd heater switch",
|
"heater_entity2_id": "2nd heater switch",
|
||||||
"heater_entity3_id": "3rd heater switch",
|
"heater_entity3_id": "3rd heater switch",
|
||||||
"heater_entity4_id": "4th heater switch",
|
"heater_entity4_id": "4th heater switch",
|
||||||
|
"heater_keep_alive": "Switch keep-alive interval in seconds",
|
||||||
"proportional_function": "Algorithm",
|
"proportional_function": "Algorithm",
|
||||||
"climate_entity_id": "1st underlying climate",
|
"climate_entity_id": "1st underlying climate",
|
||||||
"climate_entity2_id": "2nd underlying climate",
|
"climate_entity2_id": "2nd underlying climate",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||||
|
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
|
||||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||||
"climate_entity_id": "Underlying climate entity id",
|
"climate_entity_id": "Underlying climate entity id",
|
||||||
"climate_entity2_id": "2nd underlying climate entity id",
|
"climate_entity2_id": "2nd underlying climate entity id",
|
||||||
@@ -281,6 +283,7 @@
|
|||||||
"heater_entity2_id": "2nd heater switch",
|
"heater_entity2_id": "2nd heater switch",
|
||||||
"heater_entity3_id": "3rd heater switch",
|
"heater_entity3_id": "3rd heater switch",
|
||||||
"heater_entity4_id": "4th heater switch",
|
"heater_entity4_id": "4th heater switch",
|
||||||
|
"heater_keep_alive": "Switch keep-alive interval in seconds",
|
||||||
"proportional_function": "Algorithm",
|
"proportional_function": "Algorithm",
|
||||||
"climate_entity_id": "1st underlying climate",
|
"climate_entity_id": "1st underlying climate",
|
||||||
"climate_entity2_id": "2nd underlying climate",
|
"climate_entity2_id": "2nd underlying climate",
|
||||||
@@ -302,6 +305,7 @@
|
|||||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||||
|
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
|
||||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||||
"climate_entity_id": "Underlying climate entity id",
|
"climate_entity_id": "Underlying climate entity id",
|
||||||
"climate_entity2_id": "2nd underlying climate entity id",
|
"climate_entity2_id": "2nd underlying climate entity id",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"heater_entity2_id": "2ème radiateur",
|
"heater_entity2_id": "2ème radiateur",
|
||||||
"heater_entity3_id": "3ème radiateur",
|
"heater_entity3_id": "3ème radiateur",
|
||||||
"heater_entity4_id": "4ème radiateur",
|
"heater_entity4_id": "4ème radiateur",
|
||||||
|
"heater_keep_alive": "Intervalle keep-alive du switch en secondes",
|
||||||
"proportional_function": "Algorithme",
|
"proportional_function": "Algorithme",
|
||||||
"climate_entity_id": "Thermostat sous-jacent",
|
"climate_entity_id": "Thermostat sous-jacent",
|
||||||
"climate_entity2_id": "2ème thermostat sous-jacent",
|
"climate_entity2_id": "2ème thermostat sous-jacent",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
||||||
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
||||||
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
||||||
|
"heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.",
|
||||||
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
||||||
"climate_entity_id": "Entity id du thermostat sous-jacent",
|
"climate_entity_id": "Entity id du thermostat sous-jacent",
|
||||||
"climate_entity2_id": "Entity id du 2ème thermostat sous-jacent",
|
"climate_entity2_id": "Entity id du 2ème thermostat sous-jacent",
|
||||||
@@ -293,6 +295,7 @@
|
|||||||
"heater_entity2_id": "2ème radiateur",
|
"heater_entity2_id": "2ème radiateur",
|
||||||
"heater_entity3_id": "3ème radiateur",
|
"heater_entity3_id": "3ème radiateur",
|
||||||
"heater_entity4_id": "4ème radiateur",
|
"heater_entity4_id": "4ème radiateur",
|
||||||
|
"heater_keep_alive": "Intervalle keep-alive du switch en secondes",
|
||||||
"proportional_function": "Algorithme",
|
"proportional_function": "Algorithme",
|
||||||
"climate_entity_id": "Thermostat sous-jacent",
|
"climate_entity_id": "Thermostat sous-jacent",
|
||||||
"climate_entity2_id": "2ème thermostat sous-jacent",
|
"climate_entity2_id": "2ème thermostat sous-jacent",
|
||||||
@@ -314,6 +317,7 @@
|
|||||||
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
||||||
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
||||||
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
||||||
|
"heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.",
|
||||||
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
||||||
"climate_entity_id": "Entity id du thermostat sous-jacent",
|
"climate_entity_id": "Entity id du thermostat sous-jacent",
|
||||||
"climate_entity2_id": "Entity id du 2ème thermostat sous-jacent",
|
"climate_entity2_id": "Entity id du 2ème thermostat sous-jacent",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"heater_entity2_id": "Secondo riscaldatore",
|
"heater_entity2_id": "Secondo riscaldatore",
|
||||||
"heater_entity3_id": "Terzo riscaldatore",
|
"heater_entity3_id": "Terzo riscaldatore",
|
||||||
"heater_entity4_id": "Quarto riscaldatore",
|
"heater_entity4_id": "Quarto riscaldatore",
|
||||||
|
"heater_keep_alive": "Intervallo keep-alive dell'interruttore in secondi",
|
||||||
"proportional_function": "Algoritmo",
|
"proportional_function": "Algoritmo",
|
||||||
"climate_entity_id": "Primo termostato",
|
"climate_entity_id": "Primo termostato",
|
||||||
"climate_entity2_id": "Secondo termostato",
|
"climate_entity2_id": "Secondo termostato",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
"heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
"heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
|
"heater_keep_alive": "Frequenza di aggiornamento dell'interruttore (facoltativo). Lasciare vuoto se non richiesto.",
|
||||||
"proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)",
|
"proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)",
|
||||||
"climate_entity_id": "Entity id del primo termostato",
|
"climate_entity_id": "Entity id del primo termostato",
|
||||||
"climate_entity2_id": "Entity id del secondo termostato",
|
"climate_entity2_id": "Entity id del secondo termostato",
|
||||||
@@ -191,6 +193,7 @@
|
|||||||
"heater_entity2_id": "Secondo riscaldatore",
|
"heater_entity2_id": "Secondo riscaldatore",
|
||||||
"heater_entity3_id": "Terzo riscaldatore",
|
"heater_entity3_id": "Terzo riscaldatore",
|
||||||
"heater_entity4_id": "Quarto riscaldatore",
|
"heater_entity4_id": "Quarto riscaldatore",
|
||||||
|
"heater_keep_alive": "Intervallo keep-alive dell'interruttore in secondi",
|
||||||
"proportional_function": "Algoritmo",
|
"proportional_function": "Algoritmo",
|
||||||
"climate_entity_id": "Primo termostato",
|
"climate_entity_id": "Primo termostato",
|
||||||
"climate_entity2_id": "Secondo termostato",
|
"climate_entity2_id": "Secondo termostato",
|
||||||
@@ -210,6 +213,7 @@
|
|||||||
"heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
"heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
"heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
"heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato",
|
||||||
|
"heater_keep_alive": "Frequenza di aggiornamento dell'interruttore (facoltativo). Lasciare vuoto se non richiesto.",
|
||||||
"proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)",
|
"proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)",
|
||||||
"climate_entity_id": "Entity id del primo termostato",
|
"climate_entity_id": "Entity id del primo termostato",
|
||||||
"climate_entity2_id": "Entity id del secondo termostato",
|
"climate_entity2_id": "Entity id del secondo termostato",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from homeassistant.helpers.entity_component import EntityComponent
|
|||||||
from homeassistant.helpers.event import async_call_later
|
from homeassistant.helpers.event import async_call_later
|
||||||
|
|
||||||
from .const import UnknownEntity, overrides
|
from .const import UnknownEntity, overrides
|
||||||
|
from .keep_alive import IntervalCaller
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
thermostat: Any,
|
thermostat: Any,
|
||||||
switch_entity_id: str,
|
switch_entity_id: str,
|
||||||
initial_delay_sec: int,
|
initial_delay_sec: int,
|
||||||
|
keep_alive_sec: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the underlying switch"""
|
"""Initialize the underlying switch"""
|
||||||
|
|
||||||
@@ -202,6 +204,7 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
self._on_time_sec = 0
|
self._on_time_sec = 0
|
||||||
self._off_time_sec = 0
|
self._off_time_sec = 0
|
||||||
self._hvac_mode = None
|
self._hvac_mode = None
|
||||||
|
self._keep_alive = IntervalCaller(hass, keep_alive_sec)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def initial_delay_sec(self):
|
def initial_delay_sec(self):
|
||||||
@@ -214,6 +217,13 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
"""Tells if the switch command should be inversed"""
|
"""Tells if the switch command should be inversed"""
|
||||||
return self._thermostat.is_inversed
|
return self._thermostat.is_inversed
|
||||||
|
|
||||||
|
@overrides
|
||||||
|
def startup(self):
|
||||||
|
super().startup()
|
||||||
|
self._keep_alive.set_async_action(
|
||||||
|
self.turn_on if self.is_device_active else self.turn_off
|
||||||
|
)
|
||||||
|
|
||||||
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
|
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
|
||||||
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
|
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
|
||||||
"""Set the HVACmode. Returns true if something have change"""
|
"""Set the HVACmode. Returns true if something have change"""
|
||||||
@@ -245,12 +255,13 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
domain = self._entity_id.split(".")[0]
|
domain = self._entity_id.split(".")[0]
|
||||||
# This may fails if called after shutdown
|
# This may fails if called after shutdown
|
||||||
try:
|
try:
|
||||||
data = {ATTR_ENTITY_ID: self._entity_id}
|
try:
|
||||||
await self._hass.services.async_call(
|
data = {ATTR_ENTITY_ID: self._entity_id}
|
||||||
domain,
|
await self._hass.services.async_call(domain, command, data)
|
||||||
command,
|
self._keep_alive.set_async_action(self.turn_off)
|
||||||
data,
|
except Exception:
|
||||||
)
|
self._keep_alive.cancel()
|
||||||
|
raise
|
||||||
except ServiceNotFound as err:
|
except ServiceNotFound as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.error(err)
|
||||||
|
|
||||||
@@ -260,12 +271,13 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF
|
command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF
|
||||||
domain = self._entity_id.split(".")[0]
|
domain = self._entity_id.split(".")[0]
|
||||||
try:
|
try:
|
||||||
data = {ATTR_ENTITY_ID: self._entity_id}
|
try:
|
||||||
await self._hass.services.async_call(
|
data = {ATTR_ENTITY_ID: self._entity_id}
|
||||||
domain,
|
await self._hass.services.async_call(domain, command, data)
|
||||||
command,
|
self._keep_alive.set_async_action(self.turn_on)
|
||||||
data,
|
except Exception:
|
||||||
)
|
self._keep_alive.cancel()
|
||||||
|
raise
|
||||||
except ServiceNotFound as err:
|
except ServiceNotFound as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.error(err)
|
||||||
|
|
||||||
@@ -422,6 +434,7 @@ class UnderlyingSwitch(UnderlyingEntity):
|
|||||||
def remove_entity(self):
|
def remove_entity(self):
|
||||||
"""Remove the entity after stopping its cycle"""
|
"""Remove the entity after stopping its cycle"""
|
||||||
self._cancel_cycle()
|
self._cancel_cycle()
|
||||||
|
self._keep_alive.cancel()
|
||||||
|
|
||||||
|
|
||||||
class UnderlyingClimate(UnderlyingEntity):
|
class UnderlyingClimate(UnderlyingEntity):
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 50 KiB |
BIN
images/en/config-linked-entity.png
Normal file
BIN
images/en/config-linked-entity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -74,6 +74,7 @@ MOCK_TH_OVER_SWITCH_CENTRAL_MAIN_CONFIG = {
|
|||||||
|
|
||||||
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
|
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
|
||||||
CONF_HEATER: "switch.mock_switch",
|
CONF_HEATER: "switch.mock_switch",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
CONF_AC_MODE: False,
|
CONF_AC_MODE: False,
|
||||||
CONF_INVERSE_SWITCH: False,
|
CONF_INVERSE_SWITCH: False,
|
||||||
@@ -91,6 +92,7 @@ MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = {
|
|||||||
CONF_HEATER_2: "switch.mock_4switch1",
|
CONF_HEATER_2: "switch.mock_4switch1",
|
||||||
CONF_HEATER_3: "switch.mock_4switch2",
|
CONF_HEATER_3: "switch.mock_4switch2",
|
||||||
CONF_HEATER_4: "switch.mock_4switch3",
|
CONF_HEATER_4: "switch.mock_4switch3",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
CONF_AC_MODE: False,
|
CONF_AC_MODE: False,
|
||||||
CONF_INVERSE_SWITCH: False,
|
CONF_INVERSE_SWITCH: False,
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ async def test_user_config_flow_over_4_switches(
|
|||||||
CONF_HEATER_2: "switch.mock_switch2",
|
CONF_HEATER_2: "switch.mock_switch2",
|
||||||
CONF_HEATER_3: "switch.mock_switch3",
|
CONF_HEATER_3: "switch.mock_switch3",
|
||||||
CONF_HEATER_4: "switch.mock_switch4",
|
CONF_HEATER_4: "switch.mock_switch4",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
CONF_AC_MODE: False,
|
CONF_AC_MODE: False,
|
||||||
CONF_INVERSE_SWITCH: False,
|
CONF_INVERSE_SWITCH: False,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ async def test_multiple_switchs(
|
|||||||
CONF_HEATER_2: "switch.mock_switch2",
|
CONF_HEATER_2: "switch.mock_switch2",
|
||||||
CONF_HEATER_3: "switch.mock_switch3",
|
CONF_HEATER_3: "switch.mock_switch3",
|
||||||
CONF_HEATER_4: "switch.mock_switch4",
|
CONF_HEATER_4: "switch.mock_switch4",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||||
CONF_SECURITY_DELAY_MIN: 5,
|
CONF_SECURITY_DELAY_MIN: 5,
|
||||||
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||||
@@ -746,6 +747,7 @@ async def test_multiple_switch_power_management(
|
|||||||
CONF_HEATER_2: "switch.mock_switch2",
|
CONF_HEATER_2: "switch.mock_switch2",
|
||||||
CONF_HEATER_3: "switch.mock_switch3",
|
CONF_HEATER_3: "switch.mock_switch3",
|
||||||
CONF_HEATER_4: "switch.mock_switch4",
|
CONF_HEATER_4: "switch.mock_switch4",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||||
CONF_SECURITY_DELAY_MIN: 5,
|
CONF_SECURITY_DELAY_MIN: 5,
|
||||||
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ async def test_over_switch_deactivate_preset(
|
|||||||
CONF_HEATER_2: None,
|
CONF_HEATER_2: None,
|
||||||
CONF_HEATER_3: None,
|
CONF_HEATER_3: None,
|
||||||
CONF_HEATER_4: None,
|
CONF_HEATER_4: None,
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 0,
|
||||||
CONF_SECURITY_DELAY_MIN: 10,
|
CONF_SECURITY_DELAY_MIN: 10,
|
||||||
CONF_MINIMAL_ACTIVATION_DELAY: 10,
|
CONF_MINIMAL_ACTIVATION_DELAY: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
256
tests/test_switch_keep_alive.py
Normal file
256
tests/test_switch_keep_alive.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""Test the switch keep-alive feature."""
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator, Callable, Awaitable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import ANY, _Call, call, patch
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.thermostat_switch import (
|
||||||
|
ThermostatOverSwitch,
|
||||||
|
)
|
||||||
|
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
|
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_entry() -> MockConfigEntry:
|
||||||
|
"""Return common test data"""
|
||||||
|
return 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_CYCLE_MIN: 5,
|
||||||
|
CONF_TEMP_MIN: 15,
|
||||||
|
CONF_TEMP_MAX: 30,
|
||||||
|
CONF_USE_WINDOW_FEATURE: False,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
CONF_HEATER: "switch.mock_switch",
|
||||||
|
CONF_HEATER_KEEP_ALIVE: 1,
|
||||||
|
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.1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommonMocks:
|
||||||
|
"""Common mocked objects used by most test cases"""
|
||||||
|
|
||||||
|
config_entry: MockConfigEntry
|
||||||
|
hass: HomeAssistant
|
||||||
|
thermostat: ThermostatOverSwitch
|
||||||
|
mock_is_state: MagicMock
|
||||||
|
mock_service_call: MagicMock
|
||||||
|
mock_async_track_time_interval: MagicMock
|
||||||
|
mock_send_event: MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name, line-too-long, protected-access
|
||||||
|
@pytest.fixture
|
||||||
|
async def common_mocks(
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> AsyncGenerator[CommonMocks, None]:
|
||||||
|
"""Create and destroy a ThermostatOverSwitch as a test fixture"""
|
||||||
|
# fmt: off
|
||||||
|
with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call, \
|
||||||
|
patch("homeassistant.core.StateMachine.is_state", return_value=False) as mock_is_state, \
|
||||||
|
patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
|
||||||
|
patch("custom_components.versatile_thermostat.keep_alive.async_track_time_interval") as mock_async_track_time_interval:
|
||||||
|
# fmt: on
|
||||||
|
thermostat = cast(ThermostatOverSwitch, await create_thermostat(
|
||||||
|
hass, config_entry, "climate.theoverswitchmockname"
|
||||||
|
))
|
||||||
|
yield CommonMocks(
|
||||||
|
config_entry=config_entry,
|
||||||
|
hass=hass,
|
||||||
|
thermostat=thermostat,
|
||||||
|
mock_is_state=mock_is_state,
|
||||||
|
mock_service_call=mock_service_call,
|
||||||
|
mock_async_track_time_interval=mock_async_track_time_interval,
|
||||||
|
mock_send_event=mock_send_event,
|
||||||
|
)
|
||||||
|
# Clean the entity
|
||||||
|
thermostat.remove_thermostat()
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeepAlive:
|
||||||
|
"""Tests for the switch keep-alive feature"""
|
||||||
|
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
def setup_method(self):
|
||||||
|
"""Initialise test case data before the execution of each test case method."""
|
||||||
|
self._prev_service_calls: list[_Call] = []
|
||||||
|
self._prev_atti_call_count = 0 # atti: async_time_track_interval
|
||||||
|
self._prev_atti_callback: Callable[[datetime], Awaitable[None]] | None = None
|
||||||
|
|
||||||
|
def _assert_service_call(
|
||||||
|
self, cm: CommonMocks, expected_additional_calls: list[_Call]
|
||||||
|
):
|
||||||
|
"""Assert that hass.services.async_call() was called with the expected arguments,
|
||||||
|
cumulatively over the course of long test cases."""
|
||||||
|
self._prev_service_calls.extend(expected_additional_calls)
|
||||||
|
cm.mock_service_call.assert_has_calls(self._prev_service_calls)
|
||||||
|
|
||||||
|
def _assert_async_mock_track_time_interval(
|
||||||
|
self, cm: CommonMocks, expected_additional_calls: int
|
||||||
|
):
|
||||||
|
"""Assert that async_track_time_interval() was called the expected number of times
|
||||||
|
with the expected arguments, cumulatively over the course of long test cases."""
|
||||||
|
self._prev_atti_call_count += expected_additional_calls
|
||||||
|
assert (
|
||||||
|
cm.mock_async_track_time_interval.call_count == self._prev_atti_call_count
|
||||||
|
)
|
||||||
|
interval = timedelta(seconds=cm.config_entry.data[CONF_HEATER_KEEP_ALIVE])
|
||||||
|
cm.mock_async_track_time_interval.assert_called_with(cm.hass, ANY, interval)
|
||||||
|
keep_alive_callback = cm.mock_async_track_time_interval.call_args.args[1]
|
||||||
|
assert callable(keep_alive_callback)
|
||||||
|
self._prev_atti_callback = keep_alive_callback
|
||||||
|
|
||||||
|
async def _assert_multipe_keep_alive_callback_calls(
|
||||||
|
self, cm: CommonMocks, n_calls: int
|
||||||
|
):
|
||||||
|
"""Call the keep-alive callback a few times as if `async_track_time_interval()` had
|
||||||
|
done it, and assert that this triggers further calls to `async_track_time_interval()`.
|
||||||
|
"""
|
||||||
|
old_callback = self._prev_atti_callback
|
||||||
|
assert (
|
||||||
|
old_callback
|
||||||
|
), "The keep-alive callback should have been called before, but it wasn't."
|
||||||
|
interval = timedelta(seconds=cm.config_entry.data[CONF_HEATER_KEEP_ALIVE])
|
||||||
|
for _ in range(n_calls):
|
||||||
|
await old_callback(datetime.fromtimestamp(0))
|
||||||
|
self._prev_atti_call_count += 1
|
||||||
|
assert (
|
||||||
|
cm.mock_async_track_time_interval.call_count
|
||||||
|
== self._prev_atti_call_count
|
||||||
|
)
|
||||||
|
cm.mock_async_track_time_interval.assert_called_with(cm.hass, ANY, interval)
|
||||||
|
new_callback = cm.mock_async_track_time_interval.call_args.args[1]
|
||||||
|
assert new_callback is not old_callback
|
||||||
|
assert new_callback.__qualname__ == old_callback.__qualname__
|
||||||
|
old_callback = new_callback
|
||||||
|
|
||||||
|
self._prev_atti_callback = old_callback
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expected_lingering_tasks", [True])
|
||||||
|
@pytest.mark.parametrize("expected_lingering_timers", [True])
|
||||||
|
async def test_switch_keep_alive_startup(self, common_mocks: CommonMocks):
|
||||||
|
"""Test that switch keep-alive service calls are made at startup time."""
|
||||||
|
|
||||||
|
thermostat = common_mocks.thermostat
|
||||||
|
await thermostat.async_set_hvac_mode(HVACMode.HEAT)
|
||||||
|
assert thermostat.hvac_mode is HVACMode.HEAT
|
||||||
|
assert thermostat.target_temperature == 15
|
||||||
|
assert thermostat.is_device_active is False
|
||||||
|
|
||||||
|
# When the keep-alive feature is enabled, regular calls to the switch
|
||||||
|
# turn_on / turn_off methods are _scheduled_ at start up.
|
||||||
|
self._assert_async_mock_track_time_interval(common_mocks, 1)
|
||||||
|
|
||||||
|
# Those keep-alive calls are scheduled but until the callback is called,
|
||||||
|
# no service calls are made to the SERVICE_TURN_OFF home assistant service.
|
||||||
|
self._assert_service_call(common_mocks, [])
|
||||||
|
|
||||||
|
# Call the keep-alive callback a few times (as if `async_track_time_interval`
|
||||||
|
# had done it) and assert that the callback function is replaced each time.
|
||||||
|
await self._assert_multipe_keep_alive_callback_calls(common_mocks, 2)
|
||||||
|
|
||||||
|
# Every time the keep-alive callback is called, the home assistant switch
|
||||||
|
# turn on/off service should be called too.
|
||||||
|
self._assert_service_call(
|
||||||
|
common_mocks,
|
||||||
|
[
|
||||||
|
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}),
|
||||||
|
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expected_lingering_tasks", [True])
|
||||||
|
@pytest.mark.parametrize("expected_lingering_timers", [True])
|
||||||
|
async def test_switch_keep_alive(self, common_mocks: CommonMocks):
|
||||||
|
"""Test that switch keep-alive service calls are made during thermostat operation."""
|
||||||
|
|
||||||
|
hass = common_mocks.hass
|
||||||
|
thermostat = common_mocks.thermostat
|
||||||
|
|
||||||
|
await thermostat.async_set_hvac_mode(HVACMode.HEAT)
|
||||||
|
assert thermostat.hvac_mode is HVACMode.HEAT
|
||||||
|
assert thermostat.target_temperature == 15
|
||||||
|
assert thermostat.is_device_active is False
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now = datetime.now(tz)
|
||||||
|
event_timestamp = now - timedelta(minutes=4)
|
||||||
|
|
||||||
|
# 1. Decrease the temperature to activate the heater switch
|
||||||
|
|
||||||
|
await send_temperature_change_event(thermostat, 14, event_timestamp)
|
||||||
|
|
||||||
|
# async_track_time_interval() should have been called twice: once at startup
|
||||||
|
# while the switch was turned off, and once when the switch was turned on.
|
||||||
|
self._assert_async_mock_track_time_interval(common_mocks, 2)
|
||||||
|
|
||||||
|
# The keep-alive callback hasn't been called yet, so the only service
|
||||||
|
# call so far is to SERVICE_TURN_ON as a result of the switch turn_on()
|
||||||
|
# method being called when the target temperature increased.
|
||||||
|
self._assert_service_call(
|
||||||
|
common_mocks,
|
||||||
|
[call("switch", SERVICE_TURN_ON, {"entity_id": "switch.mock_switch"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the keep-alive callback a few times (as if `async_track_time_interval`
|
||||||
|
# had done it) and assert that the callback function is replaced each time.
|
||||||
|
await self._assert_multipe_keep_alive_callback_calls(common_mocks, 2)
|
||||||
|
|
||||||
|
# Every time the keep-alive callback is called, the home assistant switch
|
||||||
|
# turn on/off service should be called too.
|
||||||
|
self._assert_service_call(
|
||||||
|
common_mocks,
|
||||||
|
[
|
||||||
|
call("switch", SERVICE_TURN_ON, {"entity_id": "switch.mock_switch"}),
|
||||||
|
call("switch", SERVICE_TURN_ON, {"entity_id": "switch.mock_switch"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Increase the temperature to deactivate the heater switch
|
||||||
|
|
||||||
|
await send_temperature_change_event(thermostat, 20, event_timestamp)
|
||||||
|
|
||||||
|
# Simulate the end of the TPI heating cycle
|
||||||
|
await thermostat._underlyings[0].turn_off() # pylint: disable=protected-access
|
||||||
|
|
||||||
|
# turn_off() should have triggered a call to `async_track_time_interval()`
|
||||||
|
self._assert_async_mock_track_time_interval(common_mocks, 1)
|
||||||
|
|
||||||
|
# turn_off() should have triggered a call to the SERVICE_TURN_OFF service.
|
||||||
|
self._assert_service_call(
|
||||||
|
common_mocks,
|
||||||
|
[call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the keep-alive callback a few times (as if `async_track_time_interval`
|
||||||
|
# had done it) and assert that the callback function is replaced each time.
|
||||||
|
await self._assert_multipe_keep_alive_callback_calls(common_mocks, 2)
|
||||||
|
|
||||||
|
# Every time the keep-alive callback is called, the home assistant switch
|
||||||
|
# turn on/off service should be called too.
|
||||||
|
self._assert_service_call(
|
||||||
|
common_mocks,
|
||||||
|
[
|
||||||
|
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}),
|
||||||
|
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user