Compare commits
23 Commits
2.2.0.beta
...
3.0.0.beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea7b6a0425 | ||
|
|
7c8717553b | ||
|
|
f672fc807d | ||
|
|
168568ac5d | ||
|
|
330c3323d1 | ||
|
|
e63213d22a | ||
|
|
fb7ee1bdac | ||
|
|
ca86b310c4 | ||
|
|
23074e6f46 | ||
|
|
718315c4fe | ||
|
|
46278ca9a3 | ||
|
|
0b81a94d0f | ||
|
|
33590886c1 | ||
|
|
039b372a53 | ||
|
|
a161540f10 | ||
|
|
8bbcafdf4a | ||
|
|
08d08e52de | ||
|
|
81b4f7e5f6 | ||
|
|
7a917c6ff7 | ||
|
|
20a9e2523e | ||
|
|
bb6e9edd06 | ||
|
|
d557263311 | ||
|
|
343596fb39 |
@@ -118,6 +118,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
|
||||
|
||||
194
README-fr.md
194
README-fr.md
@@ -25,20 +25,23 @@
|
||||
- [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)
|
||||
- [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)]._
|
||||
@@ -72,7 +75,7 @@ Ce composant nommé __Versatile thermostat__ gère les cas d'utilisation suivant
|
||||
- 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.
|
||||
- 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 et changer les paramètres de sécurité.
|
||||
|
||||
# Comment installer cet incroyable Thermostat Versatile ?
|
||||
|
||||
@@ -243,19 +246,23 @@ Le formulaire de configuration avancée est le suivant :
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
>  _*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 +274,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,8 +323,8 @@ 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
|
||||
|
||||
@@ -335,7 +356,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 +366,40 @@ target:
|
||||
>  _*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 :
|
||||
@@ -379,10 +434,11 @@ Les attributs personnalisés sont les suivants :
|
||||
| ``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 |
|
||||
| ``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é |
|
||||
| ``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`` | 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_state`` | 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 |
|
||||
@@ -507,6 +563,94 @@ 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)
|
||||
|
||||
182
README.md
182
README.md
@@ -26,19 +26,22 @@
|
||||
- [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)
|
||||
- [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)]._
|
||||
@@ -70,7 +73,7 @@ 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.
|
||||
|
||||
# How to install this incredible Versatile Thermostat ?
|
||||
|
||||
@@ -233,14 +236,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.
|
||||
|
||||
>  _*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
|
||||
|
||||
> _*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 +261,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,8 +309,8 @@ 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
|
||||
|
||||
@@ -330,6 +352,40 @@ target:
|
||||
>  _*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:
|
||||
@@ -365,6 +421,7 @@ Custom attributes are the following:
|
||||
| ``presence_state`` | 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 |
|
||||
| ``security_default_on_percent`` | The on_percent used when thermostat is in ``security`` |
|
||||
| ``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 |
|
||||
@@ -492,6 +549,93 @@ 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)
|
||||
|
||||
@@ -15,7 +15,7 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -58,13 +58,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,12 +96,11 @@ 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
|
||||
|
||||
|
||||
184
custom_components/versatile_thermostat/binary_sensor.py
Normal file
184
custom_components/versatile_thermostat/binary_sensor.py
Normal file
@@ -0,0 +1,184 @@
|
||||
""" 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
|
||||
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"
|
||||
|
||||
@callback
|
||||
async def async_my_climate_changed(self, event: Event):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
old_state = self._attr_is_on
|
||||
self._attr_is_on = self.my_climate.security_state
|
||||
if old_state != self._attr_is_on:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
@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"
|
||||
|
||||
@callback
|
||||
async def async_my_climate_changed(self, event: Event):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
old_state = self._attr_is_on
|
||||
self._attr_is_on = self.my_climate.overpowering_state
|
||||
if old_state != self._attr_is_on:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
@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"
|
||||
|
||||
@callback
|
||||
async def async_my_climate_changed(self, event: Event):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
old_state = self._attr_is_on
|
||||
self._attr_is_on = self.my_climate.window_state == STATE_ON
|
||||
if old_state != self._attr_is_on:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
if self._attr_is_on:
|
||||
return "mdi:window-open-variant"
|
||||
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"
|
||||
|
||||
@callback
|
||||
async def async_my_climate_changed(self, event: Event):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
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 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"
|
||||
|
||||
@callback
|
||||
async def async_my_climate_changed(self, event: Event):
|
||||
"""Called when my climate have change"""
|
||||
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
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 icon(self) -> str | None:
|
||||
if self._attr_is_on:
|
||||
return "mdi:home-account"
|
||||
else:
|
||||
return "mdi:nature-people"
|
||||
@@ -8,18 +8,23 @@ 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.event import (
|
||||
async_track_state_change_event,
|
||||
@@ -87,7 +92,8 @@ from homeassistant.const import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
# DOMAIN,
|
||||
DOMAIN,
|
||||
DEVICE_MANUFACTURER,
|
||||
CONF_HEATER,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_TEMP_SENSOR,
|
||||
@@ -114,6 +120,7 @@ from .const import (
|
||||
PROPORTIONAL_FUNCTION_TPI,
|
||||
SERVICE_SET_PRESENCE,
|
||||
SERVICE_SET_PRESET_TEMPERATURE,
|
||||
SERVICE_SET_SECURITY,
|
||||
PRESET_AWAY_SUFFIX,
|
||||
CONF_SECURITY_DELAY_MIN,
|
||||
CONF_SECURITY_MIN_ON_PERCENT,
|
||||
@@ -130,6 +137,8 @@ from .const import (
|
||||
CONF_CLIMATE,
|
||||
UnknownEntity,
|
||||
EventType,
|
||||
ATTR_MEAN_POWER_CYCLE,
|
||||
ATTR_TOTAL_ENERGY,
|
||||
)
|
||||
|
||||
from .prop_algorithm import PropAlgorithm
|
||||
@@ -178,6 +187,16 @@ async def async_setup_entry(
|
||||
"service_set_preset_temperature",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_SECURITY,
|
||||
{
|
||||
vol.Optional("delay_min"): cv.positive_int,
|
||||
vol.Optional("min_on_percent"): vol.Coerce(float),
|
||||
vol.Optional("default_on_percent"): vol.Coerce(float),
|
||||
},
|
||||
"service_set_security",
|
||||
)
|
||||
|
||||
|
||||
class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
"""Representation of a Versatile Thermostat device."""
|
||||
@@ -185,6 +204,14 @@ 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
|
||||
|
||||
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||
"""Initialize the thermostat."""
|
||||
@@ -235,8 +262,25 @@ 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._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"""
|
||||
|
||||
@@ -331,8 +375,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)
|
||||
@@ -345,8 +389,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)
|
||||
|
||||
@@ -380,8 +422,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
or 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
|
||||
|
||||
@@ -425,6 +467,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,
|
||||
@@ -534,6 +578,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)
|
||||
@@ -545,19 +597,16 @@ 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,
|
||||
@@ -751,10 +800,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
):
|
||||
self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
|
||||
self.save_preset_mode()
|
||||
else:
|
||||
self._attr_preset_mode = PRESET_NONE
|
||||
|
||||
if not self._hvac_mode and old_state.state:
|
||||
self._hvac_mode = old_state.state
|
||||
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:
|
||||
@@ -908,7 +964,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):
|
||||
@@ -956,6 +1012,59 @@ 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 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 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
|
||||
|
||||
def turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
@@ -1055,6 +1164,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
|
||||
@@ -1085,7 +1204,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"""
|
||||
@@ -1184,6 +1303,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,
|
||||
@@ -1192,9 +1327,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,
|
||||
@@ -1207,9 +1342,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,
|
||||
@@ -1234,8 +1369,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(_):
|
||||
@@ -1253,6 +1386,7 @@ 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)
|
||||
@@ -1275,12 +1409,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):
|
||||
@@ -1366,14 +1505,29 @@ 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)
|
||||
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,
|
||||
@@ -1382,20 +1536,66 @@ 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 = [
|
||||
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()
|
||||
@@ -1404,14 +1604,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
||||
|
||||
@callback
|
||||
async def _async_update_ext_temp(self, state):
|
||||
async def _async_update_ext_temp(self, state: State):
|
||||
"""Update thermostat with latest state from sensor."""
|
||||
try:
|
||||
cur_ext_temp = float(state.state)
|
||||
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
|
||||
raise ValueError(f"Sensor has illegal state {state.state}")
|
||||
self._cur_ext_temp = cur_ext_temp
|
||||
self._last_ext_temperature_mesure = datetime.now()
|
||||
self._last_ext_temperature_mesure = 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()
|
||||
@@ -1632,7 +1840,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",
|
||||
@@ -1641,6 +1862,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(
|
||||
@@ -1691,11 +1913,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
async def check_security(self) -> bool:
|
||||
"""Check if last temperature date is too long"""
|
||||
now = datetime.now()
|
||||
delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0
|
||||
delta_ext_temp = (
|
||||
now - self._last_ext_temperature_mesure
|
||||
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.replace(tzinfo=self._current_tz)
|
||||
).total_seconds() / 60.0
|
||||
|
||||
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
|
||||
|
||||
temp_cond: bool = (
|
||||
delta_temp > self._security_delay_min
|
||||
@@ -1709,11 +1935,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
not self._is_over_climate
|
||||
and self._prop_algorithm is not None
|
||||
and self._prop_algorithm.calculated_on_percent
|
||||
> self._security_min_on_percent
|
||||
>= 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 temp_cond and climate_cond:
|
||||
if mode_cond and temp_cond and climate_cond:
|
||||
if not self._security_state:
|
||||
_LOGGER.warning(
|
||||
"%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Set it into security mode",
|
||||
@@ -1725,17 +1962,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
)
|
||||
ret = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f temp_cond=%s climate_cond=%s switch_cond=%s",
|
||||
self,
|
||||
delta_temp,
|
||||
delta_ext_temp,
|
||||
temp_cond,
|
||||
climate_cond,
|
||||
switch_cond,
|
||||
)
|
||||
|
||||
if temp_cond and switch_cond:
|
||||
if mode_cond and temp_cond and switch_cond:
|
||||
if not self._security_state:
|
||||
_LOGGER.warning(
|
||||
"%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent (%.2f) is over defined value (%.2f). Set it into security mode",
|
||||
@@ -1748,12 +1975,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
)
|
||||
ret = True
|
||||
|
||||
if not self._security_state and temp_cond:
|
||||
if mode_cond and temp_cond and not self._security_state:
|
||||
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,
|
||||
@@ -1775,8 +2006,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,
|
||||
@@ -1805,8 +2040,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,
|
||||
@@ -1920,7 +2159,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,
|
||||
@@ -1934,6 +2172,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(
|
||||
@@ -1942,6 +2181,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)
|
||||
|
||||
@@ -1974,11 +2216,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],
|
||||
@@ -2006,16 +2269,29 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
"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),
|
||||
}
|
||||
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"
|
||||
@@ -2096,6 +2372,36 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
await self._async_set_preset_mode_internal(preset, force=True)
|
||||
await self._async_control_heating(force=True)
|
||||
|
||||
async def service_set_security(self, delay_min, min_on_percent, default_on_percent):
|
||||
"""Called by a service call:
|
||||
service: versatile_thermostat.set_security
|
||||
data:
|
||||
delay_min: 15
|
||||
min_on_percent: 0.5
|
||||
default_on_percent: 0.2
|
||||
target:
|
||||
entity_id: climate.thermostat_2
|
||||
"""
|
||||
_LOGGER.info(
|
||||
"%s - Calling service_set_security, delay_min: %s, min_on_percent: %s, default_on_percent: %s",
|
||||
self,
|
||||
delay_min,
|
||||
min_on_percent,
|
||||
default_on_percent,
|
||||
)
|
||||
if delay_min:
|
||||
self._security_delay_min = delay_min
|
||||
if min_on_percent:
|
||||
self._security_min_on_percent = min_on_percent
|
||||
if default_on_percent:
|
||||
self._security_default_on_percent = default_on_percent
|
||||
|
||||
if self._prop_algorithm and self._security_state:
|
||||
self._prop_algorithm.set_security(self._security_default_on_percent)
|
||||
|
||||
await self._async_control_heating()
|
||||
self.update_custom_attributes()
|
||||
|
||||
def send_event(self, event_type: EventType, data: dict):
|
||||
"""Send an event"""
|
||||
_LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data)
|
||||
|
||||
104
custom_components/versatile_thermostat/commons.py
Normal file
104
custom_components/versatile_thermostat/commons.py
Normal 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
|
||||
@@ -204,6 +204,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
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,
|
||||
@@ -290,7 +291,6 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
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),
|
||||
}
|
||||
)
|
||||
@@ -331,7 +331,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.
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
from enum import Enum
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.components.climate.const import (
|
||||
from homeassistant.components.climate import (
|
||||
# PRESET_ACTIVITY,
|
||||
PRESET_BOOST,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
)
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
DEVICE_MANUFACTURER = "JMCOLLIN"
|
||||
DEVICE_MODEL = "Versatile Thermostat"
|
||||
|
||||
from .prop_algorithm import (
|
||||
PROPORTIONAL_FUNCTION_TPI,
|
||||
)
|
||||
@@ -126,16 +129,22 @@ 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"
|
||||
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"""
|
||||
|
||||
SECURITY_EVENT: str = "versatile_thermostat_security_event"
|
||||
POWER_EVENT: str = "versatile_thermostat_power_event"
|
||||
TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event"
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
homeassistant
|
||||
@@ -0,0 +1,3 @@
|
||||
# -r requirements_dev.txt
|
||||
# aiodiscover
|
||||
pytest-homeassistant-custom-component
|
||||
358
custom_components/versatile_thermostat/sensor.py
Normal file
358
custom_components/versatile_thermostat/sensor.py
Normal file
@@ -0,0 +1,358 @@
|
||||
""" Implements the VersatileThermostat sensors component """
|
||||
import logging
|
||||
import math
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback, Event
|
||||
|
||||
from homeassistant.const import UnitOfTime
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_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),
|
||||
]
|
||||
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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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:
|
||||
return "kWh"
|
||||
|
||||
@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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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:
|
||||
return "kW"
|
||||
|
||||
@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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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 "%"
|
||||
|
||||
@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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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):
|
||||
"""Called when my climate have change"""
|
||||
_LOGGER.debug("%s - climate state change", event.origin.name)
|
||||
|
||||
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
|
||||
@@ -1,69 +1,117 @@
|
||||
set_presence:
|
||||
name: Set presence
|
||||
description: Force the presence mode in thermostat
|
||||
target:
|
||||
entity:
|
||||
integration: versatile_thermostat
|
||||
fields:
|
||||
presence:
|
||||
name: Presence
|
||||
description: Presence setting
|
||||
required: true
|
||||
advanced: false
|
||||
example: "on"
|
||||
default: "on"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "on"
|
||||
- "off"
|
||||
- "home"
|
||||
- "not_home"
|
||||
name: Set presence
|
||||
description: Force the presence mode in thermostat
|
||||
target:
|
||||
entity:
|
||||
integration: versatile_thermostat
|
||||
fields:
|
||||
presence:
|
||||
name: Presence
|
||||
description: Presence setting
|
||||
required: true
|
||||
advanced: false
|
||||
example: "on"
|
||||
default: "on"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "on"
|
||||
- "off"
|
||||
- "home"
|
||||
- "not_home"
|
||||
|
||||
set_preset_temperature:
|
||||
name: Set temperature preset
|
||||
description: Change the target temperature of a preset
|
||||
target:
|
||||
entity:
|
||||
integration: versatile_thermostat
|
||||
fields:
|
||||
preset:
|
||||
name: Preset
|
||||
description: Preset name
|
||||
required: true
|
||||
advanced: false
|
||||
example: "comfort"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "eco"
|
||||
- "comfort"
|
||||
- "boost"
|
||||
temperature:
|
||||
name: Temperature when present
|
||||
description: Target temperature for the preset when present
|
||||
required: false
|
||||
advanced: false
|
||||
example: "19.5"
|
||||
default: "17"
|
||||
selector:
|
||||
number:
|
||||
min: 7
|
||||
max: 35
|
||||
step: 0.1
|
||||
unit_of_measurement: °
|
||||
mode: slider
|
||||
temperature_away:
|
||||
name: Temperature when not present
|
||||
description: Target temperature for the preset when not present
|
||||
required: false
|
||||
advanced: false
|
||||
example: "17"
|
||||
default: "15"
|
||||
selector:
|
||||
number:
|
||||
min: 7
|
||||
max: 35
|
||||
step: 0.1
|
||||
unit_of_measurement: °
|
||||
mode: slider
|
||||
name: Set temperature preset
|
||||
description: Change the target temperature of a preset
|
||||
target:
|
||||
entity:
|
||||
integration: versatile_thermostat
|
||||
fields:
|
||||
preset:
|
||||
name: Preset
|
||||
description: Preset name
|
||||
required: true
|
||||
advanced: false
|
||||
example: "comfort"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "eco"
|
||||
- "comfort"
|
||||
- "boost"
|
||||
temperature:
|
||||
name: Temperature when present
|
||||
description: Target temperature for the preset when present
|
||||
required: false
|
||||
advanced: false
|
||||
example: "19.5"
|
||||
default: "17"
|
||||
selector:
|
||||
number:
|
||||
min: 7
|
||||
max: 35
|
||||
step: 0.1
|
||||
unit_of_measurement: °
|
||||
mode: slider
|
||||
temperature_away:
|
||||
name: Temperature when not present
|
||||
description: Target temperature for the preset when not present
|
||||
required: false
|
||||
advanced: false
|
||||
example: "17"
|
||||
default: "15"
|
||||
selector:
|
||||
number:
|
||||
min: 7
|
||||
max: 35
|
||||
step: 0.1
|
||||
unit_of_measurement: °
|
||||
mode: slider
|
||||
|
||||
set_security:
|
||||
name: Set security
|
||||
description: Change the security parameters
|
||||
target:
|
||||
entity:
|
||||
integration: versatile_thermostat
|
||||
fields:
|
||||
delay_min:
|
||||
name: Delay in minutes
|
||||
description: Maximum allowed delay in minutes between two temperature mesures
|
||||
required: false
|
||||
advanced: false
|
||||
example: "30"
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 9999
|
||||
unit_of_measurement: "min"
|
||||
mode: box
|
||||
min_on_percent:
|
||||
name: Minimal on_percent
|
||||
description: Minimal heating percent value for security preset activation
|
||||
required: false
|
||||
advanced: false
|
||||
example: "0.5"
|
||||
default: "0.5"
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1
|
||||
step: 0.05
|
||||
unit_of_measurement: "%"
|
||||
mode: slider
|
||||
default_on_percent:
|
||||
name: on_percent used in security mode
|
||||
description: The default heating percent value in security preset
|
||||
required: false
|
||||
advanced: false
|
||||
example: "0.1"
|
||||
default: "0.1"
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1
|
||||
step: 0.05
|
||||
unit_of_measurement: "%"
|
||||
mode: slider
|
||||
|
||||
@@ -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",
|
||||
@@ -70,7 +71,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"
|
||||
}
|
||||
},
|
||||
@@ -117,6 +117,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",
|
||||
@@ -173,7 +174,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"
|
||||
}
|
||||
},
|
||||
|
||||
1
custom_components/versatile_thermostat/tests/__init__.py
Normal file
1
custom_components/versatile_thermostat/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""" To make this repo a module """
|
||||
271
custom_components/versatile_thermostat/tests/commons.py
Normal file
271
custom_components/versatile_thermostat/tests/commons.py
Normal file
@@ -0,0 +1,271 @@
|
||||
""" Some common resources """
|
||||
from typing import Mapping
|
||||
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_component import EntityComponent
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from ..climate import VersatileThermostat
|
||||
from ..const import *
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ATTR_PRESET_MODE,
|
||||
HVACMode,
|
||||
HVACAction,
|
||||
ClimateEntityFeature,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
MOCK_TH_OVER_SWITCH_USER_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
|
||||
MOCK_PRESETS_CONFIG,
|
||||
MOCK_WINDOW_CONFIG,
|
||||
MOCK_MOTION_CONFIG,
|
||||
MOCK_POWER_CONFIG,
|
||||
MOCK_PRESENCE_CONFIG,
|
||||
MOCK_ADVANCED_CONFIG,
|
||||
# MOCK_DEFAULT_FEATURE_CONFIG,
|
||||
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):
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
return HVACMode.HEAT
|
||||
|
||||
@property
|
||||
def hvac_action(self):
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
return 15
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
return 14
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float | None:
|
||||
return 0.5
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
return 35
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
return 7
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[str] | None:
|
||||
return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str] | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
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
|
||||
|
||||
entity = find_my_entity(entity_id)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
|
||||
"""Sending a new temperature event simulating a change on temperature sensor"""
|
||||
temp_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
"new_state": State(
|
||||
entity_id=entity.entity_id,
|
||||
state=new_temp,
|
||||
last_changed=date,
|
||||
last_updated=date,
|
||||
)
|
||||
},
|
||||
)
|
||||
return await entity._async_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
|
||||
|
||||
|
||||
def get_tz(hass):
|
||||
"""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)
|
||||
80
custom_components/versatile_thermostat/tests/conftest.py
Normal file
80
custom_components/versatile_thermostat/tests/conftest.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""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 HomeAssistant, StateMachine
|
||||
|
||||
from custom_components.versatile_thermostat.config_flow import (
|
||||
VersatileThermostatBaseConfigFlow,
|
||||
)
|
||||
|
||||
from custom_components.versatile_thermostat.climate import (
|
||||
VersatileThermostat,
|
||||
)
|
||||
|
||||
pytest_plugins = "pytest_homeassistant_custom_component"
|
||||
|
||||
|
||||
# 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):
|
||||
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
|
||||
|
||||
|
||||
# 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_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
|
||||
130
custom_components/versatile_thermostat/tests/const.py
Normal file
130
custom_components/versatile_thermostat/tests/const.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from homeassistant.components.climate.const import (
|
||||
PRESET_BOOST,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_NONE,
|
||||
PRESET_ACTIVITY,
|
||||
)
|
||||
from custom_components.versatile_thermostat.const import (
|
||||
CONF_NAME,
|
||||
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_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_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,
|
||||
}
|
||||
223
custom_components/versatile_thermostat/tests/test_config_flow.py
Normal file
223
custom_components/versatile_thermostat/tests/test_config_flow.py
Normal file
@@ -0,0 +1,223 @@
|
||||
""" 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
|
||||
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
|
||||
|
||||
from custom_components.versatile_thermostat.const import DOMAIN
|
||||
from custom_components.versatile_thermostat import VersatileThermostatAPI
|
||||
|
||||
from .const import (
|
||||
MOCK_TH_OVER_SWITCH_USER_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
|
||||
MOCK_PRESETS_CONFIG,
|
||||
MOCK_WINDOW_CONFIG,
|
||||
MOCK_MOTION_CONFIG,
|
||||
MOCK_POWER_CONFIG,
|
||||
MOCK_PRESENCE_CONFIG,
|
||||
MOCK_ADVANCED_CONFIG,
|
||||
MOCK_DEFAULT_FEATURE_CONFIG,
|
||||
)
|
||||
|
||||
|
||||
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, 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, 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)
|
||||
447
custom_components/versatile_thermostat/tests/test_power.py
Normal file
447
custom_components/versatile_thermostat/tests/test_power.py
Normal file
@@ -0,0 +1,447 @@
|
||||
""" Test the Power management """
|
||||
from unittest.mock import patch, call, MagicMock
|
||||
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: "eco",
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
188
custom_components/versatile_thermostat/tests/test_security.py
Normal file
188
custom_components/versatile_thermostat/tests/test_security.py
Normal 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)
|
||||
|
||||
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
|
||||
145
custom_components/versatile_thermostat/tests/test_start.py
Normal file
145
custom_components/versatile_thermostat/tests/test_start.py
Normal 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 *
|
||||
|
||||
|
||||
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")]
|
||||
)
|
||||
83
custom_components/versatile_thermostat/tests/test_tpi.py
Normal file
83
custom_components/versatile_thermostat/tests/test_tpi.py
Normal 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
|
||||
200
custom_components/versatile_thermostat/tests/test_window.py
Normal file
200
custom_components/versatile_thermostat/tests/test_window.py
Normal file
@@ -0,0 +1,200 @@
|
||||
""" Test the Window management """
|
||||
from unittest.mock import patch, call
|
||||
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
import logging
|
||||
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def test_window_management_time_not_enough(
|
||||
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: 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 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: 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
|
||||
@@ -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",
|
||||
@@ -70,7 +71,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"
|
||||
}
|
||||
},
|
||||
@@ -117,6 +117,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",
|
||||
@@ -173,7 +174,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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -69,7 +70,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"
|
||||
}
|
||||
},
|
||||
@@ -117,6 +117,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",
|
||||
@@ -173,7 +174,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"
|
||||
}
|
||||
},
|
||||
|
||||
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: 42 KiB |
Reference in New Issue
Block a user