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:
Paulo Ferreira de Castro
2024-01-30 06:47:17 +00:00
committed by GitHub
parent 4f349d6f6f
commit 1f13eb4f37
19 changed files with 375 additions and 17 deletions

View File

@@ -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"
], ],

View File

@@ -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```
![image](images/config-linked-entity.png) ![image](images/config-linked-entity.png)
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 | - | - |

View File

@@ -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
![image](images/config-linked-entity.png) ![image](images/en/config-linked-entity.png)
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:
> ![Tip](images/tips.png) _*Notes*_ > ![Tip](images/tips.png) _*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 | - | - |

View File

@@ -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,

View File

@@ -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,

View 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)
)

View File

@@ -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",

View File

@@ -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())

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}, },

View 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"}),
],
)