Compare commits

...

18 Commits
3.0.0 ... 3.2.2

Author SHA1 Message Date
Jean-Marc Collin
a17423d470 FIX #73, #72. thanks to Salabur 2023-04-14 08:24:01 +02:00
Jean-Marc Collin
81a467b8c3 Translations typos 2023-04-02 17:40:40 +02:00
Jean-Marc Collin
4e3ee0703b Typo 2023-04-02 17:33:59 +02:00
Jean-Marc Collin
539ec4a6bd Isseu #70. Build release. 2023-04-02 16:44:57 +02:00
Jean-Marc Collin
9b085f1264 FIX overpowering ko 2023-03-28 10:06:35 +02:00
Jean-Marc Collin
637367bd65 FIX circular call on over_climate 2023-03-28 09:33:50 +02:00
Jean-Marc Collin
bcc0a32b6a Release 3.2 beta1 - an error in multi-testu 2023-03-26 18:39:08 +02:00
Jean-Marc Collin
80fa977c15 With all testu developed and ok 2023-03-26 12:40:38 +02:00
Jean-Marc Collin
67d20dd083 Testu ok 2023-03-25 13:02:03 +01:00
Jean-Marc Collin
e2e8499bdb First commit not completed 2023-03-25 12:32:51 +01:00
Jean-Marc Collin
93cfd22744 Issue #66 When windows opens and closes rapidly, thermostat stays to OFF 2023-03-19 10:28:16 +01:00
Jean-Marc Collin
0671e008a1 Issue #63 - security parameters cannot be set to 0 2023-03-18 12:18:44 +01:00
Jean-Marc Collin
a7465fba2e Issue #56 - Exception when undeerlying thermostat is not found at startup 2023-03-18 11:40:28 +01:00
Jean-Marc Collin
c98197e99f Add buy me a coffe badge 2023-03-12 11:27:23 +01:00
Jean-Marc Collin
b091056032 Add buy me a coffee badge 2023-03-12 11:24:59 +01:00
Jean-Marc Collin
c9efea2ce0 Typo in Readme 2023-03-12 11:10:30 +01:00
Jean-Marc Collin
171ad20d85 Release 3.1.0 2023-03-12 11:06:17 +01:00
Jean-Marc Collin
63cf77abc9 [#28] Add a window open detection based on internal temperature change 2023-03-11 18:17:17 +01:00
37 changed files with 3844 additions and 666 deletions

View File

@@ -3,7 +3,9 @@ default_config:
logger:
default: info
logs:
custom_components.versatile_thermostat: debug
custom_components.versatile_thermostat: info
custom_components.versatile_thermostat.underlyings: debug
custom_components.versatile_thermostat.climate: debug
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
debugpy:
@@ -19,6 +21,7 @@ input_number:
step: .1
icon: mdi:thermometer
unit_of_measurement: °C
mode: box
fake_external_temperature_sensor1:
name: Ext Temperature
min: -10
@@ -26,6 +29,7 @@ input_number:
step: .1
icon: mdi:home-thermometer
unit_of_measurement: °C
mode: box
fake_current_power:
name: Current power
min: 0
@@ -47,14 +51,26 @@ input_boolean:
name: Window 1
icon: mdi:window-closed-variant
# input_boolean to simulate the heater entity switch. Only for development environment.
fake_heater_switch1:
name: Heater 1 (Linear)
fake_heater_switch3:
name: Heater 3
icon: mdi:radiator
fake_heater_switch2:
name: Heater (TPI with presence preset)
name: Heater 2
icon: mdi:radiator
fake_heater_switch3:
name: Heater (TPI with offset)
fake_heater_switch1:
name: Heater 1
icon: mdi:radiator
fake_heater_4switch1:
name: Heater (multiswitch1)
icon: mdi:radiator
fake_heater_4switch2:
name: Heater (multiswitch2)
icon: mdi:radiator
fake_heater_4switch3:
name: Heater (multiswitch3)
icon: mdi:radiator
fake_heater_4switch4:
name: Heater (multiswitch4)
icon: mdi:radiator
# input_boolean to simulate the motion sensor entity. Only for development environment.
fake_motion_sensor1:

View File

@@ -2,11 +2,13 @@
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
[![hacs][hacs_badge]][hacs]
[![BuyMeCoffee][buymecoffeebadge]][buymecoffee]
![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/icon.png?raw=true)
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true?raw=true) Cette intégration de thermostat vise à simplifier considérablement vos automatisations autour de la gestion du chauffage. Parce que tous les événements autour du chauffage classiques sont gérés nativement par le thermostat (personne à la maison ?, activité détectée dans une pièce ?, fenêtre ouverte ?, délestage de courant ?), vous n'avez pas à vous encombrer de scripts et d'automatismes compliqués pour gérer vos climats. ;-).
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) Cette intégration de thermostat vise à simplifier considérablement vos automatisations autour de la gestion du chauffage. Parce que tous les événements autour du chauffage classiques sont gérés nativement par le thermostat (personne à la maison ?, activité détectée dans une pièce ?, fenêtre ouverte ?, délestage de courant ?), vous n'avez pas à vous encombrer de scripts et d'automatismes compliqués pour gérer vos climats. ;-).
- [Merci pour la bière buymecoffee: https://www.buymeacoffee.com/jmcollin78](#merci-pour-la-bière-buymecoffee-httpswwwbuymeacoffeecomjmcollin78)
- [Quand l'utiliser et ne pas l'utiliser](#quand-lutiliser-et-ne-pas-lutiliser)
- [Pourquoi une nouvelle implémentation du thermostat ?](#pourquoi-une-nouvelle-implémentation-du-thermostat-)
- [Comment installer cet incroyable Thermostat Versatile ?](#comment-installer-cet-incroyable-thermostat-versatile-)
@@ -14,10 +16,12 @@
- [Installation manuelle](#installation-manuelle)
- [Configuration](#configuration)
- [Choix des attributs de base](#choix-des-attributs-de-base)
- [Sélectionnez l'entité pilotée](#sélectionnez-lentité-pilotée)
- [Sélectionnez des entités pilotées](#sélectionnez-des-entités-pilotées)
- [Configurez les coefficients de l'algorithme TPI](#configurez-les-coefficients-de-lalgorithme-tpi)
- [Configurer la température préréglée](#configurer-la-température-préréglée)
- [Configurer les portes/fenêtres en allumant/éteignant les thermostats](#configurer-les-portesfenêtres-en-allumantéteignant-les-thermostats)
- [Le mode capteur](#le-mode-capteur)
- [Le mode auto](#le-mode-auto)
- [Configurer le mode d'activité ou la détection de mouvement](#configurer-le-mode-dactivité-ou-la-détection-de-mouvement)
- [Configurer la gestion de la puissance](#configurer-la-gestion-de-la-puissance)
- [Configurer la présence ou l'occupation](#configurer-la-présence-ou-loccupation)
@@ -45,8 +49,22 @@
- [Et toujours de mieux en mieux avec l'AappDaemon NOTIFIER pour notifier les évènements](#et-toujours-de-mieux-en-mieux-avec-laappdaemon-notifier-pour-notifier-les-évènements)
- [Les contributions sont les bienvenues !](#les-contributions-sont-les-bienvenues)
Ce composant personnalisé pour Home Assistant est une mise à niveau et est une réécriture complète du composant "Awesome thermostat" (voir [Github](https://github.com/dadge/awesome_thermostat)) avec l'ajout de fonctionnalités.
> ![Nouveau](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_
> * **Release 3.2** : ajout de la possibilité de commander plusieurs switch à partir du même thermostat. Dans ce mode, les switchs sont déclenchés avec un délai pour minimiser la puissance nécessaire à un instant (on minimise les périodes de recouvrement). Voir [Configuration](#sélectionnez-des-entités-pilotées)
> * **Release 3.1** : ajout d'une détection de fenêtres/portes ouvertes par chute de température. Cette nouvelle fonction permet de stopper automatiquement un radiateur lorsque la température chute brutalement. Voir [Le mode auto](#le-mode-auto)
> * **Release majeure 3.0** : ajout d'un équipement thermostat et de capteurs (binaires et non binaires) associés. Beaucoup plus proche de la philosphie Home Assistant, vous avez maintenant un accès direct à l'énergie consommée par le radiateur piloté par le thermostat et à plein d'autres capteurs qui seront utiles dans vos automatisations et dashboard.
> * **release 2.3** : ajout de la mesure de puissance et d'énergie du radiateur piloté par le thermostat.
> * **release 2.2** : ajout de fonction de sécurité permettant de ne pas laisser éternellement en chauffe un radiateur en cas de panne du thermomètre
> * **release majeure 2.0** : ajout du thermostat "over climate" permettant de transformer n'importe quel thermostat en Versatile Thermostat et lui ajouter toutes les fonctions de ce dernier.
# Merci pour la bière [buymecoffee]: https://www.buymeacoffee.com/jmcollin78
Un grand merci à @salabur pour la bière. Ca fait très plaisir.
# Quand l'utiliser et ne pas l'utiliser
Ce thermostat peut piloter 2 types d'équipement:
1. un radiateur qui ne fonctionne qu'en mode marche/arrêt (nommé ```thermostat_over_switch```). La configuration minimale nécessaire pour utiliser ce type thermostat est :
@@ -64,7 +82,6 @@ Parce que cette intégration vise à commander le radiateur en tenant compte du
# Pourquoi une nouvelle implémentation du thermostat ?
Pour mon usage personnel, j'avais besoin d'ajouter quelques fonctionnalités et aussi de mettre à jour le comportement implémenté dans le composant précédent "Awesome thermostat".
Ce composant nommé __Versatile thermostat__ gère les cas d'utilisation suivants :
- Configuration via l'interface graphique d'intégration standard (à l'aide du flux Config Entry),
- Utilisations complètes du **mode préréglages**,
@@ -128,12 +145,16 @@ Donnez les principaux attributs obligatoires :
1. avec le type ```thermostat_over_swutch```, les calculs sont effectués à chaque cycle. Donc en cas de changement de conditions, il faudra attendre le prochain cycle pour voir un changement. Pour cette raison, le cycle ne doit pas être trop long. **5 min est une bonne valeur**,
2. si le cycle est trop court, le radiateur ne pourra jamais atteindre la température cible en effet pour le radiateur à accumulation et il sera sollicité inutilement
## Sélectionnez l'entité pilotée
En fonction de votre choix sur le type de thermostat, vous devrez choisir une entité de type switch ou une entité de type climate. Seules les entités compatibles sont présentées.
## Sélectionnez des entités pilotées
En fonction de votre choix sur le type de thermostat, vous devrez choisir une ou plusieurs entités de type switch ou une entité de type climate. Seules les entités compatibles sont présentées.
Pour un thermostat de type ```thermostat_over_switch```:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity.png?raw=true)
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.
Exemple de déclenchement synchronisé :
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/multi-switch-activation.png?raw=true)
Pour un thermostat de type ```thermostat_over_climate```:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity2.png?raw=true)
@@ -171,19 +192,45 @@ Le mode préréglé (preset) vous permet de préconfigurer la température cibl
5. Si vous ne souhaitez pas utiliser le préréglage, indiquez 0 comme température. Le préréglage sera alors ignoré et ne s'affichera pas dans le composant front
## Configurer les portes/fenêtres en allumant/éteignant les thermostats
Si vous avez choisi la fonctionnalité ```Avec détection des ouvertures```, cliquez sur 'Valider' sur la page précédente et vous y arriverez :
Vous devez avoir choisi la fonctionnalité ```Avec détection des ouvertures``` dans la première page pour arriver sur cette page.
La détecttion des ouvertures peut se faire de 2 manières:
1. soit avec un capteur placé sur l'ouverture (mode capteur),
2. soit en détectant une chute brutale de température (mode auto)
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window.png?raw=true)
### Le mode capteur
En mode capteur, vous devez renseigner les informations suivantes:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window-sensor.png?raw=true)
Donnez les attributs suivants :
1. un identifiant d'entité d'un **capteur de fenêtre/porte**. Cela devrait être un binary_sensor ou un input_boolean. L'état de l'entité doit être 'on' lorsque la fenêtre est ouverte ou 'off' lorsqu'elle est fermée
2. un **délai en secondes** avant tout changement. Cela permet d'ouvrir rapidement une fenêtre sans arrêter le chauffage.
Et c'est tout ! votre thermostat s'éteindra lorsque les fenêtres seront ouvertes et se rallumera lorsqu'il sera fermé après le délai.
### Le mode auto
En mode auto, la configuration est la suivante:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window-auto.png?raw=true)
1. un seuil de détection en degré par minute. Lorsque la température chute au delà de ce seuil, le thermostat s'éteindra. Plus cette valeur est faible et plus la détection sera rapide (en contre-partie d'un risque de faux positif),
2. un seuil de fin de détection en degré par minute. Lorsque la chute de température repassera au-dessus cette valeur, le thermostat se remettra dans le mode précédent (mode et preset),
3. une durée maximale de détection. Au delà de cette durée, le thermostat se remettra dans son mode et preset précédent même si la température continue de chuter.
Pour régler les seuils il est conseillé de commencer avec les valeurs de référence et d'ajuster les seuils de détection. Quelques essais m'ont donné les valeurs suivantes (pour un bureau):
- seuil de détection : 0,05 °C/min
- seuil de non détection: 0 °C/min
- durée max : 60 min.
Un nouveau capteur "slope" a été ajouté pour tous les thermostats. Il donne la pente de la courbe de température en °C/min (ou °K/min). Cette pente est lissée et filtrée pour éviter les valeurs abérrantes des thermomètres qui viendraient pertuber la mesure.
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/temperature-slope.png?raw=true)
Pour bien régler il est conseillé d'affocher sur un même graphique historique la courbe de température et la pente de la courbe (le "slope") :
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/window-auto-tuning.png?raw=true)
Et c'est tout ! votre thermostat s'éteindra lorsque les fenêtres seront ouvertes et se rallumera lorsqu'il sera fermé.
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. Si vous souhaitez utiliser **plusieurs capteurs de porte/fenêtre** pour automatiser votre thermostat, créez simplement un groupe avec le comportement habituel (https://www.home-assistant.io/integrations/binary_sensor.group/)
2. Si vous n'avez pas de capteur de fenêtre/porte dans votre chambre, laissez simplement l'identifiant de l'entité du capteur vide
2. Si vous n'avez pas de capteur de fenêtre/porte dans votre chambre, laissez simplement l'identifiant de l'entité du capteur vide,
3. **Un seul mode est permis**. On ne peut pas configurer un thermostat avec un capteur et une détection automatique. Les 2 modes risquant de se contredire, il n'est pas possible d'avoir les 2 modes en même temps,
4. Il est déconseillé d'utiliser le mode automatique pour un équipement soumis à des variations de température fréquentes et normales (couloirs, zones ouvertes, ...)
## Configurer le mode d'activité ou la détection de mouvement
Si vous avez choisi la fonctionnalité ```Avec détection de mouvement```, cliquez sur 'Valider' sur la page précédente et vous y arriverez :
@@ -713,8 +760,9 @@ Si vous souhaitez contribuer, veuillez lire les [directives de contribution](CON
***
[integration_blueprint]: https://github.com/custom-components/integration_blueprint
[versatile_thermostat]: https://github.com/jmcollin78/versatile_thermostat
[buymecoffee]: https://www.buymeacoffee.com/jmcollin78
[buymecoffeebadge]: https://img.shields.io/badge/Buy%20me%20a%20beer-%245-orange?style=for-the-badge&logo=buy-me-a-beer
[commits-shield]: https://img.shields.io/github/commit-activity/y/jmcollin78/versatile_thermostat.svg?style=for-the-badge
[commits]: https://github.com/jmcollin78/versatile_thermostat/commits/master
[hacs]: https://github.com/custom-components/hacs

View File

@@ -2,11 +2,13 @@
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
[![hacs][hacs_badge]][hacs]
[![BuyMeCoffee][buymecoffeebadge]][buymecoffee]
![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/icon.png?raw=true)
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true?raw=true) This thermostat integration aims to drastically simplify your automations around climate management. Because all classical events in climate are natively handled by the thermostat (nobody at home ?, activity detected in a room ?, window open ?, power shedding ?), you don't have to build over complicated scripts and automations to manage your climates ;-).
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) This thermostat integration aims to drastically simplify your automations around climate management. Because all classical events in climate are natively handled by the thermostat (nobody at home ?, activity detected in a room ?, window open ?, power shedding ?), you don't have to build over complicated scripts and automations to manage your climates ;-).
- [Thanks for the beer](#thanks-for-the-beer)
- [When to use / not use](#when-to-use--not-use)
- [Why another thermostat implementation ?](#why-another-thermostat-implementation-)
- [How to install this incredible Versatile Thermostat ?](#how-to-install-this-incredible-versatile-thermostat-)
@@ -18,6 +20,8 @@
- [Configure the TPI algorithm coefficients](#configure-the-tpi-algorithm-coefficients)
- [Configure the preset temperature](#configure-the-preset-temperature)
- [Configure the doors/windows turning on/off the thermostats](#configure-the-doorswindows-turning-onoff-the-thermostats)
- [The sensor mode](#the-sensor-mode)
- [Auto mode](#auto-mode)
- [Configure the activity mode or motion detection](#configure-the-activity-mode-or-motion-detection)
- [Configure the power management](#configure-the-power-management)
- [Configure the presence or occupancy](#configure-the-presence-or-occupancy)
@@ -48,6 +52,17 @@
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
>![New](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*News*_
> * **Release 3.2**: added the ability to control multiple switches from the same thermostat. In this mode, the switches are triggered with a delay to minimize the power required at one time (we minimize the recovery periods). See [Configuration](#select-the-driven-entity)
> * **Release 3.1**: added detection of open windows/doors by temperature drop. This new function makes it possible to automatically stop a radiator when the temperature drops suddenly. See [Auto mode](#auto-mode)
> * **Major release 3.0**: addition of thermostat equipment and associated sensors (binary and non-binary). Much closer to the Home Assistant philosophy, you now have direct access to the energy consumed by the radiator controlled by the thermostat and many other sensors that will be useful in your automations and dashboard.
> * **release 2.3**: addition of the power and energy measurement of the radiator controlled by the thermostat.
> * **release 2.2**: addition of a safety function allowing a radiator not to be left heating forever in the event of a thermometer failure
> * **major release 2.0**: addition of the "over climate" thermostat allowing you to transform any thermostat into a Versatile Thermostat and add all the functions of the latter.
# Thanks for the beer [buymecoffee]: https://www.buymeacoffee.com/jmcollin78
Many thanks to @salabur for the beer. It's very pleasing. (Vielen Dank an @salabur für das Bier. Es ist sehr erfreulich.)
# When to use / not use
This thermostat can control 2 types of equipment:
1. a heater that only works in on/off mode (named ```thermostat_over_switch```). The minimum configuration required to use this type of thermostat is:
@@ -63,7 +78,7 @@ The ```thermostat_over_climate``` type allows you to add all the functionality p
# Why another thermostat implementation ?
For my personnal usage, I needed to add a couple of features and also to update the behavior that implemented in the previous component "Awesome thermostat".
This component named __Versatile thermostat__ manage the following use cases :
- Configuration through standard integration GUI (using Config Entry flow),
- Full uses of **presets mode**,
@@ -131,6 +146,9 @@ Depending on your choice on the type of thermostat, you will have to choose a sw
For a ```thermostat_over_switch``` thermostat:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity.png?raw=true)
The algorithm to be used today is limited to TPI is available. See [algorithm](#algorithm)
If several type entities are configured, the thermostat staggers the activations in order to minimize the number of active switches at a time t. This allows a better distribution of power since each radiator will turn on in turn.
Example of synchronized triggering:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/multi-switch-activation.png?raw=true)
For a ```thermostat_over_climate``` thermostat:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity2.png?raw=true)
@@ -160,19 +178,44 @@ The preset mode allows you to pre-configurate targeted temperature. Used in conj
5. ff you don't want to use the preseet, give 0 as temperature. The preset will then been ignored and will not displayed in the front component
## Configure the doors/windows turning on/off the thermostats
If you choose the ```Window management``` feature, click on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window.png?raw=true)
You must have chosen the ```With opening detection``` feature on the first page to arrive on this page.
The detection of openings can be done in 2 ways:
1. either with a sensor placed on the opening (sensor mode),
2. either by detecting a sudden drop in temperature (auto mode)
Give the following attributes:
1. an entity id of a **window/door sensor**. This should be a binary_sensor or a input_boolean. The state of the entity should be 'on' when the window is open or 'off' when closed
2. a **delay in seconds** before any change. This allow to quickly open a window without stopping the heater.
### The sensor mode
In sensor mode, you must fill in the following information:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window-sensor.png?raw=true)
And that's it ! your thermostat will turn off when the windows is open and be turned back on when it's closed afer the delay.
1. an entity ID of a **window/door sensor**. It should be a binary_sensor or an input_boolean. The state of the entity must be 'on' when the window is open or 'off' when it is closed
2. a **delay in seconds** before any change. This allows a window to be opened quickly without stopping the heating.
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. If you want to use **several door/windows sensors** to automatize your thermostat, just create a group with the regular behavior (https://www.home-assistant.io/integrations/binary_sensor.group/)
2. If you don't have any window/door sensor in your room, just leave the sensor entity id empty
### Auto mode
In auto mode, the configuration is as follows:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-window-auto.png?raw=true)
1. a detection threshold in degrees per minute. When the temperature drops below this threshold, the thermostat will turn off. The lower this value, the faster the detection will be (in return for a risk of false positives),
2. an end of detection threshold in degrees per minute. When the temperature drop goes above this value, the thermostat will go back to the previous mode (mode and preset),
3. maximum detection time. Beyond this time, the thermostat will return to its previous mode and preset even if the temperature continues to drop.
To set the thresholds it is advisable to start with the reference values and adjust the detection thresholds. A few tries gave me the following values (for a desktop):
- detection threshold: 0.05°C/min
- non-detection threshold: 0 °C/min
- maximum duration: 60 min.
A new "slope" sensor has been added for all thermostats. It gives the slope of the temperature curve in °C/min (or °K/min). This slope is smoothed and filtered to avoid aberrant values from the thermometers which would interfere with the measurement.
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/temperature-slope.png?raw=true)
To properly adjust it is advisable to display on the same historical graph the temperature curve and the slope of the curve (the "slope"):
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/window-auto-tuning.png?raw=true)
And that's all ! your thermostat will turn off when the windows are open and turn back on when closed.
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. If you want to use **multiple door/window sensors** to automate your thermostat, just create a group with the usual behavior (https://www.home-assistant.io/integrations/binary_sensor.group/)
2. If you don't have a window/door sensor in your room, just leave the sensor entity id blank,
3. **Only one mode is allowed**. You cannot configure a thermostat with a sensor and automatic detection. The 2 modes may contradict each other, it is not possible to have the 2 modes at the same time,
4. It is not recommended to use the automatic mode for equipment subject to frequent and normal temperature variations (corridors, open areas, ...)
## Configure the activity mode or motion detection
If you choose the ```Motion management``` feature, lick on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-motion.png?raw=true)
@@ -698,8 +741,9 @@ If you want to contribute to this please read the [Contribution guidelines](CONT
***
[integration_blueprint]: https://github.com/custom-components/integration_blueprint
[versatile_thermostat]: https://github.com/jmcollin78/versatile_thermostat
[buymecoffee]: https://www.buymeacoffee.com/jmcollin78
[buymecoffeebadge]: https://img.shields.io/badge/Buy%20me%20a%20beer-%245-orange?style=for-the-badge&logo=buy-me-a-beer
[commits-shield]: https://img.shields.io/github/commit-activity/y/jmcollin78/versatile_thermostat.svg?style=for-the-badge
[commits]: https://github.com/jmcollin78/versatile_thermostat/commits/master
[hacs]: https://github.com/custom-components/hacs

View File

@@ -64,10 +64,8 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.security_state is True
if old_state != self._attr_is_on:
@@ -99,10 +97,8 @@ class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.overpowering_state is True
if old_state != self._attr_is_on:
@@ -134,12 +130,13 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.window_state == STATE_ON
self._attr_is_on = (
self.my_climate.window_state == STATE_ON
or self.my_climate.window_auto_state == STATE_ON
)
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@@ -151,7 +148,10 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:window-open-variant"
if self.my_climate.window_state == STATE_ON:
return "mdi:window-open-variant"
else:
return "mdi:window-open"
else:
return "mdi:window-closed-variant"
@@ -169,10 +169,7 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
@@ -205,10 +202,7 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ class VersatileThermostatBaseEntity(Entity):
_my_climate: VersatileThermostat
hass: HomeAssistant
_config_id: str
_devince_name: str
_device_name: str
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
"""The CTOR"""
@@ -58,7 +58,7 @@ class VersatileThermostatBaseEntity(Entity):
try:
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
_LOGGER.debug("Device_info is %s", entity.device_info)
# _LOGGER.debug("Device_info is %s", entity.device_info)
if entity.device_info == self.device_info:
_LOGGER.debug("Found %s!", entity)
return entity

View File

@@ -41,12 +41,18 @@ from .const import (
DOMAIN,
CONF_NAME,
CONF_HEATER,
CONF_HEATER_2,
CONF_HEATER_3,
CONF_HEATER_4,
CONF_TEMP_SENSOR,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_POWER_SENSOR,
CONF_MAX_POWER_SENSOR,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
@@ -79,6 +85,7 @@ from .const import (
CONF_USE_POWER_FEATURE,
CONF_THERMOSTAT_TYPES,
UnknownEntity,
WindowOpenDetectionMethod,
)
_LOGGER = logging.getLogger(__name__)
@@ -167,7 +174,9 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
is_empty: bool = not bool(infos)
# Fix features selection depending to infos
self._infos[CONF_USE_WINDOW_FEATURE] = (
is_empty or self._infos.get(CONF_WINDOW_SENSOR) is not None
is_empty
or self._infos.get(CONF_WINDOW_SENSOR) is not None
or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None
)
self._infos[CONF_USE_MOTION_FEATURE] = (
is_empty or self._infos.get(CONF_MOTION_SENSOR) is not None
@@ -195,12 +204,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
),
),
# vol.In(temp_sensors),
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
),
), # vol.In(temp_sensors),
),
vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int,
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
@@ -218,7 +226,22 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
), # vol.In(switches),
),
vol.Optional(CONF_HEATER_2): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
),
vol.Optional(CONF_HEATER_3): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
),
vol.Optional(CONF_HEATER_4): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
),
vol.Required(
CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
): vol.In(
@@ -233,7 +256,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
{
vol.Required(CONF_CLIMATE): selector.EntitySelector(
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
), # vol.In(climates),
),
}
)
@@ -257,8 +280,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
), # vol.In(window_sensors),
),
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION): cv.positive_int,
}
)
@@ -268,7 +294,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
), # vol.In(window_sensors),
),
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
CONF_PRESETS_SELECTIONABLE
@@ -285,12 +311,12 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
),
), # vol.In(power_sensors),
),
vol.Optional(CONF_MAX_POWER_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
),
), # vol.In(power_sensors),
),
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
}
)
@@ -305,7 +331,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
INPUT_BOOLEAN_DOMAIN,
]
),
), # vol.In(presence_sensors),
),
}
).extend(
{
@@ -357,6 +383,19 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
)
raise UnknownEntity(conf)
# Check that only one window feature is used
ws = data.get(CONF_WINDOW_SENSOR) # pylint: disable=invalid-name
waot = data.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD)
wact = data.get(CONF_WINDOW_AUTO_CLOSE_THRESHOLD)
wamd = data.get(CONF_WINDOW_AUTO_MAX_DURATION)
if ws is not None and (
waot is not None or wact is not None or wamd is not None
):
_LOGGER.error(
"Only one window detection method should be used. Use window_sensor or auto window open detection but not both"
)
raise WindowOpenDetectionMethod(CONF_WINDOW_SENSOR)
def merge_user_input(self, data_schema: vol.Schema, user_input: dict):
"""For each schema entry not in user_input, set or remove values in infos"""
self._infos.update(user_input)
@@ -387,6 +426,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
await self.validate_input(user_input)
except UnknownEntity as err:
errors[str(err)] = "unknown_entity"
except WindowOpenDetectionMethod as err:
errors[str(err)] = "window_open_detection_method"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

View File

@@ -30,6 +30,9 @@ DOMAIN = "versatile_thermostat"
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR]
CONF_HEATER = "heater_entity_id"
CONF_HEATER_2 = "heater_entity2_id"
CONF_HEATER_3 = "heater_entity3_id"
CONF_HEATER_4 = "heater_entity4_id"
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
CONF_POWER_SENSOR = "power_sensor_entity_id"
@@ -61,6 +64,9 @@ CONF_USE_WINDOW_FEATURE = "use_window_feature"
CONF_USE_MOTION_FEATURE = "use_motion_feature"
CONF_USE_PRESENCE_FEATURE = "use_presence_feature"
CONF_USE_POWER_FEATURE = "use_power_feature"
CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold"
CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold"
CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration"
CONF_PRESETS = {
p: f"{p}_temp"
@@ -97,6 +103,9 @@ ALL_CONF = (
CONF_MAX_POWER_SENSOR,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
@@ -153,7 +162,12 @@ class EventType(Enum):
TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event"
HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event"
PRESET_EVENT: str = "versatile_thermostat_preset_event"
WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event"
class UnknownEntity(HomeAssistantError):
"""Error to indicate there is an unknown entity_id given."""
class WindowOpenDetectionMethod(HomeAssistantError):
"""Error to indicate there is an error in the window open detection method given."""

View File

@@ -0,0 +1,117 @@
""" This file implements the Open Window by temperature algorithm
This algo works the following way:
- each time a new temperature is measured
- calculate the slope of the temperature curve. For this we calculate the slope(t) = 1/2 slope(t-1) + 1/2 * dTemp / dt
- if the slope is lower than a threshold the window opens alert is notified
- if the slope regain positive the end of the window open alert is notified
"""
import logging
from datetime import datetime
_LOGGER = logging.getLogger(__name__)
# To filter bad values
MIN_DELTA_T_SEC = 10 # two temp mesure should be > 10 sec
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
class WindowOpenDetectionAlgorithm:
"""The class that implements the algorithm listed above"""
_alert_threshold: float
_end_alert_threshold: float
_last_slope: float
_last_datetime: datetime
_last_temperature: float
def __init__(self, alert_threshold, end_alert_threshold) -> None:
"""Initalize a new algorithm with the both threshold"""
self._alert_threshold = alert_threshold
self._end_alert_threshold = end_alert_threshold
self._last_slope = None
self._last_datetime = None
def add_temp_measurement(
self, temperature: float, datetime_measure: datetime
) -> float:
"""Add a new temperature measurement
returns the last slope
"""
if self._last_datetime is None or self._last_temperature is None:
_LOGGER.debug("First initialisation")
self._last_datetime = datetime_measure
self._last_temperature = temperature
return None
_LOGGER.debug(
"We are already initialized slope=%s last_temp=%0.2f",
self._last_slope,
self._last_temperature,
)
lspe = self._last_slope
delta_t_sec = float((datetime_measure - self._last_datetime).total_seconds())
delta_t = delta_t_sec / 60.0
if delta_t_sec <= MIN_DELTA_T_SEC:
_LOGGER.debug(
"Delta t is %d < %d which should be not possible. We don't consider this value",
delta_t_sec,
MIN_DELTA_T_SEC,
)
return lspe
delta_temp = float(temperature - self._last_temperature)
new_slope = delta_temp / delta_t
if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE:
_LOGGER.debug(
"New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value",
new_slope,
MAX_SLOPE_VALUE,
)
return lspe
if self._last_slope is None:
self._last_slope = new_slope
else:
self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
self._last_datetime = datetime_measure
self._last_temperature = temperature
_LOGGER.debug(
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f",
delta_t,
delta_temp,
new_slope,
lspe,
self._last_slope,
)
return self._last_slope
def is_window_open_detected(self) -> bool:
"""True if the last calculated slope is under (because negative value) the _alert_threshold"""
if self._alert_threshold is None:
return False
return (
self._last_slope < -self._alert_threshold
if self._last_slope is not None
else False
)
def is_window_close_detected(self) -> bool:
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
if self._end_alert_threshold is None:
return False
return (
self._last_slope >= self._end_alert_threshold
if self._last_slope is not None
else False
)
@property
def last_slope(self) -> float:
"""Return the last calculated slope"""
return self._last_slope

View File

@@ -1,3 +1,4 @@
# -r requirements_dev.txt
# aiodiscover
ulid_transform
pytest-homeassistant-custom-component

View File

@@ -46,6 +46,7 @@ async def async_setup_entry(
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
@@ -72,10 +73,7 @@ class EnergySensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
if math.isnan(self.my_climate.total_energy) or math.isinf(
self.my_climate.total_energy
@@ -130,10 +128,7 @@ class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf(
self.my_climate.mean_cycle_power
@@ -190,10 +185,7 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
on_percent = (
float(self.my_climate.proportional_algorithm.on_percent)
@@ -245,10 +237,7 @@ class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
on_time = (
float(self.my_climate.proportional_algorithm.on_time_sec)
@@ -293,10 +282,7 @@ class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
off_time = (
float(self.my_climate.proportional_algorithm.off_time_sec)
@@ -341,10 +327,7 @@ class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_temperature_mesure
@@ -373,10 +356,7 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_ext_temperature_mesure
@@ -391,3 +371,56 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TIMESTAMP
class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a sensor which exposes the temperature slope curve"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the slope sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Temperature slope"
self._attr_unique_id = f"{self._device_name}_temperature_slope"
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
last_slope = self.my_climate.last_temperature_slope
if last_slope is None:
return
if math.isnan(last_slope) or math.isinf(last_slope):
raise ValueError(f"Sensor has illegal state {last_slope}")
old_state = self._attr_native_value
self._attr_native_value = round(last_slope, self.suggested_display_precision)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_native_value is None or self._attr_native_value == 0:
return "mdi:thermometer"
elif self._attr_native_value > 0:
return "mdi:thermometer-chevron-up"
else:
return "mdi:thermometer-chevron-down"
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
if not self.my_climate:
return None
return self.my_climate.temperature_unit + "/min"
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 2

View File

@@ -22,12 +22,23 @@
}
},
"type": {
"title": "Linked entity",
"description": "Linked entity attributes",
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater entity id",
"heater_entity_id": "Heater switch",
"heater_entity2_id": "2nd Heater switch",
"heater_entity3_id": "3rd Heater switch",
"heater_entity4_id": "4th Heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying thermostat"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
"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_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"climate_entity_id": "Underlying thermostat entity id"
"climate_entity_id": "Underlying climate entity id"
}
},
"tpi": {
@@ -49,10 +60,20 @@
},
"window": {
"title": "Window management",
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
"data": {
"window_sensor_entity_id": "Window sensor entity id",
"window_delay": "Window delay (seconds)"
"window_delay": "Window sensor delay (seconds)",
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
"window_delay": "The delay in seconds before sensor detection is taken into account",
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
}
},
"motion": {
@@ -88,16 +109,23 @@
"title": "Advanced parameters",
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
"data": {
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a sceurity off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
"minimal_activation_delay": "Delay in seconds under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present"
}
}
},
"error": {
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id"
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
},
"abort": {
"already_configured": "Device is already configured"
@@ -125,12 +153,23 @@
}
},
"type": {
"title": "Linked entity",
"description": "Linked entity attributes",
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater entity id",
"heater_entity_id": "Heater switch",
"heater_entity2_id": "2nd Heater switch",
"heater_entity3_id": "3rd Heater switch",
"heater_entity4_id": "4th Heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying thermostat"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
"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_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"climate_entity_id": "Underlying thermostat entity id"
"climate_entity_id": "Underlying climate entity id"
}
},
"tpi": {
@@ -152,10 +191,20 @@
},
"window": {
"title": "Window management",
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
"data": {
"window_sensor_entity_id": "Window sensor entity id",
"window_delay": "Window delay (seconds)"
"window_delay": "Window sensor delay (seconds)",
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
"window_delay": "The delay in seconds before sensor detection is taken into account",
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
}
},
"motion": {
@@ -191,16 +240,23 @@
"title": "Advanced parameters",
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
"data": {
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
"minimal_activation_delay": "Delay in seconds under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present"
}
}
},
"error": {
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id"
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
},
"abort": {
"already_configured": "Device is already configured"
@@ -228,16 +284,5 @@
}
}
}
},
"state_attributes": {
"_": {
"preset_mode": {
"state": {
"power": "Shedding",
"security": "Security",
"none": "Manual"
}
}
}
}
}

View File

@@ -1,4 +1,6 @@
""" Some common resources """
import asyncio
import logging
from unittest.mock import patch, MagicMock
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
@@ -19,11 +21,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import
from ..underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from .const import ( # pylint: disable=unused-import
MOCK_TH_OVER_SWITCH_USER_CONFIG,
MOCK_TH_OVER_4SWITCH_USER_CONFIG,
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG,
@@ -59,6 +64,20 @@ PARTIAL_CLIMATE_CONFIG = (
| MOCK_ADVANCED_CONFIG
)
FULL_4SWITCH_CONFIG = (
MOCK_TH_OVER_4SWITCH_USER_CONFIG
| MOCK_TH_OVER_4SWITCH_TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_WINDOW_CONFIG
| MOCK_MOTION_CONFIG
| MOCK_POWER_CONFIG
| MOCK_PRESENCE_CONFIG
| MOCK_ADVANCED_CONFIG
)
_LOGGER = logging.getLogger(__name__)
class MockClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""
@@ -180,8 +199,16 @@ def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity:
return None
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
async def send_temperature_change_event(
entity: VersatileThermostat, new_temp, date, sleep=True
):
"""Sending a new temperature event simulating a change on temperature sensor"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s",
new_temp,
date,
entity,
)
temp_event = Event(
EVENT_STATE_CHANGED,
{
@@ -193,13 +220,21 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d
)
},
)
return await entity._async_temperature_changed(temp_event)
await entity._async_temperature_changed(temp_event)
if sleep:
await asyncio.sleep(0.1)
async def send_ext_temperature_change_event(
entity: VersatileThermostat, new_temp, date
entity: VersatileThermostat, new_temp, date, sleep=True
):
"""Sending a new external temperature event simulating a change on temperature sensor"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s",
new_temp,
date,
entity,
)
temp_event = Event(
EVENT_STATE_CHANGED,
{
@@ -211,11 +246,21 @@ async def send_ext_temperature_change_event(
)
},
)
return await entity._async_ext_temperature_changed(temp_event)
await entity._async_ext_temperature_changed(temp_event)
if sleep:
await asyncio.sleep(0.1)
async def send_power_change_event(entity: VersatileThermostat, new_power, date):
async def send_power_change_event(
entity: VersatileThermostat, new_power, date, sleep=True
):
"""Sending a new power event simulating a change on power sensor"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_power=%.2f date=%s on %s",
new_power,
date,
entity,
)
power_event = Event(
EVENT_STATE_CHANGED,
{
@@ -227,11 +272,21 @@ async def send_power_change_event(entity: VersatileThermostat, new_power, date):
)
},
)
return await entity._async_power_changed(power_event)
await entity._async_power_changed(power_event)
if sleep:
await asyncio.sleep(0.1)
async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date):
async def send_max_power_change_event(
entity: VersatileThermostat, new_power_max, date, sleep=True
):
"""Sending a new power max event simulating a change on power max sensor"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_power_max=%.2f date=%s on %s",
new_power_max,
date,
entity,
)
power_event = Event(
EVENT_STATE_CHANGED,
{
@@ -243,13 +298,22 @@ async def send_max_power_change_event(entity: VersatileThermostat, new_power_max
)
},
)
return await entity._async_max_power_changed(power_event)
await entity._async_max_power_changed(power_event)
if sleep:
await asyncio.sleep(0.1)
async def send_window_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
):
"""Sending a new window event simulating a change on the window state"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
new_state,
old_state,
date,
entity,
)
window_event = Event(
EVENT_STATE_CHANGED,
{
@@ -268,13 +332,22 @@ async def send_window_change_event(
},
)
ret = await entity._async_windows_changed(window_event)
if sleep:
await asyncio.sleep(0.1)
return ret
async def send_motion_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
):
"""Sending a new motion event simulating a change on the window state"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
new_state,
old_state,
date,
entity,
)
motion_event = Event(
EVENT_STATE_CHANGED,
{
@@ -293,13 +366,22 @@ async def send_motion_change_event(
},
)
ret = await entity._async_motion_changed(motion_event)
if sleep:
await asyncio.sleep(0.1)
return ret
async def send_presence_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
):
"""Sending a new presence event simulating a change on the window state"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
new_state,
old_state,
date,
entity,
)
presence_event = Event(
EVENT_STATE_CHANGED,
{
@@ -318,6 +400,8 @@ async def send_presence_change_event(
},
)
ret = await entity._async_presence_changed(presence_event)
if sleep:
await asyncio.sleep(0.1)
return ret
@@ -334,8 +418,18 @@ async def send_climate_change_event(
new_hvac_action: HVACAction,
old_hvac_action: HVACAction,
date,
sleep=True,
):
"""Sending a new climate event simulating a change on the underlying climate state"""
_LOGGER.info(
"------- Testu: sending send_temperature_change_event, new_hvac_mode=%s old_hvac_mode=%s new_hvac_action=%s old_hvac_action=%s date=%s on %s",
new_hvac_mode,
old_hvac_mode,
new_hvac_action,
old_hvac_action,
date,
entity,
)
climate_event = Event(
EVENT_STATE_CHANGED,
{
@@ -356,4 +450,14 @@ async def send_climate_change_event(
},
)
ret = await entity._async_climate_changed(climate_event)
if sleep:
await asyncio.sleep(0.1)
return ret
def cancel_switchs_cycles(entity: VersatileThermostat):
"""This method will cancel all running cycle on all underlying switch entity"""
if entity._is_over_climate:
return
for under in entity._underlyings:
under._cancel_cycle()

View File

@@ -55,9 +55,9 @@ def skip_notifications_fixture():
def skip_turn_on_off_heater():
"""Skip turning on and off the heater"""
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
"custom_components.versatile_thermostat.underlyings.UnderlyingEntity.turn_on"
), patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingEntity.turn_off"
):
yield
@@ -78,6 +78,24 @@ def skip_hass_states_get_fixture():
yield
@pytest.fixture(name="skip_control_heating")
def skip_control_heating_fixture():
"""Skip the control_heating of VersatileThermostat"""
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
yield
@pytest.fixture(name="skip_find_underlying_climate")
def skip_find_underlying_climate_fixture():
"""Skip the find_underlying_climate of VersatileThermostat"""
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate"
):
yield
@pytest.fixture(name="skip_hass_states_is_state")
def skip_hass_states_is_state_fixture():
"""Skip the is_state in HomeAssistant"""

View File

@@ -1,4 +1,5 @@
from homeassistant.components.climate.const import (
""" The commons const for all tests """
from homeassistant.components.climate.const import ( # pylint: disable=unused-import
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
@@ -8,6 +9,9 @@ from homeassistant.components.climate.const import (
from custom_components.versatile_thermostat.const import (
CONF_NAME,
CONF_HEATER,
CONF_HEATER_2,
CONF_HEATER_3,
CONF_HEATER_4,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_TYPE,
@@ -30,6 +34,9 @@ from custom_components.versatile_thermostat.const import (
CONF_USE_PRESENCE_FEATURE,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
@@ -58,6 +65,21 @@ MOCK_TH_OVER_SWITCH_USER_CONFIG = {
CONF_USE_PRESENCE_FEATURE: True,
}
MOCK_TH_OVER_4SWITCH_USER_CONFIG = {
CONF_NAME: "TheOver4SwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 8,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True,
}
MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
CONF_NAME: "TheOverClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
@@ -75,6 +97,14 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
}
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = {
CONF_HEATER: "switch.mock_4switch0",
CONF_HEATER_2: "switch.mock_4switch1",
CONF_HEATER_3: "switch.mock_4switch2",
CONF_HEATER_4: "switch.mock_4switch3",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
}
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
@@ -95,6 +125,12 @@ MOCK_WINDOW_CONFIG = {
CONF_WINDOW_DELAY: 10,
}
MOCK_WINDOW_AUTO_CONFIG = {
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 1.0,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.0,
CONF_WINDOW_AUTO_MAX_DURATION: 5.0,
}
MOCK_MOTION_CONFIG = {
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
CONF_MOTION_DELAY: 10,

View File

@@ -442,7 +442,7 @@ async def test_binary_sensors_over_climate_minimal(
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=the_mock_underlying,
):
entry = MockConfigEntry(

View File

@@ -0,0 +1,337 @@
""" Test the Window management """
from unittest.mock import patch
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_bug_56(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that in over_climate mode there is no error when underlying climate is not available"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=None, # dont find the underlying climate
):
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "climate.mock_climate",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverclimatemockname"
)
assert entity
# cause the underlying climate was not found
assert entity.is_over_climate is True
assert entity.underlying_entity(0)._underlying_climate is None
# Should not failed
entity.update_custom_attributes()
# try to call _async_control_heating
try:
await entity._async_control_heating()
# an exception should be send
assert False
except UnknownEntity:
pass
except Exception: # pylint: disable=broad-exception-caught
assert False
# This time the underlying will be found
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=the_mock_underlying, # dont find the underlying climate
):
# try to call _async_control_heating
try:
await entity._async_control_heating()
except UnknownEntity:
assert False
except Exception: # pylint: disable=broad-exception-caught
assert False
# Should not failed
entity.update_custom_attributes()
async def test_bug_63(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that it should be possible to set the security_default_on_percent to 0"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
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_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.0, # !! here
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.0, # !! here
CONF_DEVICE_POWER: 200,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
assert entity._security_min_on_percent == 0
assert entity._security_default_on_percent == 0
# Waiting for answer in https://github.com/jmcollin78/versatile_thermostat/issues/64
# Repro case not evident
async def test_bug_64(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that it should be possible to set the security_default_on_percent to 0"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
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_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.5,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, # !! here
CONF_DEVICE_POWER: 200,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
async def test_bug_66(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that it should be possible to open/close the window rapidly without side effect"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.5,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.1, # !! here
CONF_DEVICE_POWER: 200,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 0, # important to not been obliged to wait
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
# Open the window and let the thermostat shut down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
await send_temperature_change_event(entity, 15, now)
try_window_condition = await send_window_change_event(
entity, True, False, now, False
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert mock_send_event.call_count == 1
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count >= 1
assert mock_condition.call_count == 1
assert entity.window_state == STATE_ON
# Close the window but too shortly
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=False
) as mock_condition, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
event_timestamp = now + timedelta(minutes=1)
try_window_condition = await send_window_change_event(
entity, False, True, event_timestamp
)
# simulate the call to try_window_condition
await try_window_condition(None)
# window state should not have change
assert entity.window_state == STATE_ON
# Reopen immediatly with sufficient time
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
try_window_condition = await send_window_change_event(
entity, True, False, event_timestamp
)
# simulate the call to try_window_condition
await try_window_condition(None)
# still no change
assert entity.window_state == STATE_ON
assert entity.hvac_mode == HVACMode.OFF
# Close the window but with sufficient time this time
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
event_timestamp = now + timedelta(minutes=2)
try_window_condition = await send_window_change_event(
entity, False, True, event_timestamp
)
# simulate the call to try_window_condition
await try_window_condition(None)
# window state should be Off this time and old state should have been restored
assert entity.window_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST

View File

@@ -6,20 +6,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntry
from custom_components.versatile_thermostat.const import DOMAIN
from .const import (
MOCK_TH_OVER_SWITCH_USER_CONFIG,
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG,
MOCK_WINDOW_CONFIG,
MOCK_MOTION_CONFIG,
MOCK_POWER_CONFIG,
MOCK_PRESENCE_CONFIG,
MOCK_ADVANCED_CONFIG,
MOCK_DEFAULT_FEATURE_CONFIG,
)
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_show_form(hass: HomeAssistant) -> None:
@@ -217,3 +204,257 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat
assert result["result"].version == 1
assert result["result"].title == "TheOverClimateMockName"
assert isinstance(result["result"], ConfigEntry)
async def test_user_config_flow_window_auto_ok(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
):
"""Test the config flow with only window auto feature"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "type"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tpi"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presets"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "window"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_WINDOW_AUTO_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "advanced"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
result["data"]
== MOCK_TH_OVER_SWITCH_USER_CONFIG
| {
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_WINDOW_DELAY: 30, # the default value is added
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
}
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_WINDOW_AUTO_CONFIG
| MOCK_ADVANCED_CONFIG
)
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 1
assert result["result"].title == "TheOverSwitchMockName"
assert isinstance(result["result"], ConfigEntry)
async def test_user_config_flow_window_auto_ko(
hass: HomeAssistant, skip_hass_states_get
):
"""Test the config flow with window auto and window features -> not allowed"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "type"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tpi"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presets"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "window"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_WINDOW_AUTO_CONFIG | MOCK_WINDOW_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# We should stay on window with an error
assert result["step_id"] == "window"
assert result["errors"] == {
"window_sensor_entity_id": "window_open_detection_method"
}
async def test_user_config_flow_over_4_switches(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
):
"""Test the config flow with 4 switchs thermostat_over_switch features"""
SOURCE_CONFIG = { # pylint: disable=wildcard-import, invalid-name
CONF_NAME: "TheOver4SwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
}
TYPE_CONFIG = { # pylint: disable=wildcard-import, invalid-name
CONF_HEATER: "switch.mock_switch1",
CONF_HEATER_2: "switch.mock_switch2",
CONF_HEATER_3: "switch.mock_switch3",
CONF_HEATER_4: "switch.mock_switch4",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=SOURCE_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "type"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TYPE_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tpi"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presets"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "advanced"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
result["data"]
== SOURCE_CONFIG
| TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 1
assert result["result"].title == "TheOver4SwitchMockName"
assert isinstance(result["result"], ConfigEntry)

View File

@@ -0,0 +1,401 @@
""" Test the Window management """
import asyncio
from unittest.mock import patch, call, PropertyMock
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_movement_management_time_not_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Presence management when time is not enough"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"eco_away_temp": 17,
"comfort_away_temp": 18,
"boost_away_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_ACTIVITY)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
await send_presence_change_event(entity, True, False, event_timestamp)
assert entity.presence_state is "on"
# starts detecting motion
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch(
"homeassistant.helpers.condition.state", return_value=False
):
event_timestamp = now - timedelta(minutes=3)
await send_motion_change_event(entity, True, False, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
# Change is not confirmed
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# stop detecting motion with confirmation of stop
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition:
event_timestamp = now - timedelta(minutes=2)
await send_motion_change_event(entity, False, True, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is "off"
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
# Change is not confirmed
assert mock_heater_on.call_count == 0
# Because device is active
assert mock_heater_off.call_count == 1
assert mock_send_event.call_count == 0
async def test_movement_management_time_enough_and_presence(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Presence management when time is not enough"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"eco_away_temp": 17,
"comfort_away_temp": 18,
"boost_away_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_ACTIVITY)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
await send_presence_change_event(entity, True, False, event_timestamp)
assert entity.presence_state is "on"
# starts detecting motion
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
), patch(
"homeassistant.helpers.condition.state", return_value=True
):
event_timestamp = now - timedelta(minutes=3)
await send_motion_change_event(entity, True, False, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because motion is detected yet -> switch to Boost mode
assert entity.target_temperature == 19
assert entity.motion_state is "on"
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
# Change is confirmed. Heater should be started
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# stop detecting motion with confirmation of stop
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch(
"homeassistant.helpers.condition.state", return_value=True
):
event_timestamp = now - timedelta(minutes=2)
await send_motion_change_event(entity, False, True, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is "off"
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
# Because heating is no more necessary
assert mock_heater_off.call_count == 1
assert mock_send_event.call_count == 0
async def test_movement_management_time_enoughand_not_presence(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Presence management when time is not enough"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"eco_away_temp": 17.1,
"comfort_away_temp": 18.1,
"boost_away_temp": 19.1,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_ACTIVITY)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet and presence is unknown
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
await send_presence_change_event(entity, False, True, event_timestamp)
assert entity.presence_state is "off"
# starts detecting motion
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
), patch(
"homeassistant.helpers.condition.state", return_value=True
):
event_timestamp = now - timedelta(minutes=3)
await send_motion_change_event(entity, True, False, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because motion is detected yet -> switch to Boost away mode
assert entity.target_temperature == 19.1
assert entity.motion_state is "on"
assert entity.presence_state is "off"
assert mock_send_event.call_count == 0
# Change is confirmed. Heater should be started
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# stop detecting motion with confirmation of stop
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch(
"homeassistant.helpers.condition.state", return_value=True
):
event_timestamp = now - timedelta(minutes=2)
await send_motion_change_event(entity, False, True, event_timestamp)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18.1
assert entity.motion_state is "off"
assert entity.presence_state is "off"
assert mock_send_event.call_count == 0
# 18.1 starts heating with a low on_percent
assert mock_heater_on.call_count == 1
assert entity.proportional_algorithm.on_percent == 0.11
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0

View File

@@ -0,0 +1,337 @@
""" Test the Multiple switch management """
import asyncio
from unittest.mock import patch, call, ANY
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_one_switch_cycle(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
):
"""Test that when multiple switch are configured the activation is distributed"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOver4SwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOver4SwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 8,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch1",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theover4switchmockname"
)
assert entity
assert entity.is_over_climate is False
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Checks that all heaters are off
with patch(
"homeassistant.core.StateMachine.is_state", return_value=False
) as mock_is_state:
assert entity._is_device_active is False
# Should be call for the Switch
assert mock_is_state.call_count == 1
# Set temperature to a low level
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
) as mock_device_active, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later",
return_value=None,
) as mock_call_later:
await send_ext_temperature_change_event(entity, 5, event_timestamp)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be on but because call_later is mocked heater_on is not called
# assert mock_heater_on.call_count == 1
assert mock_heater_on.call_count == 0
# There is no check if active
assert mock_device_active.call_count == 0
# 4 calls dispatched along the cycle
assert mock_call_later.call_count == 1
mock_call_later.assert_has_calls(
[
call.call_later(hass, 0.0, ANY),
]
)
# Set a temperature at middle level
event_timestamp = now - timedelta(minutes=4)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
) as mock_device_active:
await send_temperature_change_event(entity, 18, event_timestamp)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be turned on but is already on but because above we mock call_later the heater is not on. But this time it will be really on
assert mock_heater_on.call_count == 1
# Set another temperature at middle level
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active:
await send_temperature_change_event(entity, 18.1, event_timestamp)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The heater is already on cycle. So we wait that the cycle ends and no heater action is done
assert mock_heater_on.call_count == 0
# assert entity.underlying_entity(0)._should_relaunch_control_heating is True
# Simulate the relaunch
await entity.underlying_entity(0)._turn_on_later(None)
# wait restart
await asyncio.sleep(0.1)
assert mock_heater_on.call_count == 1
# TODO normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False
# Simulate the end of heater on cycle
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active:
await entity.underlying_entity(0)._turn_off_later(None)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
# The heater should be turned off this time
assert mock_heater_off.call_count == 1
# assert entity.underlying_entity(0)._should_relaunch_control_heating is False
# Simulate the start of heater on cycle
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active:
await entity.underlying_entity(0)._turn_on_later(None)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
# The heater should be turned off this time
assert mock_heater_off.call_count == 0
# assert entity.underlying_entity(0)._should_relaunch_control_heating is False
async def test_multiple_switchs(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
):
"""Test that when multiple switch are configured the activation is distributed"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOver4SwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOver4SwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 8,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch1",
CONF_HEATER_2: "switch.mock_switch2",
CONF_HEATER_3: "switch.mock_switch3",
CONF_HEATER_4: "switch.mock_switch4",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theover4switchmockname"
)
assert entity
assert entity.is_over_climate is False
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Checks that all heaters are off
with patch(
"homeassistant.core.StateMachine.is_state", return_value=False
) as mock_is_state:
assert entity._is_device_active is False
# Should be call for all Switch
assert mock_is_state.call_count == 4
# Set temperature to a low level
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
) as mock_device_active, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later",
return_value=None,
) as mock_call_later:
await send_ext_temperature_change_event(entity, 5, event_timestamp)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be on but because call_later is mocked heater_on is not called
# assert mock_heater_on.call_count == 1
assert mock_heater_on.call_count == 0
# There is no check if active
assert mock_device_active.call_count == 0
# 4 calls dispatched along the cycle
assert mock_call_later.call_count == 4
mock_call_later.assert_has_calls(
[
call.call_later(hass, 0.0, ANY),
call.call_later(hass, 120.0, ANY),
call.call_later(hass, 240.0, ANY),
call.call_later(hass, 360.0, ANY),
]
)
# Set a temperature at middle level
event_timestamp = now - timedelta(minutes=4)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
) as mock_device_active:
await send_temperature_change_event(entity, 18, event_timestamp)
# No special event
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be turned on but is already on but because call_later is mocked, it is only turned on here
assert mock_heater_on.call_count == 1

View File

@@ -0,0 +1,134 @@
""" Test the OpenWindow algorithm """
from datetime import datetime, timedelta
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from ..open_window_algorithm import WindowOpenDetectionAlgorithm
async def test_open_window_algo(
hass: HomeAssistant,
skip_hass_states_is_state,
):
"""Tests the Algo"""
the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0)
assert the_algo.last_slope is None
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
event_timestamp = now - timedelta(minutes=5)
last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp
)
# We need at least 2 measurement
assert last_slope is None
assert the_algo.last_slope is None
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False
event_timestamp = now - timedelta(minutes=4)
last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp
)
# No slope because same temperature
assert last_slope == 0
assert the_algo.last_slope == 0
assert the_algo.is_window_close_detected() is True
assert the_algo.is_window_open_detected() is False
event_timestamp = now - timedelta(minutes=3)
last_slope = the_algo.add_temp_measurement(
temperature=9, datetime_measure=event_timestamp
)
# A slope is calculated
assert last_slope == -0.5
assert the_algo.last_slope == -0.5
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False
# A new temperature with 2 degre less in one minute (value will be rejected)
event_timestamp = now - timedelta(minutes=2)
last_slope = the_algo.add_temp_measurement(
temperature=7, datetime_measure=event_timestamp
)
# A slope is calculated
assert last_slope == -0.5 / 2.0 - 2.0 / 2.0
assert the_algo.last_slope == -1.25
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is True
# A new temperature with 1 degre less
event_timestamp = now - timedelta(minutes=1)
last_slope = the_algo.add_temp_measurement(
temperature=6, datetime_measure=event_timestamp
)
# A slope is calculated
assert last_slope == -1.25 / 2 - 1.0 / 2.0
assert the_algo.last_slope == -1.125
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is True
# A new temperature with 0 degre less
event_timestamp = now - timedelta(minutes=0)
last_slope = the_algo.add_temp_measurement(
temperature=6, datetime_measure=event_timestamp
)
# A slope is calculated
assert last_slope == -1.125 / 2
assert the_algo.last_slope == -1.125 / 2
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False
# A new temperature with 1 degre more
event_timestamp = now + timedelta(minutes=1)
last_slope = the_algo.add_temp_measurement(
temperature=7, datetime_measure=event_timestamp
)
# A slope is calculated
assert last_slope == -1.125 / 4 + 0.5
assert the_algo.last_slope == 0.21875
assert the_algo.is_window_close_detected() is True
assert the_algo.is_window_open_detected() is False
async def test_open_window_algo_wrong(
hass: HomeAssistant,
skip_hass_states_is_state,
):
"""Tests the Algo with wrong date"""
the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0)
assert the_algo.last_slope is None
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
event_timestamp = now - timedelta(minutes=5)
last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp
)
# We need at least 2 measurement
assert last_slope is None
assert the_algo.last_slope is None
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False
# The next datetime_measurement cannot be in the past
event_timestamp = now - timedelta(minutes=6)
last_slope = the_algo.add_temp_measurement(
temperature=18, datetime_measure=event_timestamp
)
# No slope because same temperature
assert last_slope is None
assert the_algo.last_slope is None
assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False

View File

@@ -81,9 +81,9 @@ async def test_power_management_hvac_off(
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
@@ -160,9 +160,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
@@ -194,9 +194,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_power_change_event(entity, 48, datetime.now())
assert await entity.check_overpowering() is False
@@ -278,13 +278,13 @@ async def test_power_management_energy_over_switch(
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 15, datetime.now())
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
await send_temperature_change_event(entity, 15, datetime.now())
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
@@ -307,9 +307,9 @@ async def test_power_management_energy_over_switch(
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 18, datetime.now())
assert tpi_algo.on_percent == 0.3
@@ -329,9 +329,9 @@ async def test_power_management_energy_over_switch(
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 20, datetime.now())
assert tpi_algo.on_percent == 0.0
@@ -357,7 +357,7 @@ async def test_power_management_energy_over_climate(
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=the_mock_underlying,
):
entry = MockConfigEntry(

View File

@@ -87,7 +87,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on:
event_timestamp = now - timedelta(minutes=6)
@@ -134,7 +134,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on:
await entity.async_set_preset_mode(PRESET_BOOST)
@@ -149,7 +149,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on:
event_timestamp = datetime.now()
@@ -185,4 +185,5 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
any_order=True,
)
assert mock_heater_on.call_count == 0
# Heater is now on
assert mock_heater_on.call_count == 1

View File

@@ -179,6 +179,8 @@ async def test_sensors_over_switch(
)
assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
cancel_switchs_cycles(entity)
async def test_sensors_over_climate(
hass: HomeAssistant,
@@ -190,7 +192,7 @@ async def test_sensors_over_climate(
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=the_mock_underlying,
):
entry = MockConfigEntry(
@@ -324,7 +326,7 @@ async def test_sensors_over_climate_minimal(
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=the_mock_underlying,
):
entry = MockConfigEntry(

View File

@@ -91,7 +91,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
) as mock_find_climate:
entry.add_to_hass(hass)
@@ -139,7 +139,76 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
)
assert mock_find_climate.call_count == 1
assert mock_find_climate.mock_calls[0] == call("climate.mock_climate")
mock_find_climate.assert_has_calls(
[call.find_underlying_entity("climate.mock_climate")]
assert mock_find_climate.mock_calls[0] == call()
mock_find_climate.assert_has_calls([call.find_underlying_entity()])
async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the normal full start of a thermostat in thermostat_over_switch with 4 switches type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOver4SwitchMockName",
unique_id="uniqueId",
data=FULL_4SWITCH_CONFIG,
)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity: VersatileThermostat = find_my_entity("climate.theover4switchmockname")
assert entity
assert entity.name == "TheOver4SwitchMockName"
assert entity._is_over_climate is False
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
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
assert entity._presence_state is None
assert entity._prop_algorithm is not None
assert entity.nb_underlying_entities == 4
# Checks that we have the 4 UnderlyingEntity correctly configured
for idx in range(4):
under = entity.underlying_entity(idx)
assert under is not None
assert isinstance(under, UnderlyingSwitch)
assert under.entity_id == "switch.mock_4switch" + str(idx)
assert under.initial_delay_sec == 8 * 60 / 4 * idx
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
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},
),
]
)

View File

@@ -1,7 +1,8 @@
""" Test the Window management """
from unittest.mock import patch, call
import asyncio
from unittest.mock import patch, call, PropertyMock
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime
from datetime import datetime, timedelta
import logging
@@ -11,7 +12,7 @@ logging.getLogger().setLevel(logging.DEBUG)
async def test_window_management_time_not_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
"""Test the Window management when time is not enough"""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -65,18 +66,16 @@ async def test_window_management_time_not_enough(
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=False
) as mock_condition:
await send_temperature_change_event(entity, 15, datetime.now())
try_window_condition = await send_window_change_event(
entity, True, False, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
await send_window_change_event(entity, True, False, datetime.now())
# simulate the call to try_window_condition. No need due to 0 WINDOW_DELAY and sleep after event is sent
# await try_window_condition(None)
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
@@ -97,7 +96,7 @@ async def test_window_management_time_not_enough(
async def test_window_management_time_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
"""Test the Window management when time is enough"""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -147,49 +146,79 @@ async def test_window_management_time_enough(
assert entity.window_state is None
# Open the window, but condition of time is not satisfied and check the thermostat don't turns off
# change temperature to force turning on the heater
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"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
await send_temperature_change_event(entity, 15, datetime.now())
# Heater shoud turn-on
assert mock_heater_on.call_count >= 1
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# Open the window, condition of time is satisfied, check the thermostat and heater turns off
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
await send_temperature_change_event(entity, 15, datetime.now())
try_window_condition = await send_window_change_event(
entity, True, False, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
await send_window_change_event(entity, True, False, datetime.now())
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})]
)
assert mock_heater_on.call_count == 1
# One call in turn_oiff and one call in the control_heating
# Heater should not be on
assert mock_heater_on.call_count == 0
# One call in set_hvac_mode turn_off and one call in the control_heating for security
assert mock_heater_off.call_count == 2
assert mock_condition.call_count == 1
assert entity.hvac_mode is HVACMode.OFF
assert entity.window_state == STATE_ON
# Close the window
try_window_condition = await send_window_change_event(
entity, False, True, datetime.now()
# Close the window
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
try_function = await send_window_change_event(
entity, False, True, datetime.now(), sleep=False
)
# simulate the call to try_window_condition
await try_window_condition(None)
await try_function(None)
# Wait for initial delay of heater
await asyncio.sleep(0.3)
assert entity.window_state == STATE_OFF
assert mock_heater_on.call_count == 2
assert mock_send_event.call_count == 2
assert mock_heater_on.call_count == 1
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
),
@@ -197,3 +226,424 @@ async def test_window_management_time_enough(
any_order=False,
)
assert entity.preset_mode is PRESET_BOOST
async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 21,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 21
assert entity.window_state is None
# Make the temperature down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
assert entity.last_temperature_slope is None
assert entity._window_auto_algo.is_window_open_detected() is False
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.hvac_mode is HVACMode.HEAT
# send one degre down in one minute
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=3)
await send_temperature_change_event(entity, 18, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 2
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count >= 1
assert entity.last_temperature_slope == -1
assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.OFF
mock_send_event.assert_has_calls(
[
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "start", "cause": "slope alert", "curve_slope": -1.0},
),
],
any_order=True,
)
# send another 0.1 degre in one minute -> no change
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = now - timedelta(minutes=2)
await send_temperature_change_event(entity, 17.9, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == -0.1 * 0.5 - 1 * 0.5
assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.OFF
# send another plus 1.1 degre in one minute -> restore state
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = now - timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
),
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{
"type": "end",
"cause": "end of slope alert",
"curve_slope": 0.27500000000000036,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == 0.275
assert entity._window_auto_algo.is_window_open_detected() is False
assert entity._window_auto_algo.is_window_close_detected() is True
assert entity.window_auto_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT
async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 21,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 21
assert entity.window_state is None
# Make the temperature down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_heater_on.call_count == 1
assert entity.last_temperature_slope is None
assert entity._window_auto_algo.is_window_open_detected() is False
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.hvac_mode is HVACMode.HEAT
# send one degre down in one minute
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=3)
await send_temperature_change_event(entity, 18, event_timestamp, sleep=False)
assert mock_send_event.call_count == 2
# The heater turns off
mock_send_event.assert_has_calls(
[
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{
"type": "start",
"cause": "slope alert",
"curve_slope": -1.0,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count >= 1
assert entity.last_temperature_slope == -1
assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.OFF
# Waits for automatic disable
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
):
await asyncio.sleep(0.3)
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == -1
# Because the algorithm is not aware of the expiration, for the algo we are still in alert
assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.window_auto_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
async def test_window_auto_no_on_percent(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 21,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 21
assert entity.window_state is None
# Make the temperature down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 21.5, event_timestamp)
# The heater turns on
assert mock_heater_on.call_count == 0
assert entity.last_temperature_slope is None
assert entity._window_auto_algo.is_window_open_detected() is False
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.hvac_mode is HVACMode.HEAT
assert entity.proportional_algorithm.on_percent == 0.0
# send one degre down in one minute
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = now - timedelta(minutes=3)
await send_temperature_change_event(entity, 20, event_timestamp)
# The heater turns on but no alert because the heater was not heating
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert entity.last_temperature_slope == -1.5
assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False
assert entity.window_auto_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT

View File

@@ -22,12 +22,23 @@
}
},
"type": {
"title": "Linked entity",
"description": "Linked entity attributes",
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater entity id",
"heater_entity_id": "Heater switch",
"heater_entity2_id": "2nd Heater switch",
"heater_entity3_id": "3rd Heater switch",
"heater_entity4_id": "4th Heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying thermostat"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
"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_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"climate_entity_id": "Underlying thermostat entity id"
"climate_entity_id": "Underlying climate entity id"
}
},
"tpi": {
@@ -49,10 +60,20 @@
},
"window": {
"title": "Window management",
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
"data": {
"window_sensor_entity_id": "Window sensor entity id",
"window_delay": "Window delay (seconds)"
"window_delay": "Window sensor delay (seconds)",
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
"window_delay": "The delay in seconds before sensor detection is taken into account",
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
}
},
"motion": {
@@ -88,16 +109,23 @@
"title": "Advanced parameters",
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
"data": {
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a sceurity off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
"minimal_activation_delay": "Delay in seconds under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present"
}
}
},
"error": {
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id"
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
},
"abort": {
"already_configured": "Device is already configured"
@@ -125,12 +153,23 @@
}
},
"type": {
"title": "Linked entity",
"description": "Linked entity attributes",
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater entity id",
"heater_entity_id": "Heater switch",
"heater_entity2_id": "2nd Heater switch",
"heater_entity3_id": "3rd Heater switch",
"heater_entity4_id": "4th Heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying thermostat"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
"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_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"climate_entity_id": "Underlying thermostat entity id"
"climate_entity_id": "Underlying climate entity id"
}
},
"tpi": {
@@ -152,10 +191,20 @@
},
"window": {
"title": "Window management",
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
"data": {
"window_sensor_entity_id": "Window sensor entity id",
"window_delay": "Window delay (seconds)"
"window_delay": "Window sensor delay (seconds)",
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
"window_delay": "The delay in seconds before sensor detection is taken into account",
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
}
},
"motion": {
@@ -191,16 +240,23 @@
"title": "Advanced parameters",
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
"data": {
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a sceurity off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
"minimal_activation_delay": "Delay in seconds under which the equipment will not be activated",
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state",
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset",
"security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present"
}
}
},
"error": {
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id"
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
},
"abort": {
"already_configured": "Device is already configured"
@@ -228,16 +284,5 @@
}
}
}
},
"state_attributes": {
"_": {
"preset_mode": {
"state": {
"power": "Shedding",
"security": "Security",
"none": "Manual"
}
}
}
}
}

View File

@@ -21,12 +21,23 @@
}
},
"type": {
"title": "Entité liée",
"description": "Attributs de l'entité liée",
"title": "Entité(s) liée(s)",
"description": "Attributs de(s) l'entité(s) liée(s)",
"data": {
"heater_entity_id": "Radiateur entity id",
"heater_entity_id": "1er radiateur",
"heater_entity2_id": "2ème radiateur",
"heater_entity3_id": "3ème radiateur",
"heater_entity4_id": "4ème radiateur",
"proportional_function": "Algorithme",
"climate_entity_id": "Thermostat sous-jacent"
},
"data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"climate_entity_id": "Thermostat sous-jacent entity id"
"climate_entity_id": "Entity id du thermostat sous-jacent"
}
},
"tpi": {
@@ -50,8 +61,18 @@
"title": "Gestion d'une ouverture",
"description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.",
"data": {
"window_sensor_entity_id": "Ouverture sensor entity id",
"window_delay": "Délai avant extinction (seconds)"
"window_sensor_entity_id": "Détecteur d'ouverture (entity id)",
"window_delay": "Délai avant extinction (secondes)",
"window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)",
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)",
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)"
},
"data_description": {
"window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur",
"window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte",
"window_auto_open_threshold": "Valeur recommandée: entre 0.05 et 0.1. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique"
}
},
"motion": {
@@ -87,8 +108,14 @@
"title": "Parameters avancés",
"description": "Configuration des paramètres avancés. Laissez les valeurs par défaut si vous ne savez pas ce que vous faites.\nCes paramètres peuvent induire des mauvais comportements du thermostat.",
"data": {
"minimal_activation_delay": "Délai en secondes en-dessous duquel l'équipement ne sera pas activé",
"security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position éteinte de sécurité",
"minimal_activation_delay": "Délai minimal d'activation",
"security_delay_min": "Délai maximal entre 2 mesures de températures",
"security_min_on_percent": "Pourcentage minimal de puissance",
"security_default_on_percent": "Pourcentage de puissance a utiliser en mode securité"
},
"data_description": {
"minimal_activation_delay": "Délai en seondes en-dessous duquel l'équipement ne sera pas activé",
"security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position de sécurité",
"security_min_on_percent": "Seuil minimal de pourcentage de chauffage en-dessous duquel le préréglage sécurité ne sera jamais activé",
"security_default_on_percent": "Valeur par défaut pour le pourcentage de chauffage en mode sécurité. Mettre 0 pour éteindre le radiateur en mode sécurité"
}
@@ -96,7 +123,8 @@
},
"error": {
"unknown": "Erreur inattendue",
"unknown_entity": "entity id inconnu"
"unknown_entity": "entity id inconnu",
"window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux."
},
"abort": {
"already_configured": "Le device est déjà configuré"
@@ -125,12 +153,23 @@
}
},
"type": {
"title": "Entité liée",
"description": "Attributs de l'entité liée",
"title": "Entité(s) liée(s)",
"description": "Attributs de(s) l'entité(s) liée(s)",
"data": {
"heater_entity_id": "Radiateur entity id",
"heater_entity_id": "1er radiateur",
"heater_entity2_id": "2ème radiateur",
"heater_entity3_id": "3ème radiateur",
"heater_entity4_id": "4ème radiateur",
"proportional_function": "Algorithme",
"climate_entity_id": "Thermostat sous-jacent"
},
"data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"climate_entity_id": "Thermostat sous-jacent entity id"
"climate_entity_id": "Entity id du thermostat sous-jacent"
}
},
"tpi": {
@@ -154,8 +193,18 @@
"title": "Gestion d'une ouverture",
"description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.",
"data": {
"window_sensor_entity_id": "Ouverture sensor entity id",
"window_delay": "Délai avant extinction (seconds)"
"window_sensor_entity_id": "Détecteur d'ouverture (entity id)",
"window_delay": "Délai avant extinction (secondes)",
"window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)",
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)",
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)"
},
"data_description": {
"window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur",
"window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte",
"window_auto_open_threshold": "Valeur recommandée: entre 0.05 et 0.1. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique"
}
},
"motion": {
@@ -191,8 +240,14 @@
"title": "Parameters avancés",
"description": "Configuration des paramètres avancés. Laissez les valeurs par défaut si vous ne savez pas ce que vous faites.\nCes paramètres peuvent induire des mauvais comportements du thermostat.",
"data": {
"minimal_activation_delay": "Délai minimal d'activation",
"security_delay_min": "Délai maximal entre 2 mesures de températures",
"security_min_on_percent": "Pourcentage minimal de puissance",
"security_default_on_percent": "Pourcentage de puissance a utiliser en mode securité"
},
"data_description": {
"minimal_activation_delay": "Délai en seondes en-dessous duquel l'équipement ne sera pas activé",
"security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position éteinte de sécurité",
"security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position de sécurité",
"security_min_on_percent": "Seuil minimal de pourcentage de chauffage en-dessous duquel le préréglage sécurité ne sera jamais activé",
"security_default_on_percent": "Valeur par défaut pour le pourcentage de chauffage en mode sécurité. Mettre 0 pour éteindre le radiateur en mode sécurité"
}
@@ -200,7 +255,8 @@
},
"error": {
"unknown": "Erreur inattendue",
"unknown_entity": "entity id inconnu"
"unknown_entity": "entity id inconnu",
"window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux."
},
"abort": {
"already_configured": "Le device est déjà configuré"
@@ -228,16 +284,5 @@
}
}
}
},
"state_attributes": {
"_": {
"preset_mode": {
"state": {
"power": "Délestage",
"security": "Sécurité",
"none": "Manuel"
}
}
}
}
}

View File

@@ -0,0 +1,579 @@
""" Underlying entities classes """
import logging
from typing import Any
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature
from homeassistant.exceptions import ServiceNotFound
from homeassistant.backports.enum import StrEnum
from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
DOMAIN as CLIMATE_DOMAIN,
HVACMode,
HVACAction,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HUMIDITY,
SERVICE_SET_SWING_MODE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_SET_TEMPERATURE,
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later
from .const import UnknownEntity
_LOGGER = logging.getLogger(__name__)
# remove this
# _LOGGER.setLevel(logging.DEBUG)
class UnderlyingEntityType(StrEnum):
"""All underlying device type"""
# A switch
SWITCH = "switch"
# a climate
CLIMATE = "climate"
class UnderlyingEntity:
"""Represent a underlying device which could be a switch or a climate"""
_hass: HomeAssistant
# Cannot import VersatileThermostat due to circular reference
_thermostat: Any
_entity_id: str
_type: UnderlyingEntityType
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
entity_type: UnderlyingEntityType,
entity_id: str,
) -> None:
"""Initialize the underlying entity"""
self._hass = hass
self._thermostat = thermostat
self._type = entity_type
self._entity_id = entity_id
def __str__(self):
return str(self._thermostat) + "-" + self._entity_id
@property
def entity_id(self):
"""The entiy id represented by this class"""
return self._entity_id
@property
def entity_type(self) -> UnderlyingEntityType:
"""The entity type represented by this class"""
return self._type
@property
def is_initialized(self) -> bool:
"""True if the underlying is initialized"""
return True
def startup(self):
"""Startup the Entity"""
return
async def set_hvac_mode(self, hvac_mode: HVACMode):
"""Set the HVACmode"""
return
@property
def is_device_active(self) -> bool | None:
"""If the toggleable device is currently active."""
return None
async def turn_off(self):
"""Turn heater toggleable device off."""
_LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
# This may fails if called after shutdown
try:
data = {ATTR_ENTITY_ID: self._entity_id}
await self._hass.services.async_call(
HA_DOMAIN,
SERVICE_TURN_OFF,
data,
)
except ServiceNotFound as err:
_LOGGER.error(err)
async def turn_on(self):
"""Turn heater toggleable device on."""
_LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id)
try:
data = {ATTR_ENTITY_ID: self._entity_id}
await self._hass.services.async_call(
HA_DOMAIN,
SERVICE_TURN_ON,
data,
)
except ServiceNotFound as err:
_LOGGER.error(err)
async def set_temperature(self, temperature, max_temp, min_temp):
"""Set the target temperature"""
return
async def remove_entity(self):
"""Remove the underlying entity"""
return
# override to be able to mock the call
def call_later(
self, hass: HomeAssistant, delay_sec: int, called_method
) -> CALLBACK_TYPE:
"""Call the method after a delay"""
return async_call_later(hass, delay_sec, called_method)
class UnderlyingSwitch(UnderlyingEntity):
"""Represent a underlying switch"""
_initialDelaySec: int
_on_time_sec: int
_off_time_sec: int
_hvac_mode: HVACMode
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
switch_entity_id: str,
initial_delay_sec: int,
) -> None:
"""Initialize the underlying switch"""
super().__init__(
hass=hass,
thermostat=thermostat,
entity_type=UnderlyingEntityType.SWITCH,
entity_id=switch_entity_id,
)
self._initial_delay_sec = initial_delay_sec
self._async_cancel_cycle = None
self._should_relaunch_control_heating = False
self._on_time_sec = 0
self._off_time_sec = 0
@property
def initial_delay_sec(self):
"""The initial delay for this class"""
return self._initial_delay_sec
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
"""Set the HVACmode. Returns true if we need to redo a control_heating"""
if hvac_mode == HVACMode.OFF:
if self.is_device_active:
await self.turn_off()
await self._cancel_cycle()
return True
@property
def is_device_active(self):
"""If the toggleable device is currently active."""
return self._hass.states.is_state(self._entity_id, STATE_ON)
async def check_initial_state(self, hvac_mode: HVACMode):
"""Prevent the heater to be on but thermostat is off"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
"%s - The hvac mode is OFF, but the switch device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.turn_off()
async def start_cycle(
self,
hvac_mode: HVACMode,
on_time_sec: int,
off_time_sec: int,
force=False,
):
"""Starting cycle for switch"""
_LOGGER.debug(
"%s - Starting new cycle hvac_mode=%s on_time_sec=%d off_time_sec=%d force=%s",
self,
hvac_mode,
on_time_sec,
off_time_sec,
force,
)
self._on_time_sec = on_time_sec
self._off_time_sec = off_time_sec
self._hvac_mode = hvac_mode
# Cancel eventual previous cycle if any
if self._async_cancel_cycle is not None:
if force:
_LOGGER.debug("%s - we force a new cycle", self)
await self._cancel_cycle()
else:
_LOGGER.debug(
"%s - A previous cycle is alredy running and no force -> waits for its end",
self,
)
# self._should_relaunch_control_heating = True
_LOGGER.debug("%s - End of cycle (2)", self)
return
# If we should heat, starts the cycle with delay
if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0:
# Starts the cycle after the initial delay
self._async_cancel_cycle = self.call_later(
self._hass, self._initial_delay_sec, self._turn_on_later
)
_LOGGER.debug("%s - _async_cancel_cycle=%s", self, self._async_cancel_cycle)
# if we not heat but device is active
elif self.is_device_active:
_LOGGER.info(
"%s - stop heating (2) for %d min %d sec",
self,
off_time_sec // 60,
off_time_sec % 60,
)
await self.turn_off()
else:
_LOGGER.debug("%s - nothing to do", self)
async def _cancel_cycle(self):
"""Cancel the cycle"""
if self._async_cancel_cycle:
self._async_cancel_cycle()
self._async_cancel_cycle = None
_LOGGER.debug("%s - Stopping cycle during calculation", self)
async def _turn_on_later(self, _):
"""Turn the heater on after a delay"""
_LOGGER.debug(
"%s - calling turn_on_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
self,
self._hvac_mode,
self._should_relaunch_control_heating,
self._on_time_sec,
)
await self._cancel_cycle()
if self._hvac_mode == HVACMode.OFF:
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
if self.is_device_active:
await self.turn_off()
return
if await self._thermostat.check_overpowering():
_LOGGER.debug("%s - End of cycle (3)", self)
return
# Security mode could have change the on_time percent
await self._thermostat.check_security()
time = self._on_time_sec
action_label = "start"
# if self._should_relaunch_control_heating:
# _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label)
# self._should_relaunch_control_heating = False
# # self.hass.create_task(self._async_control_heating())
# await self.start_cycle(
# self._hvac_mode, self._on_time_sec, self._off_time_sec
# )
# _LOGGER.debug("%s - End of cycle (3)", self)
# return
if time > 0:
_LOGGER.info(
"%s - %s heating for %d min %d sec",
self,
action_label,
time // 60,
time % 60,
)
await self.turn_on()
else:
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
self._async_cancel_cycle = self.call_later(
self._hass,
time,
self._turn_off_later,
)
async def _turn_off_later(self, _):
"""Turn the heater off and call the next cycle after the delay"""
_LOGGER.debug(
"%s - calling turn_off_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
self,
self._hvac_mode,
self._should_relaunch_control_heating,
self._off_time_sec,
)
await self._cancel_cycle()
if self._hvac_mode == HVACMode.OFF:
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
if self.is_device_active:
await self.turn_off()
return
action_label = "stop"
# if self._should_relaunch_control_heating:
# _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label)
# self._should_relaunch_control_heating = False
# # self.hass.create_task(self._async_control_heating())
# await self.start_cycle(
# self._hvac_mode, self._on_time_sec, self._off_time_sec
# )
# _LOGGER.debug("%s - End of cycle (3)", self)
# return
time = self._off_time_sec
if time > 0:
_LOGGER.info(
"%s - %s heating for %d min %d sec",
self,
action_label,
time // 60,
time % 60,
)
await self.turn_off()
else:
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
self._async_cancel_cycle = self.call_later(
self._hass,
time,
self._turn_on_later,
)
# increment energy at the end of the cycle
self._thermostat.incremente_energy()
async def remove_entity(self):
"""Remove the entity"""
await self._cancel_cycle()
class UnderlyingClimate(UnderlyingEntity):
"""Represent a underlying climate"""
_underlying_climate: ClimateEntity
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
climate_entity_id: str,
) -> None:
"""Initialize the underlying climate"""
super().__init__(
hass=hass,
thermostat=thermostat,
entity_type=UnderlyingEntityType.CLIMATE,
entity_id=climate_entity_id,
)
self._underlying_climate = None
def find_underlying_climate(self) -> ClimateEntity:
"""Find the underlying climate entity"""
component: EntityComponent[ClimateEntity] = self._hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if self.entity_id == entity.entity_id:
return entity
return None
def startup(self):
"""Startup the Entity"""
# Get the underlying climate
self._underlying_climate = self.find_underlying_climate()
if self._underlying_climate:
_LOGGER.info(
"%s - The underlying climate entity: %s have been succesfully found",
self,
self._underlying_climate,
)
else:
_LOGGER.error(
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational",
self,
self.entity_id,
)
# #56 keep the over_climate and try periodically to find the underlying climate
# self._is_over_climate = False
raise UnknownEntity(f"Underlying entity {self.entity_id} not found")
return
@property
def is_initialized(self) -> bool:
"""True if the underlying climate was found"""
return self._underlying_climate is not None
async def set_hvac_mode(self, hvac_mode: HVACMode):
"""Set the HVACmode of the underlying climate"""
if not self.is_initialized:
return
data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode}
await self._hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
data,
)
@property
def is_device_active(self):
"""If the toggleable device is currently active."""
if self.is_initialized:
return self._underlying_climate.hvac_action not in [
HVACAction.IDLE,
HVACAction.OFF,
]
else:
return None
async def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"fan_mode": fan_mode,
}
await self._hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
data,
)
async def set_humidity(self, humidity: int):
"""Set new target humidity."""
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"humidity": humidity,
}
await self._hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HUMIDITY,
data,
)
async def set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"swing_mode": swing_mode,
}
await self._hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_MODE,
data,
)
async def set_temperature(self, temperature, max_temp, min_temp):
"""Set the target temperature"""
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"temperature": temperature,
"target_temp_high": max_temp,
"target_temp_low": min_temp,
}
await self._hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
data,
)
@property
def hvac_action(self) -> HVACAction | None:
"""Get the hvac action of the underlying"""
if not self.is_initialized:
return None
return self._underlying_climate.hvac_action
@property
def hvac_mode(self) -> HVACMode | None:
"""Get the hvac mode of the underlying"""
if not self.is_initialized:
return None
return self._underlying_climate.hvac_mode
@property
def fan_mode(self) -> str | None:
"""Get the fan_mode of the underlying"""
if not self.is_initialized:
return None
return self._underlying_climate.fan_mode
@property
def swing_mode(self) -> str | None:
"""Get the swing_mode of the underlying"""
if not self.is_initialized:
return None
return self._underlying_climate.swing_mode
@property
def supported_features(self) -> ClimateEntityFeature:
"""Get the supported features of the climate"""
if not self.is_initialized:
return ClimateEntityFeature.TARGET_TEMPERATURE
return self._underlying_climate.supported_features
@property
def hvac_modes(self) -> list[HVACMode]:
"""Get the hvac_modes"""
if not self.is_initialized:
return []
return self._underlying_climate.hvac_modes
@property
def fan_modes(self) -> list[str]:
"""Get the fan_modes"""
if not self.is_initialized:
return []
return self._underlying_climate.fan_modes
@property
def swing_modes(self) -> list[str]:
"""Get the swing_modes"""
if not self.is_initialized:
return []
return self._underlying_climate.swing_modes
@property
def temperature_unit(self) -> str:
"""Get the temperature_unit"""
if not self.is_initialized:
return UnitOfTemperature.CELSIUS
return self._underlying_climate.temperature_unit
@property
def target_temperature_step(self) -> str:
"""Get the target_temperature_step"""
if not self.is_initialized:
return 1
return self._underlying_climate.target_temperature_step

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
images/new-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB