Compare commits

...

33 Commits

Author SHA1 Message Date
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
Jean-Marc Collin
6e40a15262 Documentation for release 2023-03-05 10:36:58 +01:00
Jean-Marc Collin
974e5d26db Finish unit tests #48
Remove warnings #48
Add theme colors #27
2023-03-05 10:11:16 +01:00
Jean-Marc Collin
ae32f117a0 Add reload and default power and energy units 2023-03-04 11:23:20 +01:00
Jean-Marc Collin
eb8cb18c6f Update documentation 2023-03-04 09:47:50 +01:00
Jean-Marc Collin
ea7b6a0425 FIX error on startup when my_climate is not found 2023-03-03 22:03:08 +01:00
Jean-Marc Collin
7c8717553b Add duration cycle sensors 2023-03-03 18:28:33 +01:00
Jean-Marc Collin
f672fc807d Add sensors 2023-03-01 08:11:53 +01:00
Jean-Marc Collin
168568ac5d With all binary_sensor ok 2023-02-28 22:48:07 +01:00
Jean-Marc Collin
330c3323d1 Add binary_sensors and it's ok 2023-02-26 23:34:37 +01:00
Jean-Marc Collin
e63213d22a try to fix [MANIFEST] Manifest keys have been sorted: domain, name, then alphabetical order 2023-02-26 11:49:07 +01:00
Jean-Marc Collin
fb7ee1bdac try to FIX Manifest keys have been sorted 2023-02-26 11:44:33 +01:00
Jean-Marc Collin
ca86b310c4 Power of the heater should be accessible event if power management is not selected #53 2023-02-26 11:41:38 +01:00
Jean-Marc Collin
23074e6f46 Add energy for thermostat over climate 2023-02-26 11:22:20 +01:00
Jean-Marc Collin
718315c4fe FIX Documentation is wrong #55 2023-02-24 08:06:29 +01:00
Jean-Marc Collin
46278ca9a3 In certain case, temperature event are registred with an offset of one hour #52 2023-02-19 22:42:02 +01:00
Jean-Marc Collin
0b81a94d0f Add testus and change date timestamp. 2023-02-19 19:10:08 +01:00
Jean-Marc Collin
33590886c1 With energy calculation 2023-02-18 18:10:37 +01:00
Jean-Marc Collin
039b372a53 Add power tests 2023-02-18 11:49:37 +01:00
Jean-Marc Collin
a161540f10 Testus +1 2023-02-18 11:49:20 +01:00
Jean-Marc Collin
8bbcafdf4a Add the device power and device energy into attributes #25 2023-02-18 00:07:54 +01:00
Jean-Marc Collin
08d08e52de FIX A thermostat stays with security_default_on_percent when the preset change during security mode #49 2023-02-15 23:44:09 +01:00
Jean-Marc Collin
81b4f7e5f6 Testus 2 2023-02-12 23:52:04 +01:00
Jean-Marc Collin
7a917c6ff7 Testus 2023-02-12 18:50:56 +01:00
Jean-Marc Collin
20a9e2523e First config flow testu 2023-02-12 12:35:32 +01:00
Jean-Marc Collin
bb6e9edd06 Update documentation for release 2.2.0 2023-02-12 00:11:56 +01:00
45 changed files with 6046 additions and 271 deletions

View File

@@ -19,6 +19,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 +27,7 @@ input_number:
step: .1
icon: mdi:home-thermometer
unit_of_measurement: °C
mode: box
fake_current_power:
name: Current power
min: 0
@@ -118,6 +120,37 @@ template:
unique_id: maison_occupee
state: "{{is_state('person.jmc', 'home') }}"
device_class: occupancy
- sensor:
- name: "Total énergie switch1"
unique_id: total_energie_switch1
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_switch_1', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
- name: "Total énergie climate 2"
unique_id: total_energie_climate2
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_climate_2', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
- name: "Total énergie chambre"
unique_id: total_energie_chambre
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_chambre', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
switch:
- platform: template
@@ -138,3 +171,12 @@ switch:
option: comfort-2
target:
entity_id: select.seche_serviettes_sdb_rdc_cable_outlet_mode
frontend:
themes:
versatile_thermostat_theme:
state-binary_sensor-safety-on-color: "#FF0B0B"
state-binary_sensor-power-on-color: "#FF0B0B"
state-binary_sensor-window-on-color: "rgb(156, 39, 176)"
state-binary_sensor-motion-on-color: "rgb(156, 39, 176)"
state-binary_sensor-presence-on-color: "lightgreen"

View File

@@ -2,6 +2,7 @@
[![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)
@@ -18,6 +19,8 @@
- [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)
@@ -25,26 +28,37 @@
- [Exemples de réglage](#exemples-de-réglage)
- [Chauffage électrique](#chauffage-électrique)
- [Chauffage central (chauffage gaz ou fuel)](#chauffage-central-chauffage-gaz-ou-fuel)
- [Le capteur de température sera alimenté par batterie](#le-capteur-de-température-sera-alimenté-par-batterie)
- [Capteur de température réactif](#capteur-de-température-réactif)
- [Ma configuration prédéfinie](#ma-configuration-prédéfinie)
- [Le capteur de température alimenté par batterie](#le-capteur-de-température-alimenté-par-batterie)
- [Capteur de température réactif (sur secteur)](#capteur-de-température-réactif-sur-secteur)
- [Mes presets](#mes-presets)
- [Algorithme](#algorithme)
- [Algorithme TPI](#algorithme-tpi)
- [Capteurs](#capteurs)
- [Services](#services)
- [Forcer la présence/occupation](#forcer-la-présenceoccupation)
- [Modifier la température des préréglages](#modifier-la-température-des-préréglages)
- [Modifier les paramètres de sécurité](#modifier-les-paramètres-de-sécurité)
- [Notifications](#notifications)
- [Attributs personnalisés](#attributs-personnalisés)
- [Quelques résultats](#quelques-résultats)
- [Encore mieux](#encore-mieux)
- [Encore mieux avec le composant Scheduler !](#encore-mieux-avec-le-composant-scheduler-)
- [Encore bien mieux avec la custom:simple-thermostat front integration](#encore-bien-mieux-avec-la-customsimple-thermostat-front-integration)
- [Toujours mieux avec Apex-chart pour régler votre thermostat](#toujours-mieux-avec-apex-chart-pour-régler-votre-thermostat)
- [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)
_Composant développé à l'aide de l'incroyable modèle de développement [[blueprint](https://github.com/custom-components/integration_blueprint)]._
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.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.
# 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 :
@@ -62,7 +76,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**,
@@ -70,9 +83,10 @@ Ce composant nommé __Versatile thermostat__ gère les cas d'utilisation suivant
- Éteindre/allumer un thermostat lorsqu'une **porte ou des fenêtres sont ouvertes/fermées** après un certain délai,
- Changer de preset lorsqu'une **activité est détectée** ou non dans une pièce pendant un temps défini,
- Utiliser un algorithme **TPI (Time Proportional Interval)** grâce à l'algorithme [[Argonaute](https://forum.hacf.fr/u/argonaute/summary)] ,
- Ajoutez une **gestion de délestage** ou une régulation pour ne pas dépasser une puissance totale définie. Lorsque la puissance maximale est dépassée, un préréglage caché de « puissance » est défini sur l'entité climatique. Lorsque la puissance passe en dessous du maximum, le préréglage précédent est restauré.
- Ajouter la **gestion de la présence à domicile**. Cette fonctionnalité vous permet de modifier dynamiquement la température du préréglage en tenant compte d'un capteur de présence de votre maison.
- Ajoutez des **services pour interagir avec le thermostat** à partir d'autres intégrations : vous pouvez forcer la présence / la non-présence à l'aide d'un service, et vous pouvez modifier dynamiquement la température des préréglages.
- Ajouter une **gestion de délestage** ou une régulation pour ne pas dépasser une puissance totale définie. Lorsque la puissance maximale est dépassée, un préréglage caché de « puissance » est défini sur l'entité climatique. Lorsque la puissance passe en dessous du maximum, le préréglage précédent est restauré.
- La **gestion de la présence à domicile**. Cette fonctionnalité vous permet de modifier dynamiquement la température du préréglage en tenant compte d'un capteur de présence de votre maison.
- Des **services pour interagir avec le thermostat** à partir d'autres intégrations : vous pouvez forcer la présence / la non-présence à l'aide d'un service, et vous pouvez modifier dynamiquement la température des préréglages et changer les paramètres de sécurité.
- Ajouter des capteurs pour voir les états internes du thermostat.
# Comment installer cet incroyable Thermostat Versatile ?
@@ -118,7 +132,8 @@ Donnez les principaux attributs obligatoires :
5. une entité capteur de température donnant la température extérieure. Si vous n'avez pas de capteur externe, vous pouvez utiliser l'intégration météo locale
6. une durée de cycle en minutes. A chaque cycle, le radiateur s'allumera puis s'éteindra pendant une durée calculée afin d'atteindre la température ciblée (voir [preset](#configure-the-preset-temperature) ci-dessous),
7. les températures minimales et maximales du thermostat,
8. la liste des fonctionnalités qui seront utilisées pour ce thermostat. En fonction de vos choix, les écrans de configuration suivants s'afficheront ou pas.
8. une puissance de l'équipement ce qui va activer les capteurs de puissance et énergie consommée par l'appareil,
9. la liste des fonctionnalités qui seront utilisées pour ce thermostat. En fonction de vos choix, les écrans de configuration suivants s'afficheront ou pas.
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
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**,
@@ -167,19 +182,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 :
@@ -210,7 +251,7 @@ Si vous avez choisi la fonctionnalité ```Avec détection de la puissance```, cl
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-power.png?raw=true)
Cette fonction vous permet de réguler la consommation électrique de vos radiateurs. Connue sous le nom de délestage, cette fonction vous permet de limiter la consommation électrique de votre appareil de chauffage si des conditions de surpuissance sont détectées. Donnez un **capteur à la consommation électrique actuelle de votre maison**, un **capteur à la puissance max** qu'il ne faut pas dépasser, la **consommation électrique de votre chauffage** et l'algorithme ne démarrera pas un radiateur si la puissance maximale sera dépassée après le démarrage du radiateur.
Cette fonction vous permet de réguler la consommation électrique de vos radiateurs. Connue sous le nom de délestage, cette fonction vous permet de limiter la consommation électrique de votre appareil de chauffage si des conditions de surpuissance sont détectées. Donnez un **capteur à la consommation électrique actuelle de votre maison**, un **capteur à la puissance max** qu'il ne faut pas dépasser, la **consommation électrique de votre chauffage** (en étape 1 de la configuration) et l'algorithme ne démarrera pas un radiateur si la puissance maximale sera dépassée après le démarrage du radiateur.
Notez que toutes les valeurs de puissance doivent avoir les mêmes unités (kW ou W par exemple).
Cela vous permet de modifier la puissance maximale au fil du temps à l'aide d'un planificateur ou de ce que vous voulez.
@@ -243,19 +284,23 @@ Le formulaire de configuration avancée est le suivant :
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-advanced.png?raw=true)
Le premier délai (minimal_activation_delay_sec) en sec dans le délai minimum acceptable pour allumer le chauffage. Lorsque le calcul donne un délai de mise sous tension inférieur à cette valeur, le chauffage reste éteint.
Le premier délai (minimal_activation_delay_sec) en secondes est le délai minimum acceptable pour allumer le chauffage. Lorsque le calcul donne un délai de mise sous tension inférieur à cette valeur, le chauffage reste éteint.
Le deuxième délai (security_delay_min) est le délai maximal entre deux mesures de température avant de régler le préréglage sur ``security`` et d'éteindre le thermostat. Si le capteur de température ne donne plus de mesures de température, le thermostat et le radiateur s'éteindront après ce délai et le préréglage du thermostat sera réglé sur ``security``. Ceci est utile pour éviter une surchauffe si la batterie de votre capteur de température est trop faible.
Le deuxième délai (``security_delay_min``) est le délai maximal entre deux mesures de température avant de régler le préréglage sur ``security``. Si le capteur de température ne donne plus de mesures de température, le thermostat et le radiateur passeront en mode ``security`` après ce délai. Ceci est utile pour éviter une surchauffe si la batterie de votre capteur de température est trop faible.
Le troisième paramétre (security_min_on_percent) est la valeur minimal de on_percent en dessous de laquelle le préréglage sécurité ne sera pas activé.
Mettre ce paramètre à ``0.00`` déclenchera le préréglage sécurité quelque soit la dernière consigne de chauffage, à l'inverse ``1.00`` ne déclenchera jamais le préréglage sécurité.
Le troisième paramétre (``security_min_on_percent``) est la valeur minimal de ``on_percent`` en dessous de laquelle le préréglage sécurité ne sera pas activé. Ce paramètre permet de ne pas mettre en sécurité un thermostat, si le radiateur piloté ne chauffe pas suffisament.
Mettre ce paramètre à ``0.00`` déclenchera le préréglage sécurité quelque soit la dernière consigne de chauffage, à l'inverse ``1.00`` ne déclenchera jamais le préréglage sécurité ( ce qui revient à désactiver la fonction).
Le quatrième param§tre (``security_default_on_percent``) est la valeur de ``on_percent`` qui sera utilisée lorsque le thermostat passe en mode ``security``. Si vous mettez ``0`` alors le thermostat sera coupé lorsqu'il passe en mode ``security``, mettre 0,2% par exemple permet de garder un peu de chauffage (20% dans ce cas), même en mode ``security``. Ca évite de retrouver son logement totalement gelé lors d'une panne de thermomètre.
Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglage communs
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. Le préréglage ``security`` est un préréglage caché. Vous ne pouvez pas le sélectionner manuellement ou par le service prédéfini,
2. Lorsque le capteur de température viendra à vivre et renverra les températures, le préréglage sera restauré à sa valeur précédente,
3. Attention, deux températures sont nécessaires : la température interne et la température externe et chacune doit donner la température, sinon le thermostat sera en préréglage "security".
1. Lorsque le capteur de température viendra à la vie et renverra les températures, le préréglage sera restauré à sa valeur précédente,
3. Attention, deux températures sont nécessaires : la température interne et la température externe et chacune doit donner la température, sinon le thermostat sera en préréglage "security",
4. Un service est disponible qui permet de régler les 3 paramètres de sécurité. Ca peut servir à adapter la fonction de sécurité à votre usage,
5. Pour un usage naturel, le ``security_default_on_percent`` doit être inférieur à ``security_min_on_percent``,
6. Lorsqu'un thermostat de type ``thermostat_over_climate`` passe en mode ``security`` il est éteint. Les paramètres ``security_min_on_percent`` et ``security_default_on_percent`` ne sont alors pas utilisés.
# Exemples de réglage
@@ -267,22 +312,36 @@ Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglag
- cycle : entre 30 et 60 min,
- minimal_activation_delay_sec : 300 secondes (à cause du temps de réponse)
## Le capteur de température sera alimenté par batterie
## Le capteur de température alimenté par batterie
- security_delay_min : 60 min (parce que ces capteurs sont paresseux)
- security_min_on_percent : 0,5 (50% - on passe en preset ``security`` si le radiateur chauffait plus de 50% du temps)
- security_default_on_percent : 0,1 (10% - en preset ``security``, on garde un fond de chauffe de 20% du temps)
## Capteur de température réactif
Il faut comprendre ces réglages comme suit :
> Si le thermomètre n'envoie plus la température pendant 1 heure et que le pourcentage de chauffe (``on_percent``) était supérieur à 50 %, alors on ramène ce pourcentage de chauffe à 10 %.
A vous d'adapter ces réglages à votre cas !
Ce qui est important c'est de ne pas prendre trop de risque avec ces paramètres : supposez que vous êtes absent pour une longue période, que les piles de votre thermomètre arrivent en fin de vie, votre radiateur va chauffer 10% du temps pendant toute la durée de la panne.
Versatile Thermostat vous permet d'être notifié lorsqu'un évènement de ce type survient. Mettez en place, les alertes qui vont bien dès l'utilisation de ce thermostat. Cf. (#notifications)
## Capteur de température réactif (sur secteur)
- security_delay_min : 15 min
- security_min_on_percent : 0,7 (70% - on passe en preset ``security`` si le radiateur chauffait plus de 70% du temps)
- security_default_on_percent : 0,25 (25% - en preset ``security``, on garde un fond de chauffe de 25% du temps)
## Ma configuration prédéfinie
## Mes presets
Ceci est juste un exemple de la façon dont j'utilise le préréglage. A vous de vous adapter à votre configuration mais cela peut être utile pour comprendre son fonctionnement.
``Éco`` : 17
``Confort`` : 19
``Boost`` : 20
``Éco`` : 17 °C
``Confort`` : 19 °C
``Boost`` : 20 °C
Lorsque la présence est désactivée :
``Éco`` : 16,5
``Confort`` : 17
``Boost`` : 18
``Éco`` : 16,5 °C
``Confort`` : 17 °C
``Boost`` : 18 °C
Le détecteur de mouvement de mon bureau est configuré pour utiliser ``Boost`` lorsqu'un mouvement est détecté et ``Eco`` sinon.
@@ -302,13 +361,50 @@ Le pourcentage est calculé avec cette formule :
Les valeurs par défaut pour coef_int et coef_ext sont respectivement : ``0.6`` et ``0.01``. Ces valeurs par défaut conviennent à une pièce standard bien isolée.
Pour régler ces coefficients, gardez à l'esprit que :
1. **si la température cible n'est pas atteinte** après une situation stable, vous devez augmenter le ``coef_ext`` (le ``on_percent`` est trop élevé),
2. **si la température cible est dépassée** après une situation stable, vous devez diminuer le ``coef_ext`` (le ``on_percent`` est trop bas),
1. **si la température cible n'est pas atteinte** après une situation stable, vous devez augmenter le ``coef_ext`` (le ``on_percent`` est trop bas),
2. **si la température cible est dépassée** après une situation stable, vous devez diminuer le ``coef_ext`` (le ``on_percent`` est trop haut),
3. **si l'atteinte de la température cible est trop lente**, vous pouvez augmenter le ``coef_int`` pour donner plus de puissance au réchauffeur,
4. **si l'atteinte de la température cible est trop rapide et que des oscillations apparaissent** autour de la cible, vous pouvez diminuer le ``coef_int`` pour donner moins de puissance au radiateur
Voir quelques situations à [examples](#some-results).
# Capteurs
Avec le thermostat sont disponibles des capteurs qui permettent de visualiser les alertes et l'état interne du thermostat. Ils sont disponibles dans les entités de l'appareil associé au thermostat :
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/thermostat-sensors.png?raw=true)
Dans l'ordre, il y a :
1. l'entité principale climate de commande du thermostat,
2. l'énergie consommée par le thermostat (valeur qui s'incrémente en permanence),
3. l'heure de réception de la dernière température extérieure,
4. l'heure de réception de la dernière température intérieure,
5. la puissance moyenne de l'appareil sur le cycle (pour les TPI seulement),
6. le temps passé à l'état éteint dans le cycle (TPI seulement),
7. le temps passé à l'état allumé dans le cycle (TPI seulement),
8. l'état de délestage,
9. le pourcentage de puissance sur le cycle (TPI seulement),
10. l'état de présence (si la gestion de la présence est configurée),
11. l'état de sécurité,
12. l'état de l'ouverture (si la gestion des ouvertures est configurée),
13. l'état du mouvement (si la gestion du mouvements est configurée)
Pour colorer les capteurs, ajouter ces lignes et personnalisez les au besoin, dans votre configuration.yaml :
```
frontend:
themes:
versatile_thermostat_theme:
state-binary_sensor-safety-on-color: "#FF0B0B"
state-binary_sensor-power-on-color: "#FF0B0B"
state-binary_sensor-window-on-color: "rgb(156, 39, 176)"
state-binary_sensor-motion-on-color: "rgb(156, 39, 176)"
state-binary_sensor-presence-on-color: "lightgreen"
```
et choisissez le thème ```versatile_thermostat_theme``` dans la configuration du panel. Vous obtiendrez quelque-chose qui va ressembler à ça :
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/colored-thermostat-sensors.png?raw=true)
# Services
Cette implémentation personnalisée offre des services spécifiques pour faciliter l'intégration avec d'autres composants Home Assistant.
@@ -335,7 +431,7 @@ Utilisez le code suivant pour régler la température du préréglage :
```
service : thermostat_polyvalent.set_preset_temperature
date:
prest : boost
preset : boost
temperature : 17,8
temperature_away : 15
target:
@@ -345,6 +441,40 @@ target:
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
- après un redémarrage, les préréglages sont réinitialisés à la température configurée. Si vous souhaitez que votre changement soit permanent, vous devez modifier le préréglage de la température dans la configuration de l'intégration.
## Modifier les paramètres de sécurité
Ce service permet de modifier dynamiquement les paramètres de sécurité décrits ici [Configuration avancée](#configuration-avancée).
Si le thermostat est en mode ``security`` les nouveaux paramètres sont appliqués immédiatement.
Pour changer les paramètres de sécurité utilisez le code suivant :
```
service : thermostat_polyvalent.set_security
date:
min_on_percent: "0.5"
default_on_percent: "0.1"
delay_min: 60
target:
entity_id : climate.my_thermostat
```
# Notifications
Les évènements marquant du thermostat sont notifiés par l'intermédiaire du bus de message.
Les évènements notifiés sont les suivants:
- ``versatile_thermostat_security_event`` : un thermostat entre ou sort du preset ``security``
- ``versatile_thermostat_power_event`` : un thermostat entre ou sort du preset ``power``
- ``versatile_thermostat_temperature_event`` : une ou les deux mesures de température d'un thermostat n'ont pas été mis à jour depuis plus de `security_delay_min`` minutes
- ``versatile_thermostat_hvac_mode_event`` : le thermostat est allumé ou éteint. Cet évènement est aussi diffusé au démarrage du thermostat
- ``versatile_thermostat_preset_event`` : un nouveau preset est sélectionné sur le thermostat. Cet évènement est aussi diffusé au démarrage du thermostat
Si vous avez bien suivi, lorsqu'un thermostat passe en mode sécurité, 3 évènements sont déclenchés :
1. ``versatile_thermostat_temperature_event`` pour indiquer qu'un thermomètre ne répond plus,
2. ``versatile_thermostat_preset_event`` pour indiquer le passage en preset ```security```,
3. ``versatile_thermostat_hvac_mode_event`` pour indiquer l'extinction éventuelle du thermostat
Chaque évènement porte les valeurs clés de l'évènement (températures, preset courant, puissance courante, ...) ainsi que les états du thermostat.
Vous pouvez très facilement capter ses évènements dans une automatisation par exemple pour notifier les utilisateurs.
# Attributs personnalisés
Pour régler l'algorithme, vous avez accès à tout le contexte vu et calculé par le thermostat via des attributs dédiés. Vous pouvez voir (et utiliser) ces attributs dans l'IHM "Outils de développement / états" de HA. Entrez votre thermostat et vous verrez quelque chose comme ceci :
@@ -365,24 +495,25 @@ Les attributs personnalisés sont les suivants :
| ``[eco/confort/boost]_temp`` | La température configurée pour le préréglage xxx |
| ``[eco/confort/boost]_away_temp`` | La température configurée pour le préréglage xxx lorsque la présence est désactivée ou not_home |
| ``temp_power`` | La température utilisée lors de la détection de la perte |
| ``on_percent`` | Le pourcentage sur calculé par l'algorithme TPI |
| ``on_time_sec`` | La période On en sec. Doit être ```on_percent * cycle_min``` |
| ``off_time_sec`` | La période d'arrêt en sec. Doit être ```(1 - on_percent) * cycle_min``` |
| ``on_percent`` | (déprécié) Le pourcentage sur calculé par l'algorithme TPI |
| ``on_time_sec`` | (déprécié) La période On en sec. Doit être ```on_percent * cycle_min``` |
| ``off_time_sec`` | (déprécié) La période d'arrêt en sec. Doit être ```(1 - on_percent) * cycle_min``` |
| ``cycle_min`` | Le cycle de calcul en minutes |
| ``function`` | L'algorithme utilisé pour le calcul du cycle |
| ``tpi_coef_int`` | Le ``coef_int`` de l'algorithme TPI |
| ``tpi_coef_ext`` | Le ``coef_ext`` de l'algorithme TPI |
| ``saved_preset_mode`` | Le dernier preset utilisé avant le basculement automatique du preset |
| ``saved_target_temp`` | La dernière température utilisée avant la commutation automatique |
| ``window_state`` | Le dernier état connu du capteur de fenêtre. Aucun si la fenêtre n'est pas configurée |
| ``motion_state`` | Le dernier état connu du capteur de mouvement. Aucun si le mouvement n'est pas configuré |
| ``overpowering_state`` | Le dernier état connu du capteur surpuissant. Aucun si la gestion de l'alimentation n'est pas configurée |
| ``presence_state`` | Le dernier état connu du capteur de présence. Aucun si la gestion de présence n'est pas configurée |
| ``window_state`` | (déprécié) Le dernier état connu du capteur de fenêtre. Aucun si la fenêtre n'est pas configurée |
| ``motion_state`` | (déprécié) Le dernier état connu du capteur de mouvement. Aucun si le mouvement n'est pas configuré |
| ``overpowering_state`` | (déprécié) Le dernier état connu du capteur surpuissant. Aucun si la gestion de l'alimentation n'est pas configurée |
| ``presence_state`` | (déprécié) Le dernier état connu du capteur de présence. Aucun si la gestion de présence n'est pas configurée |
| ``security_delay_min`` | Le délai avant de régler le mode de sécurité lorsque le capteur de température est éteint |
| ``security_min_on_percent`` | Seuil en dessous duquel le thermostat ne passera pas en sécurité |
| ``last_temperature_datetime`` | La date et l'heure au format ISO8866 de la dernière réception de température interne |
| ``last_ext_temperature_datetime`` | La date et l'heure au format ISO8866 de la dernière réception de température extérieure |
| ``**état_sécurité**`` | L'état de sécurité. vrai ou faux |
| ``security_min_on_percent`` | Pourcentage de chauffe en dessous duquel le thermostat ne passera pas en sécurité |
| ``security_default_on_percent`` | Pourcentage de chauffe utilisé lorsque le thermostat est en sécurité |
| ``last_temperature_datetime`` | (déprécié) La date et l'heure au format ISO8866 de la dernière réception de température interne |
| ``last_ext_temperature_datetime`` | (déprécié) La date et l'heure au format ISO8866 de la dernière réception de température extérieure |
| ``security_state`` | (déprécié) L'état de sécurité. vrai ou faux |
| ``minimal_activation_delay_sec`` | Le délai d'activation minimal en secondes |
| ``last_update_datetime`` | La date et l'heure au format ISO8866 de cet état |
| ``friendly_name`` | Le nom du thermostat |
@@ -468,6 +599,24 @@ Exemple de configuration :
name: Porte sam
```
Vous pouvez personnaliser ce composant à l'aide du composant HACS card-mod pour ajuster les couleurs des alertes. Exemple pour afficher en rouge les alertes sécurité et délestage :
```
card_mod:
style: |
{% if is_state('binary_sensor.thermostat_chambre_security_state', 'on') %}
ha-card .body .sensor-heading ha-icon[icon="mdi:alert-outline"] {
color: red;
}
{% endif %}
{% if is_state('binary_sensor.thermostat_chambre_overpowering_state', 'on') %}
ha-card .body .sensor-heading ha-icon[icon="mdi:flash"] {
color: red;
}
{% endif %}
```
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/custom-css-thermostat.png?raw=true)
## Toujours mieux avec Apex-chart pour régler votre thermostat
Vous pouvez obtenir une courbe comme celle présentée dans [some results](#some-results) avec une sorte de configuration de graphique Apex uniquement en utilisant les attributs personnalisés du thermostat décrits [ici](#custom-attributes) :
@@ -507,14 +656,103 @@ series:
yaxis_id: right
```
## Et toujours de mieux en mieux avec l'AappDaemon NOTIFIER pour notifier les évènements
Cette automatisation utilise l'excellente App Daemon nommée NOTIFIER développée par Horizon Domotique que vous trouverez en démonstration [ici](https://www.youtube.com/watch?v=chJylIK0ASo&ab_channel=HorizonDomotique) et le code est [ici](https://github.com/jlpouffier/home-assistant-config/blob/master/appdaemon/apps/notifier.py). Elle permet de notifier les utilisateurs du logement lorsqu'un des évènements touchant à la sécurité survient sur un des Versatile Thermostats.
C'est un excellent exemple de l'utilisation des notifications décrites ici [notification](#notifications).
```
alias: Surveillance Mode Sécurité chauffage
description: Envoi une notification si un thermostat passe en mode sécurité ou power
trigger:
- platform: event
event_type: versatile_thermostat_security_event
id: versatile_thermostat_security_event
- platform: event
event_type: versatile_thermostat_power_event
id: versatile_thermostat_power_event
- platform: event
event_type: versatile_thermostat_temperature_event
id: versatile_thermostat_temperature_event
condition: []
action:
- choose:
- conditions:
- condition: trigger
id: versatile_thermostat_security_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Radiateur {{ trigger.event.data.name }} - {{
trigger.event.data.type }} Sécurité
message: >-
Le radiateur {{ trigger.event.data.name }} est passé en {{
trigger.event.data.type }} sécurité car le thermomètre ne répond
plus.\n{{ trigger.event.data }}
callback:
- title: Stopper chauffage
event: stopper_chauffage
image_url: /media/local/alerte-securite.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-off
tag: radiateur_security_alerte
persistent: true
- conditions:
- condition: trigger
id: versatile_thermostat_power_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Radiateur {{ trigger.event.data.name }} - {{
trigger.event.data.type }} Délestage
message: >-
Le radiateur {{ trigger.event.data.name }} est passé en {{
trigger.event.data.type }} délestage car la puissance max est
dépassée.\n{{ trigger.event.data }}
callback:
- title: Stopper chauffage
event: stopper_chauffage
image_url: /media/local/alerte-delestage.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-off
tag: radiateur_power_alerte
persistent: true
- conditions:
- condition: trigger
id: versatile_thermostat_temperature_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Le thermomètre du radiateur {{ trigger.event.data.name }} ne
répond plus
message: >-
Le thermomètre du radiateur {{ trigger.event.data.name }} ne
répond plus depuis longtemps.\n{{ trigger.event.data }}
image_url: /media/local/thermometre-alerte.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-disabled
tag: radiateur_thermometre_alerte
persistent: true
mode: queued
max: 30
```
# Les contributions sont les bienvenues !
Si vous souhaitez contribuer, veuillez lire les [directives de contribution](CONTRIBUTING.md)
***
[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

322
README.md
View File

@@ -2,6 +2,7 @@
[![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)
@@ -18,6 +19,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)
@@ -26,25 +29,35 @@
- [Electrical heater](#electrical-heater)
- [Central heating (gaz or fuel heating system)](#central-heating-gaz-or-fuel-heating-system)
- [Temperature sensor will battery](#temperature-sensor-will-battery)
- [Reponsive temperature sensor](#reponsive-temperature-sensor)
- [Reactive temperature sensor (on mains)](#reactive-temperature-sensor-on-mains)
- [My preset configuration](#my-preset-configuration)
- [Algorithm](#algorithm)
- [TPI algorithm](#tpi-algorithm)
- [Sensors](#sensors)
- [Services](#services)
- [Force the presence / occupancy](#force-the-presence--occupancy)
- [Change the temperature of presets](#change-the-temperature-of-presets)
- [Change security settings](#change-security-settings)
- [Notifications](#notifications)
- [Custom attributes](#custom-attributes)
- [Some results](#some-results)
- [Even better](#even-better)
- [Even Better with Scheduler Component !](#even-better-with-scheduler-component-)
- [Even-even better with custom:simple-thermostat front integration](#even-even-better-with-customsimple-thermostat-front-integration)
- [Even better with Apex-chart to tune your Thermostat](#even-better-with-apex-chart-to-tune-your-thermostat)
- [And always better and better with the NOTIFIER daemon app to notify events](#and-always-better-and-better-with-the-notifier-daemon-app-to-notify-events)
- [Contributions are welcome!](#contributions-are-welcome)
_Component developed by using the amazing development template [[blueprint](https://github.com/custom-components/integration_blueprint)]._
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.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.
# 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:
@@ -60,7 +73,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**,
@@ -70,7 +83,8 @@ This component named __Versatile thermostat__ manage the following use cases :
- Use a **TPI (Time Proportional Interval) algorithm** thank's to [[Argonaute](https://forum.hacf.fr/u/argonaute/summary)] algorithm ,
- Add **power shedding management** or regulation to avoid exceeding a defined total power. When max power is exceeded, a hidden 'power' preset is set on the climate entity. When power goes below the max, the previous preset is restored.
- Add **home presence management**. This feature allows you to dynamically change the temperature of preset considering a occupancy sensor of your home.
- Add **services to interact with the thermostat** from others integration: you can force the presence / un-presence using a service, and you can dynamically change the temperature of the presets.
- Add **services to interact with the thermostat** from others integration: you can force the presence / un-presence using a service, and you can dynamically change the temperature of the presets and change dynamically the security parameters.
- Add sensors to see the internal states of the thermostat
# How to install this incredible Versatile Thermostat ?
@@ -114,7 +128,8 @@ Give the main mandatory attributes:
5. a temperature sensor entity giving the outside temperature. If you don't have an external sensor, you can use local weather integration
6. a cycle duration in minutes. On each cycle, the heater will cycle on and then off for a calculated time to reach the target temperature (see [preset](#configure-the-preset-temperature) below),
7. minimum and maximum thermostat temperatures,
8. the list of features that will be used for this thermostat. Depending on your choices, the following configuration screens will appear or not.
8. the power of the l'équipement which will activate the power and energy sensors of the device,
9. the list of features that will be used for this thermostat. Depending on your choices, the following configuration screens will appear or not.
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. With the ```thermostat_over_switch``` type, calculation are done at each cycle. So in case of conditions change, you will have to wait for the next cycle to see a change. For this reason, the cycle should not be too long. **5 min is a good value**,
@@ -155,19 +170,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)
@@ -195,7 +235,7 @@ For this to work, the climate thermostat should be in ``Activity`` preset mode.
If you choose the ```Power 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-power.png?raw=true)
This feature allows you to regulate the power consumption of your radiators. Known as shedding, this feature allows you to limit the electrical power consumption of your heater if overpowering conditions are detected. Give a **sensor to the current power consumption of your house**, a **sensor to the max power** that should not be exceeded, the **power consumption of your heater** and the algorithm will not start a radiator if the max power will be exceeded after radiator starts.
This feature allows you to regulate the power consumption of your radiators. Known as shedding, this feature allows you to limit the electrical power consumption of your heater if overpowering conditions are detected. Give a **sensor to the current power consumption of your house**, a **sensor to the max power** that should not be exceeded, the **power consumption of your heater** (in the first step of the configuration) and the algorithm will not start a radiator if the max power will be exceeded after radiator starts.
Note that all power values should have the same units (kW or W for example).
@@ -233,14 +273,19 @@ The first delay (minimal_activation_delay_sec) in sec in the minimum delay accep
The second delay (security_delay_min) is the maximal delay between two temperature measure before setting the preset to ``security`` and turning off the thermostat. If the temperature sensor is no more giving temperature measures, the thermostat and heater will turns off after this delay and the preset of the thermostat will be set to ``security``. This is useful to avoid overheating is the battery of your temperature sensor is too low.
The third parameter (security_min_on_percent) is the minimal on_percent value below which the security preset won't be trigger. If you set it to ``0.00`` security preset will be trigger regardeless of the heating on_percent when there is a temperature loss, at the opposite ``1.00`` will never trigger the security preset.
The third parameter (``security_min_on_percent``) is the minimum value of ``on_percent`` below which the security preset will not be activated. This parameter makes it possible not to put a thermostat in safety, if the controlled radiator does not heat sufficiently.
Setting this parameter to ``0.00`` will trigger the security preset regardless of the last heating setpoint, conversely ``1.00`` will never trigger the security preset (which amounts to disabling the function).
See [exemple tuning](#examples-tuning) to have some commons tuning examples
The fourth parameter (``security_default_on_percent``) is the ``on_percent`` value that will be used when the thermostat enters ``security`` mode. If you put ``0`` then the thermostat will be cut off when it goes into ``security`` mode, putting 0.2% for example allows you to keep a little heating (20% in this case), even in mode ``security``. It avoids finding your home totally frozen during a thermometer failure.
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. The ``security`` preset is a hidden preset. You cannot select it manually or by the preset service,
2. When the temperature sensor will comes to live and re-send temperatures, the preset will be restored to its previous value,
3. Beware that two temperatures are needed: internal temp and external temp and each should give temperature else the thermostat will be in ``security`` preset.
See [example tuning](#examples-tuning) for common tuning examples
>![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. When the temperature sensor comes to life and returns the temperatures, the preset will be restored to its previous value,
3. Attention, two temperatures are needed: internal temperature and external temperature and each must give the temperature, otherwise the thermostat will be in "security" preset,
4. A service is available that allows you to set the 3 security parameters. This can be used to adapt the security function to your use.
5. For natural usage, the ``security_default_on_percent`` should be less than ``security_min_on_percent``,
6. When a ``thermostat_over_climate`` type thermostat goes into ``security`` mode it is turned off. The ``security_min_on_percent`` and ``security_default_on_percent`` parameters are then not used.
# Examples tuning
@@ -253,21 +298,35 @@ See [exemple tuning](#examples-tuning) to have some commons tuning examples
- minimal_activation_delay_sec: 300 seconds (because of the response time)
## Temperature sensor will battery
- security_delay_min: 60 min (because those sensors are leazy)
- security_delay_min: 60 min (because these sensors are lazy)
- security_min_on_percent: 0.5 (50% - we go to the ``security`` preset if the radiator was heating more than 50% of the time)
- security_default_on_percent: 0.1 (10% - in preset ``security``, we keep a heating background 20% of the time)
## Reponsive temperature sensor
- security_delay_min: 15 min
These settings should be understood as follows:
> If the thermometer no longer sends the temperature for 1 hour and the heating percentage (``on_percent``) was greater than 50%, then this heating percentage is reduced to 10%.
It's up to you to adapt these settings to your case!
What is important is not to take too many risks with these parameters: suppose you are away for a long period, that the batteries of your thermometer reach the end of their life, your radiator will heat up 10% of the time for the whole the duration of the outage.
Versatile Thermostat allows you to be notified when an event of this type occurs. Set up the alerts that go well as soon as you use this thermostat. See (#notifications)
## Reactive temperature sensor (on mains)
- security_delay_min: 15min
- security_min_on_percent: 0.7 (70% - we go to the ``security`` preset if the radiator was heating more than 70% of the time)
- security_default_on_percent: 0.25 (25% - in preset ``security``, we keep a heating background 25% of the time)
## My preset configuration
This is just an example of how I use the preset. It up to you to adapt to your configuration but it can be useful to understand how it works.
``Eco``: 17
``Comfort``: 19
``Boost``: 20
``Eco``: 17 °C
``Comfort``: 19 °C
``Boost``: 20 °C
When presence if off:
``Eco``: 16.5
``Comfort``: 17
``Boost``: 18
``Eco``: 16.5 °C
``Comfort``: 17 °C
``Boost``: 18 °C
Motion detector in my office is set to use ``Boost`` when motion is detected and ``Eco`` if not.
@@ -287,13 +346,50 @@ The percentage is calculated with this formula:
Defaults values for coef_int and coef_ext are respectively: ``0.6`` and ``0.01``. Those defaults values are suitable for a standard well isolated room.
To tune those coefficients keep in mind that:
1. **if target temperature is not reach** after stable situation, you have to augment the ``coef_ext`` (the ``on_percent`` is too high),
2. **if target temperature is exceeded** after stable situation, you have to decrease the ``coef_ext`` (the ``on_percent`` is too low),
1. **if target temperature is not reach** after stable situation, you have to augment the ``coef_ext`` (the ``on_percent`` is too low),
2. **if target temperature is exceeded** after stable situation, you have to decrease the ``coef_ext`` (the ``on_percent`` is too high),
3. **if reaching the target temperature is too slow**, you can increase the ``coef_int`` to give more power to the heater,
4. **if reaching the target temperature is too fast and some oscillations appears** around the target, you can decrease the ``coef_int`` to give less power to the heater
See some situations at [examples](#some-results).
# Sensors
With the thermostat are available sensors that allow you to view the alerts and the internal status of the thermostat. They are available in the entities of the device associated with the thermostat:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/thermostat-sensors.png?raw=true)
In order, there are:
1. the main climate thermostat command entity,
2. the energy consumed by the thermostat (value which continuously increases),
3. the time of receipt of the last outside temperature,
4. the time of receipt of the last indoor temperature,
5. the average power of the device over the cycle (for TPIs only),
6. the time spent in the off state in the cycle (TPI only),
7. the time spent in the on state in the cycle (TPI only),
8. load shedding status,
9. cycle power percentage (TPI only),
10. presence status (if presence management is configured),
11. security status,
12. opening status (if opening management is configured),
13. motion status (if motion management is configured)
To color the sensors, add these lines and customize them as needed, in your configuration.yaml:
```
frontend:
themes:
versatile_thermostat_theme:
state-binary_sensor-safety-on-color: "#FF0B0B"
state-binary_sensor-power-on-color: "#FF0B0B"
state-binary_sensor-window-on-color: "rgb(156, 39, 176)"
state-binary_sensor-motion-on-color: "rgb(156, 39, 176)"
state-binary_sensor-presence-on-color: "lightgreen"
```
and choose the ```versatile_thermostat_theme``` theme in the panel configuration. You will get something that will look like this:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/colored-thermostat-sensors.png?raw=true)
# Services
This custom implementation offers some specific services to facilitate integration with others Home Assisstant components.
@@ -330,6 +426,40 @@ target:
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
- after a restart the preset are resetted to the configured temperature. If you want your change to be permanent you should modify the temperature preset into the confguration of the integration.
## Change security settings
This service is used to dynamically modify the security parameters described here [Advanced configuration](#configuration-avanced).
If the thermostat is in ``security`` mode the new settings are applied immediately.
To change the security settings use the following code:
```
service : thermostat_polyvalent.set_security
date:
min_on_percent: "0.5"
default_on_percent: "0.1"
delay_min: 60
target:
entity_id : climate.my_thermostat
```
# Notifications
Significant thermostat events are notified via the message bus.
The notified events are as follows:
- ``versatile_thermostat_security_event``: a thermostat enters or exits the ``security`` preset
- ``versatile_thermostat_power_event``: a thermostat enters or exits the ``power`` preset
- ``versatile_thermostat_temperature_event``: one or both temperature measurements of a thermostat have not been updated for more than ``security_delay_min`` minutes
- ``versatile_thermostat_hvac_mode_event``: the thermostat is on or off. This event is also broadcast when the thermostat starts up
- ``versatile_thermostat_preset_event``: a new preset is selected on the thermostat. This event is also broadcast when the thermostat starts up
If you have followed correctly, when a thermostat goes into safety mode, 3 events are triggered:
1. ``versatile_thermostat_temperature_event`` to indicate that a thermometer has become unresponsive,
2. ``versatile_thermostat_preset_event`` to indicate the switch to ```security``` preset,
3. ``versatile_thermostat_hvac_mode_event`` to indicate the possible extinction of the thermostat
Each event carries the key values of the event (temperatures, current preset, current power, etc.) as well as the states of the thermostat.
You can very easily capture its events in an automation, for example to notify users.
# Custom attributes
To tune the algorithm you have access to all context seen and calculted by the thermostat through dedicated attributes. You can see (and use) those attributes in the "Development tools / states" HMI of HA. Enter your thermostat and you will see something like this:
@@ -350,24 +480,25 @@ Custom attributes are the following:
| ``[eco/comfort/boost]_temp`` | The temperature configured for the preset xxx |
| ``[eco/comfort/boost]_away_temp`` | The temperature configured for the preset xxx when presence is off or not_home |
| ``power_temp`` | The temperature used when shedding is detected |
| ``on_percent`` | The percentage on calculated by the TPI algorithm |
| ``on_time_sec`` | The On period in sec. Should be ```on_percent * cycle_min``` |
| ``off_time_sec`` | The Off period in sec. Should be ```(1 - on_percent) * cycle_min``` |
| ``on_percent`` | (deprecated) The percentage on calculated by the TPI algorithm |
| ``on_time_sec`` | (deprecated) The On period in sec. Should be ```on_percent * cycle_min``` |
| ``off_time_sec`` | (deprecated) The Off period in sec. Should be ```(1 - on_percent) * cycle_min``` |
| ``cycle_min`` | The calculation cycle in minutes |
| ``function`` | The algorithm used for cycle calculation |
| ``tpi_coef_int`` | The ``coef_int`` of the TPI algorithm |
| ``tpi_coef_ext`` | The ``coef_ext`` of the TPI algorithm |
| ``saved_preset_mode`` | The last preset used before automatic switch of the preset |
| ``saved_target_temp`` | The last temperature used before automatic switching |
| ``window_state`` | The last known state of the window sensor. None if window is not configured |
| ``motion_state`` | The last known state of the motion sensor. None if motion is not configured |
| ``overpowering_state`` | The last known state of the overpowering sensor. None if power management is not configured |
| ``presence_state`` | The last known state of the presence sensor. None if presence management is not configured |
| ``window_state`` | (deprecated) The last known state of the window sensor. None if window is not configured |
| ``motion_state`` | (deprecated) The last known state of the motion sensor. None if motion is not configured |
| ``overpowering_state`` | (deprecated) The last known state of the overpowering sensor. None if power management is not configured |
| ``presence_state`` | (deprecated) The last known state of the presence sensor. None if presence management is not configured |
| ``security_delay_min`` | The delay before setting the security mode when temperature sensor are off |
| ``security_min_on_percent`` | The minimal on_percent below which security preset won't be trigger |
| ``last_temperature_datetime`` | The date and time in ISO8866 format of the last internal temperature reception |
| ``last_ext_temperature_datetime`` | The date and time in ISO8866 format of the last external temperature reception |
| ``security_state`` | The security state. true or false |
| ``security_default_on_percent`` | The on_percent used when thermostat is in ``security`` |
| ``last_temperature_datetime`` | (deprecated) The date and time in ISO8866 format of the last internal temperature reception |
| ``last_ext_temperature_datetime`` | (deprecated) The date and time in ISO8866 format of the last external temperature reception |
| ``security_state`` | (deprecated) The security state. true or false |
| ``minimal_activation_delay_sec`` | The minimal activation delay in seconds |
| ``last_update_datetime`` | The date and time in ISO8866 format of this state |
| ``friendly_name`` | The name of the thermostat |
@@ -452,6 +583,23 @@ Example configuration:
entity: input_boolean.etat_ouverture_porte_sam
name: Porte sam
```
You can customize this component using the HACS card-mod component to adjust the alert colors. Example for displaying safety and load shedding alerts in red:
```
card_mod:
style: |
{% if is_state('binary_sensor.thermostat_chambre_security_state', 'on') %}
ha-card .body .sensor-heading ha-icon[icon="mdi:alert-outline"] {
color: red;
}
{% endif %}
{% if is_state('binary_sensor.thermostat_chambre_overpowering_state', 'on') %}
ha-card .body .sensor-heading ha-icon[icon="mdi:flash"] {
color: red;
}
{% endif %}
```
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/custom-css-thermostat.png?raw=true)
## Even better with Apex-chart to tune your Thermostat
You can get curve like presented in [some results](#some-results) with kind of Apex-chart configuration only using the custom attributes of the thermostat described [here](#custom-attributes):
@@ -492,14 +640,102 @@ series:
yaxis_id: right
```
## And always better and better with the NOTIFIER daemon app to notify events
This automation uses the excellent App Daemon named NOTIFIER developed by Horizon Domotique that you will find in demonstration [here](https://www.youtube.com/watch?v=chJylIK0ASo&ab_channel=HorizonDomotique) and the code is [here](https ://github.com/jlpouffier/home-assistant-config/blob/master/appdaemon/apps/notifier.py). It allows you to notify the users of the accommodation when one of the events affecting safety occurs on one of the Versatile Thermostats.
This is a great example of using the notifications described here [notification](#notifications).
```
alias: Surveillance Mode Sécurité chauffage
description: Envoi une notification si un thermostat passe en mode sécurité ou power
trigger:
- platform: event
event_type: versatile_thermostat_security_event
id: versatile_thermostat_security_event
- platform: event
event_type: versatile_thermostat_power_event
id: versatile_thermostat_power_event
- platform: event
event_type: versatile_thermostat_temperature_event
id: versatile_thermostat_temperature_event
condition: []
action:
- choose:
- conditions:
- condition: trigger
id: versatile_thermostat_security_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Radiateur {{ trigger.event.data.name }} - {{
trigger.event.data.type }} Sécurité
message: >-
Le radiateur {{ trigger.event.data.name }} est passé en {{
trigger.event.data.type }} sécurité car le thermomètre ne répond
plus.\n{{ trigger.event.data }}
callback:
- title: Stopper chauffage
event: stopper_chauffage
image_url: /media/local/alerte-securite.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-off
tag: radiateur_security_alerte
persistent: true
- conditions:
- condition: trigger
id: versatile_thermostat_power_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Radiateur {{ trigger.event.data.name }} - {{
trigger.event.data.type }} Délestage
message: >-
Le radiateur {{ trigger.event.data.name }} est passé en {{
trigger.event.data.type }} délestage car la puissance max est
dépassée.\n{{ trigger.event.data }}
callback:
- title: Stopper chauffage
event: stopper_chauffage
image_url: /media/local/alerte-delestage.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-off
tag: radiateur_power_alerte
persistent: true
- conditions:
- condition: trigger
id: versatile_thermostat_temperature_event
sequence:
- event: NOTIFIER
event_data:
action: send_to_jmc
title: >-
Le thermomètre du radiateur {{ trigger.event.data.name }} ne
répond plus
message: >-
Le thermomètre du radiateur {{ trigger.event.data.name }} ne
répond plus depuis longtemps.\n{{ trigger.event.data }}
image_url: /media/local/thermometre-alerte.jpg
click_url: /lovelace-chauffage/4
icon: mdi:radiator-disabled
tag: radiateur_thermometre_alerte
persistent: true
mode: queued
max: 30
```
# Contributions are welcome!
If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md)
***
[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

@@ -6,17 +6,14 @@ from typing import Dict
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .climate import VersatileThermostat
from .const import DOMAIN
from .const import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Versatile Thermostat from a config entry."""
@@ -58,13 +55,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
class VersatileThermostatAPI(Dict):
class VersatileThermostatAPI(dict):
"""The VersatileThermostatAPI"""
_hass: HomeAssistant
# _entries: Dict(str, ConfigEntry)
def __init__(self, hass):
def __init__(self, hass: HomeAssistant) -> None:
_LOGGER.debug("building a VersatileThermostatAPI")
super().__init__()
self._hass = hass
@@ -96,14 +93,13 @@ class VersatileThermostatAPI(Dict):
# Example migration function
async def async_migrate_entry(hass, config_entry: ConfigEntry):
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {**config_entry.data}
# TODO: modify Config Entry data
# TO DO: modify Config Entry data if there will be something to migrate
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)

View File

@@ -0,0 +1,221 @@
""" Implements the VersatileThermostat binary sensors component """
import logging
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import STATE_ON
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorDeviceClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .commons import VersatileThermostatBaseEntity
from .const import (
CONF_NAME,
CONF_USE_POWER_FEATURE,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_WINDOW_FEATURE,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat binary sensors with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
entities = [SecurityBinarySensor(hass, unique_id, name, entry.data)]
if entry.data.get(CONF_USE_MOTION_FEATURE):
entities.append(MotionBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_WINDOW_FEATURE):
entities.append(WindowBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_PRESENCE_FEATURE):
entities.append(PresenceBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_POWER_FEATURE):
entities.append(OverpoweringBinarySensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the security state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the SecurityState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Security state"
self._attr_unique_id = f"{self._device_name}_security_state"
self._attr_is_on = False
@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)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.security_state is True
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.SAFETY
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:shield-alert"
else:
return "mdi:shield-check-outline"
class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the overpowering state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the OverpoweringState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Overpowering state"
self._attr_unique_id = f"{self._device_name}_overpowering_state"
self._attr_is_on = False
@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)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.overpowering_state is True
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.POWER
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:flash-alert-outline"
else:
return "mdi:flash-outline"
class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the window state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the WindowState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Window state"
self._attr_unique_id = f"{self._device_name}_window_state"
self._attr_is_on = False
@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)
old_state = self._attr_is_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
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.WINDOW
@property
def icon(self) -> str | None:
if self._attr_is_on:
if self.my_climate.window_state == STATE_ON:
return "mdi:window-open-variant"
else:
return "mdi:window-open"
else:
return "mdi:window-closed-variant"
class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the motion state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the MotionState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Motion state"
self._attr_unique_id = f"{self._device_name}_motion_state"
self._attr_is_on = False
@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)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.MOTION
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:motion-sensor"
else:
return "mdi:motion-sensor-off"
class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the presence state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the PresenceState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Presence state"
self._attr_unique_id = f"{self._device_name}_presence_state"
self._attr_is_on = False
@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)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.PRESENCE
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:home-account"
else:
return "mdi:nature-people"

View File

@@ -8,19 +8,24 @@ from datetime import timedelta, datetime
import voluptuous as vol
from homeassistant.util import dt as dt_util
from homeassistant.core import (
HomeAssistant,
callback,
CoreState,
DOMAIN as HA_DOMAIN,
Event,
State,
)
from homeassistant.components.climate import ClimateEntity
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_component import EntityComponent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.event import (
async_track_state_change_event,
@@ -88,7 +93,9 @@ from homeassistant.const import (
)
from .const import (
# DOMAIN,
DOMAIN,
PLATFORMS,
DEVICE_MANUFACTURER,
CONF_HEATER,
CONF_POWER_SENSOR,
CONF_TEMP_SENSOR,
@@ -96,6 +103,9 @@ from .const import (
CONF_MAX_POWER_SENSOR,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
@@ -132,9 +142,12 @@ from .const import (
CONF_CLIMATE,
UnknownEntity,
EventType,
ATTR_MEAN_POWER_CYCLE,
ATTR_TOTAL_ENERGY,
)
from .prop_algorithm import PropAlgorithm
from .open_window_algorithm import WindowOpenDetectionAlgorithm
_LOGGER = logging.getLogger(__name__)
@@ -149,6 +162,8 @@ async def async_setup_entry(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
@@ -197,6 +212,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# The list of VersatileThermostat entities
# No more needed
# _registry: dict[str, object] = {}
_last_temperature_mesure: datetime
_last_ext_temperature_mesure: datetime
_total_energy: float
_overpowering_state: bool
_window_state: bool
_motion_state: bool
_presence_state: bool
_security_state: bool
_window_auto_state: bool
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
@@ -247,8 +271,34 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._attr_translation_key = "versatile_thermostat"
self._total_energy = None
self._underlying_climate_start_hvac_action_date = None
self._underlying_climate_delta_t = 0
self._window_sensor_entity_id = None
self._window_delay_sec = None
self._window_auto_open_threshold = 0
self._window_auto_close_threshold = 0
self._window_auto_max_duration = 0
self._window_auto_state = False
self._window_auto_on = False
self._window_auto_algo = None
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
self.post_init(entry_infos)
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._unique_id)},
name=self._name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
def post_init(self, entry_infos):
"""Finish the initialization of the thermostast"""
@@ -304,6 +354,27 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR)
self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY)
self._window_auto_open_threshold = entry_infos.get(
CONF_WINDOW_AUTO_OPEN_THRESHOLD
)
self._window_auto_close_threshold = entry_infos.get(
CONF_WINDOW_AUTO_CLOSE_THRESHOLD
)
self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION)
self._window_auto_on = (
self._window_auto_open_threshold is not None
and self._window_auto_open_threshold > 0.0
and self._window_auto_close_threshold is not None
and self._window_auto_max_duration is not None
and self._window_auto_max_duration > 0
)
self._window_auto_state = False
self._window_auto_algo = WindowOpenDetectionAlgorithm(
alert_threshold=self._window_auto_open_threshold,
end_alert_threshold=self._window_auto_close_threshold,
)
self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR)
self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY)
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
@@ -321,7 +392,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._presence_on = self._presence_sensor_entity_id is not None
# TODO if self.ac_mode:
# if self.ac_mode: -> MODE_COOL should be better to use thermostat_over_climate type
# self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF]
# else:
self._hvac_list = [HVACMode.HEAT, HVACMode.OFF]
@@ -343,8 +414,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._presets_away,
)
# Will be restored if possible
self._attr_preset_mode = None
self._saved_preset_mode = None
self._attr_preset_mode = PRESET_NONE
self._saved_preset_mode = PRESET_NONE
# Power management
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
@@ -357,8 +428,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
and self._device_power
):
self._pmax_on = True
self._current_power = 0
self._current_power_max = 0
else:
_LOGGER.info("%s - Power management is not fully configured", self)
@@ -385,15 +454,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._security_delay_min = entry_infos.get(CONF_SECURITY_DELAY_MIN)
self._security_min_on_percent = (
entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT)
or DEFAULT_SECURITY_MIN_ON_PERCENT
if entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) is not None
else DEFAULT_SECURITY_MIN_ON_PERCENT
)
self._security_default_on_percent = (
entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT)
or DEFAULT_SECURITY_DEFAULT_ON_PERCENT
if entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) is not None
else DEFAULT_SECURITY_DEFAULT_ON_PERCENT
)
self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY)
self._last_temperature_mesure = datetime.now()
self._last_ext_temperature_mesure = datetime.now()
self._last_temperature_mesure = datetime.now(tz=self._current_tz)
self._last_ext_temperature_mesure = datetime.now(tz=self._current_tz)
self._security_state = False
self._saved_hvac_mode = None
@@ -437,6 +508,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if self._motion_on:
self._attr_preset_modes.append(PRESET_ACTIVITY)
self._total_energy = 0
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s",
self,
@@ -527,7 +600,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.async_on_remove(self.async_remove_thermostat)
await self.async_startup()
try:
await self.async_startup()
except UnknownEntity:
# Ingore this error which is possible if underlying climate is not found temporary
pass
# starts a cycle if we are in over_climate type
if self._is_over_climate:
@@ -546,6 +623,14 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._async_cancel_cycle()
self._async_cancel_cycle = None
def find_underlying_climate(self, climate_entity_id) -> ClimateEntity:
"""Find the underlying climate entity"""
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if climate_entity_id == entity.entity_id:
return entity
return None
async def async_startup(self):
"""Triggered on startup, used to get old state and set internal states accordingly"""
_LOGGER.debug("%s - Calling async_startup", self)
@@ -557,25 +642,23 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Get the underlying thermostat
if self._is_over_climate:
component: EntityComponent[ClimateEntity] = self.hass.data[
CLIMATE_DOMAIN
]
for entity in component.entities:
if self._climate_entity_id == entity.entity_id:
_LOGGER.info(
"%s - The underlying climate entity: %s have been succesfully found",
self,
entity,
)
self._underlying_climate = entity
break
if self._underlying_climate is None:
self._underlying_climate = self.find_underlying_climate(
self._climate_entity_id
)
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._climate_entity_id,
)
self._is_over_climate = False
# #56 keep the over_climate and try periodically to find the underlying climate
# self._is_over_climate = False
raise UnknownEntity(
f"Underlying thermostat {self._climate_entity_id} not found"
)
@@ -771,6 +854,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else:
self._hvac_mode = HVACMode.OFF
old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
if old_total_energy:
self._total_energy = old_total_energy
else:
# No previous state, try and restore defaults
if self._target_temp is None:
@@ -924,7 +1010,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else:
return None
else:
return self.hass.states.is_state(self._heater_entity_id, STATE_ON)
return self._hass.states.is_state(self._heater_entity_id, STATE_ON)
@property
def current_temperature(self):
@@ -972,6 +1058,104 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return None
@property
def mean_cycle_power(self) -> float | None:
"""Returns tne mean power consumption during the cycle"""
if not self._device_power or self._is_over_climate:
return None
return float(self._device_power * self._prop_algorithm.on_percent)
@property
def total_energy(self) -> float | None:
"""Returns the total energy calculated for this thermostast"""
return self._total_energy
@property
def device_power(self) -> float | None:
"""Returns the device_power for this thermostast"""
return self._device_power
@property
def overpowering_state(self) -> bool | None:
"""Get the overpowering_state"""
return self._overpowering_state
@property
def window_state(self) -> bool | None:
"""Get the window_state"""
return self._window_state
@property
def window_auto_state(self) -> bool | None:
"""Get the window_auto_state"""
return STATE_ON if self._window_auto_state else STATE_OFF
@property
def security_state(self) -> bool | None:
"""Get the security_state"""
return self._security_state
@property
def motion_state(self) -> bool | None:
"""Get the motion_state"""
return self._motion_state
@property
def presence_state(self) -> bool | None:
"""Get the presence_state"""
return self._presence_state
@property
def proportional_algorithm(self) -> PropAlgorithm | None:
"""Get the eventual ProportionalAlgorithm"""
return self._prop_algorithm
@property
def last_temperature_mesure(self) -> datetime | None:
"""Get the last temperature datetime"""
return self._last_temperature_mesure
@property
def last_ext_temperature_mesure(self) -> datetime | None:
"""Get the last external temperature datetime"""
return self._last_ext_temperature_mesure
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.
Requires ClimateEntityFeature.PRESET_MODE.
"""
return self._attr_preset_mode
@property
def preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes.
Requires ClimateEntityFeature.PRESET_MODE.
"""
return self._attr_preset_modes
@property
def is_over_climate(self) -> bool | None:
"""return True is the thermostat is over a climate
or False is over switch"""
return self._is_over_climate
@property
def last_temperature_slope(self) -> float | None:
"""Return the last temperature slope curve if any"""
if not self._window_auto_algo:
return None
else:
return self._window_auto_algo.last_slope
@property
def is_window_auto_enabled(self) -> bool:
"""True if the Window auto feature is enabled"""
return self._window_auto_on
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self._is_over_climate and self._underlying_climate:
@@ -1000,22 +1184,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
raise NotImplementedError()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.
Requires ClimateEntityFeature.PRESET_MODE.
"""
return self._attr_preset_mode
@property
def preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes.
Requires ClimateEntityFeature.PRESET_MODE.
"""
return self._attr_preset_modes
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
@@ -1071,6 +1239,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if preset_mode == self._attr_preset_mode and not force:
# I don't think we need to call async_write_ha_state if we didn't change the state
return
# In security mode don't change preset but memorise the new expected preset when security will be off
if preset_mode != PRESET_SECURITY and self._security_state:
_LOGGER.debug(
"%s - is in security mode. Just memorise the new expected ", self
)
if preset_mode not in HIDDEN_PRESETS:
self._saved_preset_mode = preset_mode
return
old_preset_mode = self._attr_preset_mode
if preset_mode == PRESET_NONE:
self._attr_preset_mode = PRESET_NONE
@@ -1101,7 +1279,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
):
self._last_temperature_mesure = (
self._last_ext_temperature_mesure
) = datetime.now()
) = datetime.now(tz=self._current_tz)
def find_preset_temp(self, preset_mode):
"""Find the right temperature of a preset considering the presence if configured"""
@@ -1200,6 +1378,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context
)
def get_state_date_or_now(self, state: State):
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_changed.astimezone(self._current_tz)
if state.last_changed is not None
else datetime.now(tz=self._current_tz)
)
def get_last_updated_date_or_now(self, state: State):
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_updated.astimezone(self._current_tz)
if state.last_updated is not None
else datetime.now(tz=self._current_tz)
)
@callback
async def entry_update_listener(
self, _, config_entry: ConfigEntry # hass: HomeAssistant,
@@ -1208,9 +1402,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
@callback
async def _async_temperature_changed(self, event):
"""Handle temperature changes."""
new_state = event.data.get("new_state")
async def _async_temperature_changed(self, event: Event):
"""Handle temperature of the temperature sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - Temperature changed. Event.new_state is %s",
self,
@@ -1223,9 +1417,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.recalculate()
await self._async_control_heating(force=False)
async def _async_ext_temperature_changed(self, event):
"""Handle external temperature changes."""
new_state = event.data.get("new_state")
async def _async_ext_temperature_changed(self, event: Event):
"""Handle external temperature opf the sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - external Temperature changed. Event.new_state is %s",
self,
@@ -1250,8 +1444,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._hvac_mode,
self._saved_hvac_mode,
)
if new_state is None or old_state is None or new_state.state == old_state.state:
return
# Check delay condition
async def try_window_condition(_):
@@ -1269,12 +1461,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(
"Window delay condition is not satisfied. Ignore window event"
)
self._window_state = old_state.state
return
_LOGGER.debug("%s - Window delay condition is satisfied", self)
# if not self._saved_hvac_mode:
# self._saved_hvac_mode = self._hvac_mode
if self._window_state == new_state.state:
_LOGGER.debug("%s - no change in window state. Forget the event")
return
self._window_state = new_state.state
if self._window_state == STATE_OFF:
_LOGGER.info(
@@ -1291,12 +1488,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self.async_set_hvac_mode(HVACMode.OFF)
self.update_custom_attributes()
if new_state is None or old_state is None or new_state.state == old_state.state:
return try_window_condition
if self._window_call_cancel:
self._window_call_cancel()
self._window_call_cancel = None
self._window_call_cancel = async_call_later(
self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition
)
# For testing purpose we need to access the inner function
return try_window_condition
@callback
async def _async_motion_changed(self, event):
@@ -1356,6 +1558,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.hass, timedelta(seconds=self._motion_delay_sec), try_motion_condition
)
# For testing purpose we need to access the inner function
return try_motion_condition
@callback
async def _check_switch_initial_state(self):
"""Prevent the device from keep running if HVAC_MODE_OFF."""
@@ -1382,14 +1587,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def _async_climate_changed(self, event):
"""Handle unerdlying climate state changes."""
new_state = event.data.get("new_state")
_LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state)
if not new_state:
return
old_state = event.data.get("old_state")
old_hvac_action = (
old_state.attributes.get("hvac_action")
if old_state and old_state.attributes
else None
)
new_hvac_action = (
new_state.attributes.get("hvac_action")
if new_state and new_state.attributes
else None
)
_LOGGER.info(
"%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s",
"%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s, hvac_action=%s, old_hvac_action=%s",
self,
new_state,
self._hvac_mode,
new_hvac_action,
old_hvac_action,
)
# old_state = event.data.get("old_state")
if new_state is None or new_state.state not in [
if new_state.state in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
@@ -1398,36 +1621,93 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
HVACMode.AUTO,
HVACMode.FAN_ONLY,
]:
return
self._hvac_mode = new_state.state
self._hvac_mode = new_state.state
# Interpretation of hvac
HVAC_ACTION_ON = [ # pylint: disable=invalid-name
HVACAction.COOLING,
HVACAction.DRYING,
HVACAction.FAN,
HVACAction.HEATING,
]
if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON:
self._underlying_climate_start_hvac_action_date = (
self.get_last_updated_date_or_now(new_state)
)
_LOGGER.info(
"%s - underlying just switch ON. Set power and energy start date %s",
self,
self._underlying_climate_start_hvac_action_date.isoformat(),
)
if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON:
stop_power_date = self.get_last_updated_date_or_now(new_state)
if self._underlying_climate_start_hvac_action_date:
delta = (
stop_power_date - self._underlying_climate_start_hvac_action_date
)
self._underlying_climate_delta_t = delta.total_seconds() / 3600.0
# increment energy at the end of the cycle
self.incremente_energy()
self._underlying_climate_start_hvac_action_date = None
_LOGGER.info(
"%s - underlying just switch OFF at %s. delta_h=%.3f h",
self,
stop_power_date.isoformat(),
self._underlying_climate_delta_t,
)
self.update_custom_attributes()
await self._async_control_heating(True)
@callback
async def _async_update_temp(self, state):
async def _async_update_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_temp = float(state.state)
if math.isnan(cur_temp) or math.isinf(cur_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_temp = cur_temp
self._last_temperature_mesure = datetime.now()
self._last_temperature_mesure = self.get_state_date_or_now(state)
_LOGGER.debug(
"%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s",
self,
self._last_temperature_mesure,
state.last_changed.astimezone(self._current_tz),
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
# check window_auto
await self._async_manage_window_auto()
except ValueError as ex:
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
@callback
async def _async_update_ext_temp(self, state):
async def _async_update_ext_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_ext_temp = float(state.state)
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_ext_temp = cur_ext_temp
self._last_ext_temperature_mesure = datetime.now()
self._last_ext_temperature_mesure = self.get_state_date_or_now(state)
_LOGGER.debug(
"%s - After setting _last_ext_temperature_mesure %s , state.last_changed.replace=%s",
self,
self._last_ext_temperature_mesure,
state.last_changed.astimezone(self._current_tz),
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
@@ -1602,6 +1882,93 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
)
async def _async_manage_window_auto(self):
"""The management of the window auto feature"""
async def dearm_window_auto(_):
"""Callback that will be called after end of WINDOW_AUTO_MAX_DURATION"""
_LOGGER.info("Unset window auto because MAX_DURATION is exceeded")
await deactivate_window_auto(auto=True)
async def deactivate_window_auto(auto=False):
"""Deactivation of the Window auto state"""
_LOGGER.warning(
"%s - End auto detection of open window slope=%.3f", self, slope
)
# Send an event
cause = "max duration expiration" if auto else "end of slope alert"
self.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "end", "cause": cause, "curve_slope": slope},
)
# Set attributes
self._window_auto_state = False
await self.restore_hvac_mode()
if self._window_call_cancel:
self._window_call_cancel()
self._window_call_cancel = None
if not self._window_auto_algo:
return
slope = self._window_auto_algo.add_temp_measurement(
temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure
)
_LOGGER.debug(
"%s - Window auto is on, check the alert. last slope is %.3f",
self,
slope if slope is not None else 0.0,
)
if (
self._window_auto_algo.is_window_open_detected()
and self._window_auto_state is False
and self.hvac_mode != HVACMode.OFF
):
if (
not self.proportional_algorithm
or self.proportional_algorithm.on_percent <= 0.0
):
_LOGGER.info(
"%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event",
self,
slope,
)
return dearm_window_auto
_LOGGER.warning(
"%s - Start auto detection of open window slope=%.3f", self, slope
)
# Send an event
self.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "start", "cause": "slope alert", "curve_slope": slope},
)
# Set attributes
self._window_auto_state = True
self.save_hvac_mode()
await self.async_set_hvac_mode(HVACMode.OFF)
# Arm the end trigger
if self._window_call_cancel:
self._window_call_cancel()
self._window_call_cancel = None
self._window_call_cancel = async_call_later(
self.hass,
timedelta(minutes=self._window_auto_max_duration),
dearm_window_auto,
)
elif (
self._window_auto_algo.is_window_close_detected()
and self._window_auto_state is True
):
await deactivate_window_auto(False)
# For testing purpose we need to return the inner function
return dearm_window_auto
def save_preset_mode(self):
"""Save the current preset mode to be restored later
We never save a hidden preset mode
@@ -1648,7 +2015,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""
if not self._pmax_on:
return
_LOGGER.debug(
"%s - power not configured. check_overpowering not available", self
)
return False
if (
self._current_power is None
or self._device_power is None
or self._current_power_max is None
):
_LOGGER.warning(
"%s - power not valued. check_overpowering not available", self
)
return False
_LOGGER.debug(
"%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f",
@@ -1657,6 +2037,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._current_power_max,
self._device_power,
)
ret = self._current_power + self._device_power >= self._current_power_max
if not self._overpowering_state and ret and not self._hvac_mode == HVACMode.OFF:
_LOGGER.warning(
@@ -1707,10 +2088,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def check_security(self) -> bool:
"""Check if last temperature date is too long"""
now = datetime.now()
delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0
now = datetime.now(self._current_tz)
delta_temp = (
now - self._last_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
delta_ext_temp = (
now - self._last_ext_temperature_mesure
now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
@@ -1730,6 +2113,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
>= self._security_min_on_percent
)
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
ret = False
if mode_cond and temp_cond and climate_cond:
if not self._security_state:
@@ -1743,17 +2137,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
ret = True
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
if mode_cond and temp_cond and switch_cond:
if not self._security_state:
_LOGGER.warning(
@@ -1771,8 +2154,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.send_event(
EventType.TEMPERATURE_EVENT,
{
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1794,8 +2181,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
EventType.SECURITY_EVENT,
{
"type": "start",
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1824,8 +2215,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
EventType.SECURITY_EVENT,
{
"type": "end",
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1845,6 +2240,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._attr_preset_mode,
)
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
if self._is_over_climate and self._underlying_climate is None:
_LOGGER.info(
"%s - Underlying climate is not initialized. Try to initialize it", self
)
try:
await self.async_startup()
except UnknownEntity as err:
# still not found, we an stop here
raise err
# Check overpowering condition
overpowering: bool = await self.check_overpowering()
if overpowering:
@@ -1894,7 +2300,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0:
async def _turn_on_off_later(
on: bool, time, heater_action, next_cycle_action
on: bool, # pylint: disable=invalid-name
time,
heater_action,
next_cycle_action,
):
if self._async_cancel_cycle:
self._async_cancel_cycle()
@@ -1939,7 +2348,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(
"%s - No action on heater cause duration is 0", self
)
self.update_custom_attributes()
self._async_cancel_cycle = async_call_later(
self.hass,
time,
@@ -1953,6 +2361,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
heater_action=self._async_heater_turn_on,
next_cycle_action=_turn_off_later,
)
self.update_custom_attributes()
async def _turn_off_later(_):
await _turn_on_off_later(
@@ -1961,6 +2370,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
heater_action=self._async_underlying_entity_turn_off,
next_cycle_action=_turn_on_later,
)
# increment energy at the end of the cycle
self.incremente_energy()
self.update_custom_attributes()
await _turn_on_later(None)
@@ -1993,11 +2405,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.update_custom_attributes()
self.async_write_ha_state()
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self.hvac_mode == HVACMode.OFF:
return
added_energy = 0
if self._is_over_climate and self._underlying_climate_delta_t is not None:
added_energy = self._device_power * self._underlying_climate_delta_t
if not self._is_over_climate and self.mean_cycle_power is not None:
added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0
self._total_energy += added_energy
_LOGGER.debug(
"%s - added energy is %.3f . Total energy is now: %.3f",
self,
added_energy,
self._total_energy,
)
def update_custom_attributes(self):
"""Update the custom extra attributes for the entity"""
self._attr_extra_state_attributes: dict(str, str) = {
"hvac_mode": self._hvac_mode,
"hvac_mode": self.hvac_mode,
"preset_mode": self.preset_mode,
"type": self._thermostat_type,
"eco_temp": self._presets[PRESET_ECO],
"boost_temp": self._presets[PRESET_BOOST],
@@ -2022,19 +2455,37 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"motion_state": self._motion_state,
"overpowering_state": self._overpowering_state,
"presence_state": self._presence_state,
"window_auto_state": self._window_auto_state,
"security_delay_min": self._security_delay_min,
"security_min_on_percent": self._security_min_on_percent,
"security_default_on_percent": self._security_default_on_percent,
"last_temperature_datetime": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_datetime": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_datetime": self._last_temperature_mesure.astimezone(
self._current_tz
).isoformat(),
"last_ext_temperature_datetime": self._last_ext_temperature_mesure.astimezone(
self._current_tz
).isoformat(),
"security_state": self._security_state,
"minimal_activation_delay_sec": self._minimal_activation_delay,
"last_update_datetime": datetime.now().isoformat(),
"device_power": self._device_power,
ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power,
ATTR_TOTAL_ENERGY: self.total_energy,
"last_update_datetime": datetime.now()
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz),
"window_delay_sec": self._window_delay_sec,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
}
if self._is_over_climate:
self._attr_extra_state_attributes[
"underlying_climate"
] = self._climate_entity_id
self._attr_extra_state_attributes[
"start_hvac_action_date"
] = self._underlying_climate_start_hvac_action_date
else:
self._attr_extra_state_attributes[
"underlying_switch"

View File

@@ -0,0 +1,104 @@
""" Some usefull commons class """
import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity, DeviceInfo, DeviceEntryType
from homeassistant.helpers.event import async_track_state_change_event, async_call_later
from .climate import VersatileThermostat
from .const import DOMAIN, DEVICE_MANUFACTURER
_LOGGER = logging.getLogger(__name__)
class VersatileThermostatBaseEntity(Entity):
"""A base class for all entities"""
_my_climate: VersatileThermostat
hass: HomeAssistant
_config_id: str
_devince_name: str
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
"""The CTOR"""
self.hass = hass
self._config_id = config_id
self._device_name = device_name
self._my_climate = None
self._cancel_call = None
self._attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""Do not poll for those entities"""
return False
@property
def my_climate(self) -> VersatileThermostat | None:
"""Returns my climate if found"""
if not self._my_climate:
self._my_climate = self.find_my_versatile_thermostat()
return self._my_climate
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
def find_my_versatile_thermostat(self) -> VersatileThermostat:
"""Find the underlying climate entity"""
try:
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
# _LOGGER.debug("Device_info is %s", entity.device_info)
if entity.device_info == self.device_info:
_LOGGER.debug("Found %s!", entity)
return entity
except KeyError:
pass
return None
@callback
async def async_added_to_hass(self):
"""Listen to my climate state change"""
# Check delay condition
async def try_find_climate(_):
_LOGGER.debug(
"%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self
)
mcl = self.my_climate
if mcl:
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
self.async_on_remove(
async_track_state_change_event(
self.hass,
[mcl.entity_id],
self.async_my_climate_changed,
)
)
else:
_LOGGER.warning("%s - no entity to listen. Try later", self)
self._cancel_call = async_call_later(
self.hass, timedelta(seconds=1), try_find_climate
)
await try_find_climate(None)
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change
This method aims to be overriden to take the status change
"""
return

View File

@@ -47,6 +47,9 @@ from .const import (
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 +82,7 @@ from .const import (
CONF_USE_POWER_FEATURE,
CONF_THERMOSTAT_TYPES,
UnknownEntity,
WindowOpenDetectionMethod,
)
_LOGGER = logging.getLogger(__name__)
@@ -167,7 +171,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
@@ -180,7 +186,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
is_empty or self._infos.get(CONF_PRESENCE_SENSOR) is not None
)
self.STEP_USER_DATA_SCHEMA = vol.Schema(
self.STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_NAME): cv.string,
vol.Required(
@@ -195,15 +201,15 @@ 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),
vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float),
vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean,
vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean,
vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean,
@@ -211,13 +217,13 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
self.STEP_THERMOSTAT_SWITCH = vol.Schema(
self.STEP_THERMOSTAT_SWITCH = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_HEATER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
), # vol.In(switches),
),
vol.Required(
CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
): vol.In(
@@ -228,46 +234,49 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
self.STEP_THERMOSTAT_CLIMATE = vol.Schema(
self.STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_CLIMATE): selector.EntitySelector(
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
), # vol.In(climates),
),
}
)
self.STEP_TPI_DATA_SCHEMA = vol.Schema(
self.STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_TPI_COEF_INT, default=0.6): vol.Coerce(float),
vol.Required(CONF_TPI_COEF_EXT, default=0.01): vol.Coerce(float),
}
)
self.STEP_PRESETS_DATA_SCHEMA = vol.Schema(
self.STEP_PRESETS_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(v, default=0.0): vol.Coerce(float)
for (k, v) in CONF_PRESETS.items()
}
)
self.STEP_WINDOW_DATA_SCHEMA = vol.Schema(
self.STEP_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_SENSOR): selector.EntitySelector(
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,
}
)
self.STEP_MOTION_DATA_SCHEMA = vol.Schema(
self.STEP_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_MOTION_SENSOR): selector.EntitySelector(
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
@@ -278,24 +287,23 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
self.STEP_POWER_DATA_SCHEMA = vol.Schema(
self.STEP_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_POWER_SENSOR): selector.EntitySelector(
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_DEVICE_POWER, default="1"): vol.Coerce(float),
),
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
}
)
self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema(
self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_PRESENCE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
@@ -305,7 +313,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
INPUT_BOOLEAN_DOMAIN,
]
),
), # vol.In(presence_sensors),
),
}
).extend(
{
@@ -314,7 +322,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
self.STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
self.STEP_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(
CONF_MINIMAL_ACTIVATION_DELAY, default=10
@@ -331,7 +339,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
async def validate_input(self, data: dict) -> dict[str]:
async def validate_input(self, data: dict) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_*_DATA_SCHEMA with values provided by the user.
@@ -357,6 +365,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 +408,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

@@ -1,13 +1,14 @@
"""Constants for the Versatile Thermostat integration."""
from enum import Enum
from homeassistant.const import CONF_NAME
from homeassistant.components.climate.const import (
from homeassistant.const import CONF_NAME, Platform
from homeassistant.components.climate import (
# PRESET_ACTIVITY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
SUPPORT_TARGET_TEMPERATURE,
ClimateEntityFeature,
)
from homeassistant.exceptions import HomeAssistantError
@@ -16,6 +17,9 @@ from .prop_algorithm import (
PROPORTIONAL_FUNCTION_TPI,
)
DEVICE_MANUFACTURER = "JMCOLLIN"
DEVICE_MODEL = "Versatile Thermostat"
PRESET_POWER = "power"
PRESET_SECURITY = "security"
@@ -23,6 +27,8 @@ HIDDEN_PRESETS = [PRESET_POWER, PRESET_SECURITY]
DOMAIN = "versatile_thermostat"
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR]
CONF_HEATER = "heater_entity_id"
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
@@ -55,6 +61,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"
@@ -91,6 +100,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,
@@ -126,7 +138,7 @@ CONF_FUNCTIONS = [
CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE]
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
SERVICE_SET_PRESENCE = "set_presence"
SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature"
@@ -135,6 +147,9 @@ SERVICE_SET_SECURITY = "set_security"
DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5
DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1
ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
class EventType(Enum):
"""The event type that can be sent"""
@@ -144,7 +159,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

@@ -1,19 +1,19 @@
{
"version": "0.0.1",
"domain": "versatile_thermostat",
"name": "Versatile Thermostat",
"config_flow": true,
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
"issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues",
"requirements": [],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": [
"@jmcollin78"
],
"quality_scale": "silver",
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
"homekit": {},
"integration_type": "device",
"iot_class": "calculated",
"integration_type": "device"
}
"issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues",
"quality_scale": "silver",
"requirements": [],
"ssdp": [],
"version": "3.0.0",
"zeroconf": []
}

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.warning(
"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.warning(
"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 @@
""" The TPI calculation module """
import logging
_LOGGER = logging.getLogger(__name__)
@@ -21,7 +22,7 @@ class PropAlgorithm:
tpi_coef_ext,
cycle_min: int,
minimal_activation_delay: int,
):
) -> None:
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
"Creation new PropAlgorithm function_type: %s, tpi_coef_int: %s, tpi_coef_ext: %s, cycle_min:%d, minimal_activation_delay:%d",
@@ -85,6 +86,12 @@ class PropAlgorithm:
def _calculate_internal(self):
"""Finish the calculation to get the on_percent in seconds"""
# calculated on_time duration in seconds
if self._calculated_on_percent > 1:
self._calculated_on_percent = 1
if self._calculated_on_percent < 0:
self._calculated_on_percent = 0
if self._security:
_LOGGER.debug(
"Security is On using the default_on_percent %f",
@@ -98,11 +105,6 @@ class PropAlgorithm:
)
self._on_percent = self._calculated_on_percent
# calculated on_time duration in seconds
if self._on_percent > 1:
self._on_percent = 1
if self._on_percent < 0:
self._on_percent = 0
self._on_time_sec = self._on_percent * self._cycle_min * 60
# Do not heat for less than xx sec

View File

@@ -0,0 +1 @@
homeassistant

View File

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

View File

@@ -0,0 +1,426 @@
""" Implements the VersatileThermostat sensors component """
import logging
import math
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAGE
from homeassistant.components.sensor import (
SensorEntity,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .commons import VersatileThermostatBaseEntity
from .const import (
CONF_NAME,
CONF_DEVICE_POWER,
CONF_PROP_FUNCTION,
PROPORTIONAL_FUNCTION_TPI,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_TYPE,
)
THRESHOLD_WATT_KILO = 100
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat sensors with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
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))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_SWITCH:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
entities.append(OnTimeSensor(hass, unique_id, name, entry.data))
entities.append(OffTimeSensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
class EnergySensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Energy sensor which exposes the energy"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Energy"
self._attr_unique_id = f"{self._device_name}_energy"
@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)
if math.isnan(self.my_climate.total_energy) or math.isinf(
self.my_climate.total_energy
):
raise ValueError(f"Sensor has illegal state {self.my_climate.total_energy}")
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.total_energy, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:lightning-bolt"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.ENERGY
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.TOTAL_INCREASING
@property
def native_unit_of_measurement(self) -> str | None:
if not self.my_climate:
return None
if self.my_climate.device_power > THRESHOLD_WATT_KILO:
return UnitOfEnergy.WATT_HOUR
else:
return UnitOfEnergy.KILO_WATT_HOUR
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 3
class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a power sensor which exposes the mean power in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Mean power cycle"
self._attr_unique_id = f"{self._device_name}_mean_power_cycle"
@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)
if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf(
self.my_climate.mean_cycle_power
):
raise ValueError(
f"Sensor has illegal state {self.my_climate.mean_cycle_power}"
)
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.mean_cycle_power, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:flash-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.POWER
@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
if self.my_climate.device_power > THRESHOLD_WATT_KILO:
return UnitOfPower.WATT
else:
return UnitOfPower.KILO_WATT
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 3
class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on percent sensor which exposes the on_percent in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Power percent"
self._attr_unique_id = f"{self._device_name}_power_percent"
@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)
on_percent = (
float(self.my_climate.proportional_algorithm.on_percent)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(on_percent) or math.isinf(on_percent):
raise ValueError(f"Sensor has illegal state {on_percent}")
old_state = self._attr_native_value
self._attr_native_value = round(
on_percent * 100.0, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:meter-electric-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.POWER_FACTOR
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return PERCENTAGE
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 1
class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on time sensor which exposes the on_time_sec in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "On time"
self._attr_unique_id = f"{self._device_name}_on_time"
@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)
on_time = (
float(self.my_climate.proportional_algorithm.on_time_sec)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(on_time) or math.isinf(on_time):
raise ValueError(f"Sensor has illegal state {on_time}")
old_state = self._attr_native_value
self._attr_native_value = round(on_time)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:timer-play"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.DURATION
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return UnitOfTime.SECONDS
class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on time sensor which exposes the off_time_sec in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Off time"
self._attr_unique_id = f"{self._device_name}_off_time"
@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)
off_time = (
float(self.my_climate.proportional_algorithm.off_time_sec)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(off_time) or math.isinf(off_time):
raise ValueError(f"Sensor has illegal state {off_time}")
old_state = self._attr_native_value
self._attr_native_value = round(off_time)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:timer-off-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.DURATION
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return UnitOfTime.SECONDS
class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a last temperature datetime sensor"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the last temperature datetime sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Last temperature date"
self._attr_unique_id = f"{self._device_name}_last_temp_datetime"
@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)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_temperature_mesure
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:home-clock"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TIMESTAMP
class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a last external temperature datetime sensor"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the last temperature datetime sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Last external temperature date"
self._attr_unique_id = f"{self._device_name}_last_ext_temp_datetime"
@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)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_ext_temperature_mesure
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:sun-clock"
@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

@@ -1,3 +1,6 @@
reload:
description: Reload all Versatile Thermostat entities.
set_presence:
name: Set presence
description: Force the presence mode in thermostat

View File

@@ -14,6 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -48,10 +49,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": {
@@ -70,7 +81,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -88,16 +98,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"
@@ -117,6 +134,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -151,10 +169,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": {
@@ -173,7 +201,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -191,16 +218,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"

View File

@@ -0,0 +1 @@
""" To make this repo a module """

View File

@@ -0,0 +1,359 @@
""" Some common resources """
from unittest.mock import patch, MagicMock
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF
from homeassistant.config_entries import ConfigEntryState
from homeassistant.util import dt as dt_util
from homeassistant.helpers.entity import Entity
from homeassistant.components.climate import (
ClimateEntity,
DOMAIN as CLIMATE_DOMAIN,
HVACMode,
HVACAction,
ClimateEntityFeature,
)
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .const import ( # pylint: disable=unused-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,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_NONE,
PRESET_ECO,
PRESET_ACTIVITY,
)
FULL_SWITCH_CONFIG = (
MOCK_TH_OVER_SWITCH_USER_CONFIG
| MOCK_TH_OVER_SWITCH_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
)
PARTIAL_CLIMATE_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
class MockClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
super().__init__()
self._hass = hass
self._attr_extra_state_attributes = {}
self._unique_id = unique_id
self._name = name
self._attr_hvac_action = HVACAction.OFF
self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
class MagicMockClimate(MagicMock):
"""A Magic Mock class for a underlying climate entity"""
@property
def temperature_unit(self): # pylint: disable=missing-function-docstring
return UnitOfTemperature.CELSIUS
@property
def hvac_mode(self): # pylint: disable=missing-function-docstring
return HVACMode.HEAT
@property
def hvac_action(self): # pylint: disable=missing-function-docstring
return HVACAction.IDLE
@property
def target_temperature(self): # pylint: disable=missing-function-docstring
return 15
@property
def current_temperature(self): # pylint: disable=missing-function-docstring
return 14
@property
def target_temperature_step( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 0.5
@property
def target_temperature_high( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 35
@property
def target_temperature_low( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 7
@property
def hvac_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL]
@property
def fan_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return None
@property
def swing_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return None
@property
def fan_mode(self) -> str | None: # pylint: disable=missing-function-docstring
return None
@property
def swing_mode(self) -> str | None: # pylint: disable=missing-function-docstring
return None
@property
def supported_features(self): # pylint: disable=missing-function-docstring
return ClimateEntityFeature.TARGET_TEMPERATURE
async def create_thermostat(
hass: HomeAssistant, entry: MockConfigEntry, entity_id: str
) -> VersatileThermostat:
"""Creates and return a TPI Thermostat"""
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.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
return search_entity(hass, entity_id, CLIMATE_DOMAIN)
def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity:
"""Search and return the entity in the domain"""
component = hass.data[domain]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
return None
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
"""Sending a new temperature event simulating a change on temperature sensor"""
temp_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_temp,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_temperature_changed(temp_event)
async def send_ext_temperature_change_event(
entity: VersatileThermostat, new_temp, date
):
"""Sending a new external temperature event simulating a change on temperature sensor"""
temp_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_temp,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_ext_temperature_changed(temp_event)
async def send_power_change_event(entity: VersatileThermostat, new_power, date):
"""Sending a new power event simulating a change on power sensor"""
power_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_power,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_power_changed(power_event)
async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date):
"""Sending a new power max event simulating a change on power max sensor"""
power_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_power_max,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_max_power_changed(power_event)
async def send_window_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
):
"""Sending a new window event simulating a change on the window state"""
window_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=STATE_ON if new_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=STATE_ON if old_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_windows_changed(window_event)
return ret
async def send_motion_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
):
"""Sending a new motion event simulating a change on the window state"""
motion_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=STATE_ON if new_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=STATE_ON if old_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_motion_changed(motion_event)
return ret
async def send_presence_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
):
"""Sending a new presence event simulating a change on the window state"""
presence_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=STATE_ON if new_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=STATE_ON if old_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_presence_changed(presence_event)
return ret
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
async def send_climate_change_event(
entity: VersatileThermostat,
new_hvac_mode: HVACMode,
old_hvac_mode: HVACMode,
new_hvac_action: HVACAction,
old_hvac_action: HVACAction,
date,
):
"""Sending a new climate event simulating a change on the underlying climate state"""
climate_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_hvac_mode,
attributes={"hvac_action": new_hvac_action},
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=old_hvac_mode,
attributes={"hvac_action": old_hvac_action},
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_climate_changed(climate_event)
return ret

View File

@@ -0,0 +1,101 @@
"""Global fixtures for integration_blueprint integration."""
# Fixtures allow you to replace functions with a Mock object. You can perform
# many options via the Mock to reflect a particular behavior from the original
# function that you want to see without going through the function's actual logic.
# Fixtures can either be passed into tests as parameters, or if autouse=True, they
# will automatically be used across all tests.
#
# Fixtures that are defined in conftest.py are available across all tests. You can also
# define fixtures within a particular test file to scope them locally.
#
# pytest_homeassistant_custom_component provides some fixtures that are provided by
# Home Assistant core. You can find those fixture definitions here:
# https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py
#
# See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that
# pytest includes fixtures OOB which you can use as defined on this page)
from unittest.mock import patch
import pytest
from homeassistant.core import StateMachine
from custom_components.versatile_thermostat.config_flow import (
VersatileThermostatBaseConfigFlow,
)
from custom_components.versatile_thermostat.climate import (
VersatileThermostat,
)
pytest_plugins = "pytest_homeassistant_custom_component" # pylint: disable=invalid-name
# This fixture enables loading custom integrations in all tests.
# Remove to enable selective use of this fixture
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable all integration in tests"""
yield
# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent
# notifications. These calls would fail without this fixture since the persistent_notification
# integration is never loaded during a test.
@pytest.fixture(name="skip_notifications", autouse=True)
def skip_notifications_fixture():
"""Skip notification calls."""
with patch("homeassistant.components.persistent_notification.async_create"), patch(
"homeassistant.components.persistent_notification.async_dismiss"
):
yield
@pytest.fixture(name="skip_turn_on_off_heater")
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"
), patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
):
yield
# This fixture is used to bypass the validate_input function in config_flow
# NOT USED Now (keeped for memory)
@pytest.fixture(name="skip_validate_input")
def skip_validate_input_fixture():
"""Skip the validate_input in config flow"""
with patch.object(VersatileThermostatBaseConfigFlow, "validate_input"):
yield
@pytest.fixture(name="skip_hass_states_get")
def skip_hass_states_get_fixture():
"""Skip the get state in HomeAssistant"""
with patch.object(StateMachine, "get"):
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_hass_states_is_state")
def skip_hass_states_is_state_fixture():
"""Skip the is_state in HomeAssistant"""
with patch.object(StateMachine, "is_state", return_value=False):
yield
@pytest.fixture(name="skip_send_event")
def skip_send_event_fixture():
"""Skip the send_event in VersatileThermostat"""
with patch.object(VersatileThermostat, "send_event"):
yield

View File

@@ -0,0 +1,140 @@
""" The commons const for all tests """
from homeassistant.components.climate.const import ( # pylint: disable=unused-import
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
PRESET_ACTIVITY,
)
from custom_components.versatile_thermostat.const import (
CONF_NAME,
CONF_HEATER,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_TYPE,
CONF_TEMP_SENSOR,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_CYCLE_MIN,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_PROP_FUNCTION,
PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT,
CONF_TPI_COEF_EXT,
CONF_MINIMAL_ACTIVATION_DELAY,
CONF_SECURITY_DELAY_MIN,
CONF_SECURITY_MIN_ON_PERCENT,
CONF_SECURITY_DEFAULT_ON_PERCENT,
CONF_USE_WINDOW_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_POWER_FEATURE,
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,
CONF_NO_MOTION_PRESET,
CONF_POWER_SENSOR,
CONF_MAX_POWER_SENSOR,
CONF_DEVICE_POWER,
CONF_PRESET_POWER,
CONF_PRESENCE_SENSOR,
PRESET_AWAY_SUFFIX,
CONF_CLIMATE,
)
MOCK_TH_OVER_SWITCH_USER_CONFIG = {
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: 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,
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,
# Keep default values which are False
}
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
}
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
}
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
}
MOCK_PRESETS_CONFIG = {
PRESET_ECO + "_temp": 16,
PRESET_COMFORT + "_temp": 17,
PRESET_BOOST + "_temp": 18,
}
MOCK_WINDOW_CONFIG = {
CONF_WINDOW_SENSOR: "binary_sensor.window_sensor",
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,
CONF_MOTION_PRESET: PRESET_COMFORT,
CONF_NO_MOTION_PRESET: PRESET_ECO,
}
MOCK_POWER_CONFIG = {
CONF_POWER_SENSOR: "sensor.power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor",
CONF_PRESET_POWER: 10,
}
MOCK_PRESENCE_CONFIG = {
CONF_PRESENCE_SENSOR: "person.presence_sensor",
PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16,
PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17,
PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18,
}
MOCK_ADVANCED_CONFIG = {
CONF_MINIMAL_ACTIVATION_DELAY: 10,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.4,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3,
}
MOCK_DEFAULT_FEATURE_CONFIG = {
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
}

View File

@@ -0,0 +1,503 @@
""" Test the normal start of a Thermostat """
from unittest.mock import patch
from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACMode
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..binary_sensor import (
SecurityBinarySensor,
OverpoweringBinarySensor,
WindowBinarySensor,
MotionBinarySensor,
PresenceBinarySensor,
)
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_security_binary_sensors(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the security binary sensors in thermostat type"""
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.3,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
security_binary_sensor: SecurityBinarySensor = search_entity(
hass, "binary_sensor.theoverswitchmockname_security_state", "binary_sensor"
)
assert security_binary_sensor
now: datetime = datetime.now(tz=get_tz(hass))
# Security should be disabled
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert security_binary_sensor.state == STATE_OFF
assert security_binary_sensor.device_class == BinarySensorDeviceClass.SAFETY
# Set temperature in the past
event_timestamp = now - timedelta(minutes=6)
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
await send_temperature_change_event(entity, 15, event_timestamp)
assert entity.security_state is True
# Simulate the event reception
await security_binary_sensor.async_my_climate_changed()
assert security_binary_sensor.state == STATE_ON
# set temperature now
await send_temperature_change_event(entity, 15, now)
assert entity.security_state is False
# Simulate the event reception
await security_binary_sensor.async_my_climate_changed()
assert security_binary_sensor.state == STATE_OFF
async def test_overpowering_binary_sensors(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the overpowering binary sensors in thermostat type"""
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: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
overpowering_binary_sensor: OverpoweringBinarySensor = search_entity(
hass, "binary_sensor.theoverswitchmockname_overpowering_state", "binary_sensor"
)
assert overpowering_binary_sensor
now: datetime = datetime.now(tz=get_tz(hass))
# Overpowering should be not set because poer have not been received
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, now)
assert await entity.check_overpowering() is False
assert entity.overpowering_state is None
await overpowering_binary_sensor.async_my_climate_changed()
assert overpowering_binary_sensor.state is STATE_OFF
assert overpowering_binary_sensor.device_class == BinarySensorDeviceClass.POWER
await send_power_change_event(entity, 100, now)
await send_max_power_change_event(entity, 150, now)
assert await entity.check_overpowering() is True
assert entity.overpowering_state is True
# Simulate the event reception
await overpowering_binary_sensor.async_my_climate_changed()
assert overpowering_binary_sensor.state == STATE_ON
# set max power to a low value
await send_max_power_change_event(entity, 201, now)
assert await entity.check_overpowering() is False
assert entity.overpowering_state is False
# Simulate the event reception
await overpowering_binary_sensor.async_my_climate_changed()
assert overpowering_binary_sensor.state == STATE_OFF
async def test_window_binary_sensors(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the window binary sensors in thermostat type"""
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.3,
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
window_binary_sensor: WindowBinarySensor = search_entity(
hass, "binary_sensor.theoverswitchmockname_window_state", "binary_sensor"
)
assert window_binary_sensor
now: datetime = datetime.now(tz=get_tz(hass))
# Overpowering should be not set because poer have not been received
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, now)
assert entity.window_state is None
await window_binary_sensor.async_my_climate_changed()
assert window_binary_sensor.state is STATE_OFF
assert window_binary_sensor.device_class == BinarySensorDeviceClass.WINDOW
# Open the window
with patch("homeassistant.helpers.condition.state", return_value=True):
try_window_condition = await send_window_change_event(entity, True, False, now)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state is STATE_ON
# Simulate the event reception
await window_binary_sensor.async_my_climate_changed()
assert window_binary_sensor.state == STATE_ON
# close the window
with patch("homeassistant.helpers.condition.state", return_value=True):
try_window_condition = await send_window_change_event(entity, False, True, now)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state is STATE_OFF
# Simulate the event reception
await window_binary_sensor.async_my_climate_changed()
assert window_binary_sensor.state == STATE_OFF
async def test_motion_binary_sensors(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the motion binary sensors in thermostat type"""
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: True,
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_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
motion_binary_sensor: MotionBinarySensor = search_entity(
hass, "binary_sensor.theoverswitchmockname_motion_state", "binary_sensor"
)
assert motion_binary_sensor
now: datetime = datetime.now(tz=get_tz(hass))
# Overpowering should be not set because poer have not been received
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, now)
assert entity.motion_state is None
await motion_binary_sensor.async_my_climate_changed()
assert motion_binary_sensor.state is STATE_OFF
assert motion_binary_sensor.device_class == BinarySensorDeviceClass.MOTION
# Detect motion
with patch("homeassistant.helpers.condition.state", return_value=True):
try_motion_condition = await send_motion_change_event(entity, True, False, now)
# simulate the call to try_window_condition
await try_motion_condition(None)
assert entity.motion_state is STATE_ON
# Simulate the event reception
await motion_binary_sensor.async_my_climate_changed()
assert motion_binary_sensor.state == STATE_ON
# Undetect motion
with patch("homeassistant.helpers.condition.state", return_value=True):
try_motion_condition = await send_motion_change_event(entity, False, True, now)
# simulate the call to try_motion_condition
await try_motion_condition(None)
assert entity.motion_state is STATE_OFF
# Simulate the event reception
await motion_binary_sensor.async_my_climate_changed()
assert motion_binary_sensor.state == STATE_OFF
async def test_presence_binary_sensors(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the presence binary sensors in thermostat type"""
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": 12,
"comfort_away_temp": 13,
"boost_away_temp": 14,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
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_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
presence_binary_sensor: PresenceBinarySensor = search_entity(
hass, "binary_sensor.theoverswitchmockname_presence_state", "binary_sensor"
)
assert presence_binary_sensor
now: datetime = datetime.now(tz=get_tz(hass))
# Overpowering should be not set because poer have not been received
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, now)
assert entity.presence_state is None
await presence_binary_sensor.async_my_climate_changed()
assert presence_binary_sensor.state is STATE_OFF
assert presence_binary_sensor.device_class == BinarySensorDeviceClass.PRESENCE
# Detect motion
await send_presence_change_event(entity, True, False, now)
assert entity.presence_state is STATE_ON
# Simulate the event reception
await presence_binary_sensor.async_my_climate_changed()
assert presence_binary_sensor.state == STATE_ON
# Undetect motion
await send_presence_change_event(entity, False, True, now)
assert entity.presence_state is STATE_OFF
# Simulate the event reception
await presence_binary_sensor.async_my_climate_changed()
assert presence_binary_sensor.state == STATE_OFF
async def test_binary_sensors_over_climate_minimal(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the binary sensors with thermostat over climate type"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=the_mock_underlying,
):
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
assert entity.is_over_climate
security_binary_sensor: SecurityBinarySensor = search_entity(
hass, "binary_sensor.theoverclimatemockname_security_state", "binary_sensor"
)
assert security_binary_sensor is not None
overpowering_binary_sensor: OverpoweringBinarySensor = search_entity(
hass, "binary_sensor.theoverclimatemockname_overpowering_state", "binary_sensor"
)
assert overpowering_binary_sensor is None
window_binary_sensor: WindowBinarySensor = search_entity(
hass, "binary_sensor.theoverclimatemockname_window_state", "binary_sensor"
)
assert window_binary_sensor is None
motion_binary_sensor: MotionBinarySensor = search_entity(
hass, "binary_sensor.theoverclimatemockname_motion_state", "binary_sensor"
)
assert motion_binary_sensor is None
presence_binary_sensor: PresenceBinarySensor = search_entity(
hass, "binary_sensor.theoverclimatemockname_presence_state", "binary_sensor"
)
assert presence_binary_sensor is None

View File

@@ -0,0 +1,335 @@
""" 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.climate.VersatileThermostat.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_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.climate.VersatileThermostat.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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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",
return_value=True,
):
await send_temperature_change_event(entity, 15, now)
try_window_condition = await send_window_change_event(entity, True, False, now)
# 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 == 2
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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=False
) as mock_condition, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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",
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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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",
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

@@ -0,0 +1,371 @@
""" Test the Versatile Thermostat config flow """
from homeassistant import data_entry_flow
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
from custom_components.versatile_thermostat.const import DOMAIN
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_show_form(hass: HomeAssistant) -> None:
"""Test that the form is served with no input"""
# Init the API
# hass.data["custom_components"] = None
# loader.async_get_custom_components(hass)
# VersatileThermostatAPI(hass)
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
async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_states_get):
"""Test the config flow with all thermostat_over_switch features"""
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=MOCK_TH_OVER_SWITCH_USER_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=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_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "motion"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_MOTION_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "power"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_POWER_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presence"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESENCE_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
| MOCK_TH_OVER_SWITCH_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
)
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_over_climate(hass: HomeAssistant, skip_hass_states_get):
"""Test the config flow with all thermostat_over_climate features and no additional features"""
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=MOCK_TH_OVER_CLIMATE_USER_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=MOCK_TH_OVER_CLIMATE_TYPE_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_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "motion"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_MOTION_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "power"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_POWER_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "presence"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_PRESENCE_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_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
| MOCK_DEFAULT_FEATURE_CONFIG
)
assert result["result"]
assert result["result"].domain == DOMAIN
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"
}

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

@@ -0,0 +1,447 @@
""" Test the Power management """
from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
from homeassistant.const import UnitOfTemperature
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_power_management_hvac_off(
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": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.overpowering_state is None
assert entity.hvac_mode == HVACMode.OFF
# Send power mesurement
await send_power_change_event(entity, 50, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is not complete
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
# Send power max mesurement
await send_max_power_change_event(entity, 300, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
# Send power max mesurement too low but HVACMode is 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"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
# All configuration is complete and power is > power_max but we stay in Boost cause thermostat if Off
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is True
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
async def test_power_management_hvac_on(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": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
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 == 19
# Send power mesurement
await send_power_change_event(entity, 50, datetime.now())
# Send power max mesurement
await send_max_power_change_event(entity, 300, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
# Send power max mesurement too low and HVACMode is on
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
# All configuration is complete and power is > power_max we switch to POWER preset
assert entity.preset_mode is PRESET_POWER
assert entity.overpowering_state is True
assert entity.target_temperature == 12
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_POWER}),
call.send_event(
EventType.POWER_EVENT,
{
"type": "start",
"current_power": 50,
"device_power": 100,
"current_power_max": 149,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 1
# Send power mesurement low to unseet power preset
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_power_change_event(entity, 48, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max, we restore previous preset
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
assert entity.target_temperature == 19
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_BOOST}),
call.send_event(
EventType.POWER_EVENT,
{
"type": "end",
"current_power": 48,
"device_power": 100,
"current_power_max": 149,
},
),
],
any_order=True,
)
# No current temperature is set so the heater wont be turned on
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
async def test_power_management_energy_over_switch(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management energy mesurement"""
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: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
assert entity.total_energy == 0
# set temperature to 15 so that on_percent will be set
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.current_temperature == 15
assert tpi_algo.on_percent == 1
assert entity.mean_cycle_power == 100.0
assert mock_send_event.call_count == 2
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
entity.incremente_energy()
assert entity.total_energy == 100 * 5 / 60.0
entity.incremente_energy()
assert entity.total_energy == 2 * 100 * 5 / 60.0
# change temperature to a higher value
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 18, datetime.now())
assert tpi_algo.on_percent == 0.3
assert entity.mean_cycle_power == 30.0
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.3) * 100 * 5 / 60.0, 2)
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
# change temperature to a much higher value so that heater will be shut down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 20, datetime.now())
assert tpi_algo.on_percent == 0.0
assert entity.mean_cycle_power == 0.0
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
entity.incremente_energy()
# No change on energy
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
# Still no change
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
async def test_power_management_energy_over_climate(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management for a over_climate thermostat"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=the_mock_underlying,
):
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: True,
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,
CONF_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverclimatemockname"
)
assert entity
assert entity.is_over_climate
now = datetime.now(tz=get_tz(hass))
await send_temperature_change_event(entity, 15, now)
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.hvac_action is HVACAction.IDLE
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.current_temperature == 15
# Not initialised yet
assert entity.mean_cycle_power is None
assert entity._underlying_climate_start_hvac_action_date is None
# Send a climate_change event with HVACAction=HEATING
event_timestamp = now - timedelta(minutes=3)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.HEATING,
old_hvac_action=HVACAction.OFF,
date=event_timestamp,
)
# We have the start event and not the end event
assert (entity._underlying_climate_start_hvac_action_date - now).total_seconds() < 1
entity.incremente_energy()
assert entity.total_energy == 0
# Send a climate_change event with HVACAction=IDLE (end of heating)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.IDLE,
old_hvac_action=HVACAction.HEATING,
date=now,
)
# We have the end event -> we should have some power and on_percent
assert entity._underlying_climate_start_hvac_action_date is None
# 3 minutes at 100 W
assert entity.total_energy == 100 * 3.0 / 60
# Test the re-increment
entity.incremente_energy()
assert entity.total_energy == 2 * 100 * 3.0 / 60

View File

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

View File

@@ -0,0 +1,375 @@
""" Test the normal start of a Thermostat """
from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACMode
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAGE
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..sensor import (
EnergySensor,
MeanPowerSensor,
OnPercentSensor,
OnTimeSensor,
OffTimeSensor,
LastTemperatureSensor,
LastExtTemperatureSensor,
)
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_sensors_over_switch(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the sensors with a thermostat avec switch type"""
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.3,
CONF_DEVICE_POWER: 200,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
energy_sensor: EnergySensor = search_entity(
hass, "sensor.theoverswitchmockname_energy", "sensor"
)
assert energy_sensor
mean_power_sensor: MeanPowerSensor = search_entity(
hass, "sensor.theoverswitchmockname_mean_power_cycle", "sensor"
)
assert mean_power_sensor
on_percent_sensor: OnPercentSensor = search_entity(
hass, "sensor.theoverswitchmockname_power_percent", "sensor"
)
assert on_percent_sensor
on_time_sensor: OnTimeSensor = search_entity(
hass, "sensor.theoverswitchmockname_on_time", "sensor"
)
assert on_time_sensor
off_time_sensor: OffTimeSensor = search_entity(
hass, "sensor.theoverswitchmockname_off_time", "sensor"
)
assert off_time_sensor
last_temperature_sensor: LastTemperatureSensor = search_entity(
hass, "sensor.theoverswitchmockname_last_temperature_date", "sensor"
)
assert last_temperature_sensor
last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity(
hass, "sensor.theoverswitchmockname_last_external_temperature_date", "sensor"
)
assert last_ext_temperature_sensor
# Simulate the event reception
await energy_sensor.async_my_climate_changed()
assert energy_sensor.state == 0.0
await mean_power_sensor.async_my_climate_changed()
assert mean_power_sensor.state == 0.0
await on_percent_sensor.async_my_climate_changed()
assert on_percent_sensor.state == 0.0
await on_time_sensor.async_my_climate_changed()
assert on_time_sensor.state == 0.0
await off_time_sensor.async_my_climate_changed()
assert off_time_sensor.state == 300.0
await last_temperature_sensor.async_my_climate_changed()
assert last_temperature_sensor.state is not None
await last_ext_temperature_sensor.async_my_climate_changed()
assert last_ext_temperature_sensor.state is not None
last_temp_date = last_temperature_sensor.state
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
event_timestamp = now - timedelta(minutes=1)
# Start the heater to get some values
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 5, event_timestamp)
entity.incremente_energy()
await energy_sensor.async_my_climate_changed()
assert energy_sensor.state == 16.667
assert energy_sensor.device_class == SensorDeviceClass.ENERGY
assert energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING
# because device_power is 200
assert energy_sensor.unit_of_measurement == UnitOfEnergy.WATT_HOUR
await mean_power_sensor.async_my_climate_changed()
assert mean_power_sensor.state == 200.0
assert mean_power_sensor.device_class == SensorDeviceClass.POWER
assert mean_power_sensor.state_class == SensorStateClass.MEASUREMENT
# because device_power is 200
assert mean_power_sensor.unit_of_measurement == UnitOfPower.WATT
await on_percent_sensor.async_my_climate_changed()
assert on_percent_sensor.state == 100.0
assert on_percent_sensor.unit_of_measurement == PERCENTAGE
await on_time_sensor.async_my_climate_changed()
assert on_time_sensor.state == 300.0
assert on_time_sensor.device_class == SensorDeviceClass.DURATION
assert on_time_sensor.state_class == SensorStateClass.MEASUREMENT
assert on_time_sensor.unit_of_measurement == UnitOfTime.SECONDS
await off_time_sensor.async_my_climate_changed()
assert off_time_sensor.state == 0.0
assert off_time_sensor.device_class == SensorDeviceClass.DURATION
assert off_time_sensor.state_class == SensorStateClass.MEASUREMENT
assert off_time_sensor.unit_of_measurement == UnitOfTime.SECONDS
await last_temperature_sensor.async_my_climate_changed()
assert (
last_temperature_sensor.state is not None
and last_temperature_sensor.state != last_temp_date
)
assert last_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
await last_ext_temperature_sensor.async_my_climate_changed()
assert (
last_ext_temperature_sensor.state is not None
and last_ext_temperature_sensor.state != last_temp_date
)
assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
async def test_sensors_over_climate(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the sensors with thermostat over climate type"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=the_mock_underlying,
):
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: True,
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,
CONF_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 1.5,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverclimatemockname"
)
assert entity
assert entity.is_over_climate
energy_sensor: EnergySensor = search_entity(
hass, "sensor.theoverclimatemockname_energy", "sensor"
)
assert energy_sensor
last_temperature_sensor: LastTemperatureSensor = search_entity(
hass, "sensor.theoverclimatemockname_last_temperature_date", "sensor"
)
assert last_temperature_sensor
last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity(
hass, "sensor.theoverclimatemockname_last_external_temperature_date", "sensor"
)
assert last_ext_temperature_sensor
# Simulate the event reception
await energy_sensor.async_my_climate_changed()
assert energy_sensor.state == 0.0
await last_temperature_sensor.async_my_climate_changed()
assert last_temperature_sensor.state is not None
await last_ext_temperature_sensor.async_my_climate_changed()
assert last_ext_temperature_sensor.state is not None
last_temp_date = last_temperature_sensor.state
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
event_timestamp = now - timedelta(minutes=1)
# Start the heater to get some values
await entity.async_set_preset_mode(PRESET_COMFORT)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 5, event_timestamp)
# to add energy we must have HVACAction underlying climate event
# Send a climate_change event with HVACAction=HEATING
event_timestamp = now - timedelta(minutes=60)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.HEATING,
old_hvac_action=HVACAction.OFF,
date=event_timestamp,
)
# Send a climate_change event with HVACAction=IDLE (end of heating)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.IDLE,
old_hvac_action=HVACAction.HEATING,
date=now,
)
# 60 minutes heating with 1.5 kW heating -> 1.5 kWh
await energy_sensor.async_my_climate_changed()
assert energy_sensor.state == 1.5
assert energy_sensor.device_class == SensorDeviceClass.ENERGY
assert energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING
# because device_power is 1.5 kW
assert energy_sensor.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR
entity.incremente_energy()
await energy_sensor.async_my_climate_changed()
assert energy_sensor.state == 3.0
await last_temperature_sensor.async_my_climate_changed()
assert (
last_temperature_sensor.state is not None
and last_temperature_sensor.state != last_temp_date
)
assert last_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
await last_ext_temperature_sensor.async_my_climate_changed()
assert (
last_ext_temperature_sensor.state is not None
and last_ext_temperature_sensor.state != last_temp_date
)
assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
async def test_sensors_over_climate_minimal(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test the sensors with thermostat over climate type"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=the_mock_underlying,
):
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
assert entity.is_over_climate
energy_sensor: EnergySensor = search_entity(
hass, "sensor.theoverclimatemockname_energy", "sensor"
)
assert energy_sensor is None
last_temperature_sensor: LastTemperatureSensor = search_entity(
hass, "sensor.theoverclimatemockname_last_temperature_date", "sensor"
)
assert last_temperature_sensor
last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity(
hass, "sensor.theoverclimatemockname_last_external_temperature_date", "sensor"
)
assert last_ext_temperature_sensor

View File

@@ -0,0 +1,145 @@
""" Test the normal start of a Thermostat """
from unittest.mock import patch, call
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.config_entries import ConfigEntryState
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the normal full start of a thermostat in thermostat_over_switch type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data=FULL_SWITCH_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.theoverswitchmockname")
assert entity
assert entity.name == "TheOverSwitchMockName"
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
# 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},
),
]
)
async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the normal full start of a thermostat in thermostat_over_climate type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data=PARTIAL_CLIMATE_CONFIG,
)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=fake_underlying_climate,
) as mock_find_climate:
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 = find_my_entity("climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
assert entity._is_over_climate is True
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,
]
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
# 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},
),
]
)
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")]
)

View File

@@ -0,0 +1,83 @@
""" Test the TPI algorithm """
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the TPI calculation"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_USE_WINDOW_FEATURE: 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.3,
# CONF_DEVICE_POWER: 100,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
tpi_algo.calculate(15, 10, 7)
assert tpi_algo.on_percent == 1
assert tpi_algo.calculated_on_percent == 1
assert tpi_algo.on_time_sec == 300
assert tpi_algo.off_time_sec == 0
assert entity.mean_cycle_power is None # no device power configured
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.4
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 120
assert tpi_algo.off_time_sec == 180
tpi_algo.set_security(0.1)
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.1
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 30 # >= minimal_activation_delay (=30)
assert tpi_algo.off_time_sec == 270
tpi_algo.unset_security()
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.4
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 120
assert tpi_algo.off_time_sec == 180
# Test minimal activation delay
tpi_algo.calculate(15, 14.7, 15)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 0.09
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300
tpi_algo.set_security(0.09)
tpi_algo.calculate(15, 14.7, 15)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 0.09
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300

View File

@@ -0,0 +1,607 @@
""" 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_window_management_time_not_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Window 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,
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_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
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 == 19
assert entity.window_state is None
# Open the window, but condition of time is not satisfied and check the thermostat don't turns 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"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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)
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert mock_condition.call_count == 1
assert entity.window_state == STATE_OFF
# Close the window
try_window_condition = await send_window_change_event(
entity, False, False, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state == STATE_OFF
async def test_window_management_time_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Window management when time is 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,
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_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
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 == 19
assert entity.window_state is None
# Open the window, but condition of time is not satisfied and check the thermostat don't turns 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"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_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",
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)
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
assert mock_heater_off.call_count == 2
assert mock_condition.call_count == 1
assert entity.window_state == STATE_ON
# Close the window
try_window_condition = await send_window_change_event(
entity, False, True, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state == STATE_OFF
assert mock_heater_on.call_count == 2
assert mock_send_event.call_count == 2
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}
),
],
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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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
# Waits for automatic disable
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._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

@@ -14,6 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -48,10 +49,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": {
@@ -70,7 +81,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -88,16 +98,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"
@@ -117,6 +134,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -151,10 +169,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": {
@@ -173,7 +201,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -191,16 +218,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"

View File

@@ -13,6 +13,7 @@
"cycle_min": "Durée du cycle (minutes)",
"temp_min": "Température minimale permise",
"temp_max": "Température maximale permise",
"device_power": "Puissance de l'équipement",
"use_window_feature": "Avec détection des ouvertures",
"use_motion_feature": "Avec détection de mouvement",
"use_power_feature": "Avec gestion de la puissance",
@@ -49,8 +50,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": {
@@ -69,7 +80,6 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},
@@ -87,8 +97,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 +112,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é"
@@ -117,6 +134,7 @@
"cycle_min": "Durée du cycle (minutes)",
"temp_min": "Température minimale permise",
"temp_max": "Température maximale permise",
"device_power": "Puissance de l'équipement",
"use_window_feature": "Avec détection des ouvertures",
"use_motion_feature": "Avec détection de mouvement",
"use_power_feature": "Avec gestion de la puissance",
@@ -153,8 +171,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": {
@@ -173,7 +201,6 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},
@@ -191,8 +218,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 +233,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é"

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 34 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: 25 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: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB