Compare commits

...

13 Commits

Author SHA1 Message Date
Jean-Marc Collin
954fc6271c FIX entity_not found error 2023-10-21 13:25:49 +00:00
Jean-Marc Collin
6dc078871d FIX merge from #108 have lost some changes 2023-10-21 10:31:56 +00:00
Jean-Marc Collin
cd0ab3c88d Replace tests at the right place. Add missing config files. Run tests 2023-10-21 10:08:20 +00:00
Andrea Nicotra
eb54f2826f move AC mode config under the right configuration step (#108)
* move AC mode config under the right configuration step
2023-10-21 08:31:45 +02:00
Jean-Marc Collin
043fd5f7aa Feature 128 activity preset (#132)
* Issue #128 - add motion_off_delay

* Fix movement detection

---------

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2023-10-21 08:28:19 +02:00
Jean-Marc Collin
7e4e407732 Issue #125 - remove thermometers for over climate mode. 2023-10-18 11:34:19 +02:00
Jean-Marc Collin
81900ceeea Issue #120 - presence sensor not updated 2023-10-15 19:04:46 +02:00
Jean-Marc Collin
66297c6044 FIX non catched error (see issue #120) 2023-10-15 18:39:13 +02:00
Jean-Marc Collin
f384225b0f Maj documentation + issue #115 2023-10-15 18:16:24 +02:00
Jean-Marc Collin
e6ecd100f6 Issue #119 - Set preset target temperature not updating in ac mode 2023-10-15 12:22:15 +02:00
Jean-Marc Collin
7ac49d7864 undo remove other packages. Test don't run into gitlab-ci environment 2023-10-15 10:43:59 +02:00
Jean-Marc Collin
40da04838d Bug #121 loop in over climate
* Issue #121 - loop when underlying is slow
* Issue #121 - try fix
* Release 3.5.3
* Fix tests step
2023-10-15 10:29:54 +02:00
Jean-Marc Collin
fcdd93b4ae Issue # 114 - add unit test for multi-climate VTherm 2023-10-08 13:08:34 +02:00
47 changed files with 1422 additions and 387 deletions

View File

@@ -1,6 +1,4 @@
echo "Sourcing .bashrc"
alias ll='ls -l'
export HA='/home/vscode/core'
cd $HA
source venv/bin/activate
# source venv/bin/activate

View File

@@ -114,18 +114,22 @@ climate:
name: Underlying thermostat 4-1
heater: input_boolean.fake_heater_4climate1
target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat
name: Underlying thermostat 4-2
heater: input_boolean.fake_heater_4climate2
target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat
name: Underlying thermostat 4-3
heater: input_boolean.fake_heater_4climate3
target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat
name: Underlying thermostat 4-4
heater: input_boolean.fake_heater_4climate4
target_sensor: input_number.fake_temperature_sensor1
ac_mode: true
- platform: generic_thermostat
name: Underlying thermostat9
heater: input_boolean.fake_heater_switch3

View File

@@ -1,44 +1,54 @@
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
// "image": "ghcr.io/ludeeus/devcontainer/integration:latest",
{
"image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10",
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
"name": "Versatile Thermostat integration",
"context": "..",
"appPort": [
"9123:8123"
],
// "postCreateCommand": "container install",
"postCreateCommand": "./container install",
"extensions": [
"ms-python.python",
"github.vscode-pull-request-github",
"ryanluker.vscode-coverage-gutters",
"ms-python.vscode-pylance"
],
"postCreateCommand": "./container dev-setup",
"mounts": [
"source=/Users/jmcollin/SugarSync/Projets/home-assistant/core,target=/home/vscode/core,type=bind,consistency=cached",
"source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=/home/vscode/core/config/configuration.yaml,type=bind,consistency=cached",
"source=${localWorkspaceFolder}/custom_components,target=/home/vscode/core/config/custom_components,type=bind,consistency=cached"
"source=/Users/jmcollin/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"settings": {
"files.eol": "\n",
"editor.tabSize": 4,
"terminal.integrated.profiles.linux": {
"Bash Profile": {
"path": "bash",
"args": []
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"github.vscode-pull-request-github",
"ryanluker.vscode-coverage-gutters",
"ms-python.vscode-pylance"
],
// "mounts": [
// "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=/home/vscode/core/config/configuration.yaml,type=bind,consistency=cached",
// "source=${localWorkspaceFolder}/custom_components,target=/home/vscode/core/config/custom_components,type=bind,consistency=cached"
// ],
"settings": {
"files.eol": "\n",
"editor.tabSize": 4,
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"args": []
}
},
"terminal.integrated.defaultProfile.linux": "bash",
// "terminal.integrated.shell.linux": "/bin/bash",
"python.pythonPath": "/usr/bin/python3",
"python.analysis.autoSearchPaths": true,
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true,
"python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.analysis.logLevel": "Trace"
}
},
"terminal.integrated.defaultProfile.linux": "Bash Profile",
// "terminal.integrated.shell.linux": "/bin/bash",
"python.pythonPath": "/usr/bin/python3",
"python.analysis.autoSearchPaths": true,
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true
}
}
}

View File

@@ -31,6 +31,8 @@ jobs:
- run: black .
tests:
# Tests don't run in Gitlab ci environment
if: 0
runs-on: "ubuntu-latest"
name: Run tests
steps:
@@ -41,10 +43,10 @@ jobs:
with:
python-version: "3.8"
- name: Install requirements
run: python3 -m pip install -r requirements_test.txt
run: cd custom_components/versatile_thermostat && python3 -m pip install -r requirements_test.txt
- name: Run tests
run: |
pytest \
cd custom_components/versatile_thermostat && pytest \
-qq \
--timeout=9 \
--durations=10 \

6
.gitignore vendored
View File

@@ -103,4 +103,8 @@ dist
# TernJS port file
.tern-port
__pycache__
# init file required for unittest
custom_components/__init__.py
__pycache__
config/**

35
.vscode/launch.json vendored
View File

@@ -3,36 +3,15 @@
"version": "0.2.0",
"configurations": [
{
// Example of attaching to local debug server
"name": "Python: Attach Local",
"name": "Home Assistant (debug)",
"type": "python",
"request": "attach",
"port": 5678,
"host": "localhost",
"request": "launch",
"module": "homeassistant",
"justMyCode": false,
"pathMappings": [
// {
// "localRoot": "${workspaceFolder}",
// "remoteRoot": "."
//},
{
"localRoot": "${workspaceFolder}/../core",
"remoteRoot": "/home/vscode/core"
}
]
},
{
// Example of attaching to my production server
"name": "Python: Attach Remote",
"type": "python",
"request": "attach",
"port": 5678,
"host": "homeassistant.local",
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/usr/src/homeassistant"
}
"args": [
"--debug",
"-c",
"config"
]
}
]

16
.vscode/settings.json vendored
View File

@@ -1,12 +1,20 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.pythonPath": "/usr/local/bin/python",
"files.associations": {
"*.yaml": "home-assistant"
},
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.extraPaths": [
"/home/vscode/core",
"/workspaces/versatile_thermostat"
]
// "/home/vscode/core",
"/workspaces/custom_components/versatile_thermostat"
],
"python.formatting.provider": "none"
}

61
CONTRIBUTING-fr.md Normal file
View File

@@ -0,0 +1,61 @@
# Consignes de contribution
Contribuer à ce projet doit être aussi simple et transparent que possible, que ce soit :
- Signaler un bug
- Discuter de l'état actuel du code
- Soumettre un correctif
- Proposer de nouvelles fonctionnalités
## Github est utilisé pour tout
Github est utilisé pour héberger du code, pour suivre les problèmes et les demandes de fonctionnalités, ainsi que pour accepter les demandes d'extraction.
Les demandes d'extraction sont le meilleur moyen de proposer des modifications à la base de code.
1. Fourchez le dépôt et créez votre branche à partir de `master`.
2. Si vous avez modifié quelque chose, mettez à jour la documentation.
3. Assurez-vous que votre code peluche (en utilisant du noir).
4. Testez votre contribution.
5. Émettez cette pull request !
## Toutes les contributions que vous ferez seront sous la licence logicielle MIT
En bref, lorsque vous soumettez des modifications de code, vos soumissions sont considérées comme étant sous la même [licence MIT](http://choosealicense.com/licenses/mit/) qui couvre le projet. N'hésitez pas à contacter les mainteneurs si cela vous préoccupe.
## Signaler les bogues en utilisant les [issues] de Github (../../issues)
Les problèmes GitHub sont utilisés pour suivre les bogues publics.
Signalez un bogue en [ouvrant un nouveau problème](../../issues/new/choose) ; C'est si facile!
## Rédiger des rapports de bogue avec des détails, un arrière-plan et un exemple de code
Les **rapports de bogues géniaux** ont tendance à avoir :
- Un résumé rapide et/ou un historique
- Étapes à reproduire
- Être spécifique!
- Donnez un exemple de code si vous le pouvez.
- Ce à quoi vous vous attendiez arriverait
- Que se passe-t-il réellement
- Notes (y compris éventuellement pourquoi vous pensez que cela pourrait se produire, ou des choses que vous avez essayées qui n'ont pas fonctionné)
Les gens *adorent* les rapports de bogues approfondis. Je ne plaisante même pas.
## Utilisez un style de codage cohérent
Utilisez [black](https://github.com/ambv/black) pour vous assurer que le code suit le style.
## Testez votre modification de code
Ce composant personnalisé est basé sur les meilleures pratiques décrites ici [modèle d'intégration_blueprint](https://github.com/custom-components/integration_blueprint).
Il est livré avec un environnement de développement dans un conteneur, facile à lancer
si vous utilisez Visual Studio Code. Avec ce conteneur, vous aurez un stand alone
Instance de Home Assistant en cours d'exécution et déjà configurée avec le inclus
[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml)
déposer.
## Licence
En contribuant, vous acceptez que vos contributions soient autorisées sous sa licence MIT.

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contribution guidelines
Contributing to this project should be as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
## Github is used for everything
Github is used to host code, to track issues and feature requests, as well as accept pull requests.
Pull requests are the best way to propose changes to the codebase.
1. Fork the repo and create your branch from `master`.
2. If you've changed something, update the documentation.
3. Make sure your code lints (using black).
4. Test you contribution.
5. Issue that pull request!
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](../../issues)
GitHub issues are used to track public bugs.
Report a bug by [opening a new issue](../../issues/new/choose); it's that easy!
## Write bug reports with detail, background, and sample code
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- Give sample code if you can.
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
People *love* thorough bug reports. I'm not even kidding.
## Use a Consistent Coding Style
Use [black](https://github.com/ambv/black) to make sure the code follows the style.
## Test your code modification
This custom component is based on best practices described here [integration_blueprint template](https://github.com/custom-components/integration_blueprint).
It comes with development environment in a container, easy to launch
if you use Visual Studio Code. With this container you will have a stand alone
Home Assistant instance running and already configured with the included
[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml)
file.
## License
By contributing, you agree that your contributions will be licensed under its MIT License.

View File

@@ -26,6 +26,7 @@
- [Configurer la gestion de la puissance](#configurer-la-gestion-de-la-puissance)
- [Configurer la présence ou l'occupation](#configurer-la-présence-ou-loccupation)
- [Configuration avancée](#configuration-avancée)
- [Synthèse des paramètres](#synthèse-des-paramètres)
- [Exemples de réglage](#exemples-de-réglage)
- [Chauffage électrique](#chauffage-électrique)
- [Chauffage central (chauffage gaz ou fuel)](#chauffage-central-chauffage-gaz-ou-fuel)
@@ -54,6 +55,7 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une
> ![Nouveau](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_
> * **Release 3.6**: Ajout du paramètre `motion_off_delay` pour la gestion de l'activité.
> * **Release 3.5**: Plusieurs thermostats sont possibles en "thermostat over climate" mode [#113](https://github.com/jmcollin78/versatile_thermostat/issues/113)
> * **Release 3.4**: bug fix et exposition des preset temperatures pour le mode AC [#103](https://github.com/jmcollin78/versatile_thermostat/issues/103)
> * **Release 3.3**: ajout du mode Air Conditionné (AC). Cette fonction vous permet d'utiliser le mode AC de votre thermostat sous-jacent. Pour l'utiliser, vous devez cocher l'option "Uitliser le mode AC" et définir les valeurs de température pour les presets et pour les presets en cas d'absence
@@ -65,7 +67,7 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une
> * **release majeure 2.0** : ajout du thermostat "over climate" permettant de transformer n'importe quel thermostat en Versatile Thermostat et lui ajouter toutes les fonctions de ce dernier.
# Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Un grand merci à @salabur, @pvince83 and @bergoglio pour les bières. Ca fait très plaisir.
Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @Kriss1670, @maia pour les bières. Ca fait très plaisir.
# Quand l'utiliser et ne pas l'utiliser
@@ -75,9 +77,7 @@ Ce thermostat peut piloter 2 types d'équipement:
b. une sonde de température pour la pièce (ou un input_number),
c. un capteur de température externe (pensez à l'intégration météo si vous n'en avez pas)
2. un autre thermostat qui a ses propres modes de fonctionnement (nommé ```thermostat_over_climate```). Pour ce type de thermostat la configuration minimale nécessite :
a. un équipement comme une climatisation qui est pilotée par sa propre entity de type ```climate```,
b. une sonde de température pour la pièce (ou un input_number),
c. un capteur de température externe (pensez à l'intégration météo si vous n'en avez pas)
a. un équipement - comme une climatisation une valve thermostatique - qui est pilotée par sa propre entity de type ```climate```,
Le type ```thermostat_over_climate``` permet d'ajouter à votre équipement existant toutes les fonctionnalités fournies par VersatileThermostat. L'entité climate VersatileThermostat pilotera votre entité climate, en la coupant si les fenêtres sont ouvertes, la passant en mode Eco si personne n'est présent, etc. Cf. [ici](#pourquoi-une-nouvelle-implémentation-du-thermostat). Pour ce type de thermostat, les cycles éventuels de chauffe sont pilotés par l'entité climate sous-jacente et pas par le Versatile Thermostat lui-même.
@@ -243,17 +243,20 @@ Si vous avez choisi la fonctionnalité ```Avec détection de mouvement```, cliqu
Nous allons maintenant voir comment configurer le nouveau mode Activité.
Ce dont nous avons besoin:
- un **capteur de mouvement**. ID d'entité d'un capteur de mouvement. Les états du capteur de mouvement doivent être « on » (mouvement détecté) ou « off » (aucun mouvement détecté)
- une durée de **délai de mouvement** (en secondes) définissant combien de temps nous attendons la confirmation du mouvement avant de considérer le mouvement
- une durée de **délai de mouvement** (en secondes) définissant combien de temps nous attendons la confirmation du mouvement avant de considérer le mouvement. Ce paramètre peut être supérieur à la temporision de votre détecteur de mouvement, sinon la détection se fera à chaque mouvement signalé par le détecteur,
- une durée de fin **délai de mouvement** (en secondes) définissant combien de temps nous attendons la confirmation d'une fin de mouvement avant de ne plus considérer le mouvement.
- un **préréglage de "mouvement" **. Nous utiliserons la température de ce préréglage lorsqu'une activité sera détectée.
- un **préréglage "pas de mouvement"**. Nous utiliserons la température de ce deuxième préréglage lorsqu'aucune activité n'est détectée.
Alors imaginons que nous voulions avoir le comportement suivant :
- nous avons une pièce avec un thermostat réglé en mode activité, le mode "mouvement" choisi est confort (21.5C), le mode "pas de mouvement" choisi est Eco (18.5C) et la temporisation du mouvement est de 5 min.
- la pièce est vide depuis un moment (aucune activité détectée), la température de cette pièce est de 18,5 C
- quelqu'un entre dans la pièce, une activité est détectée la température est fixée à 21,5 C
- la personne quitte la chambre, au bout de 5 min la température est ramenée à 18,5 C
- nous avons une pièce avec un thermostat réglé en mode activité, le mode "mouvement" choisi est confort (21,5°C), le mode "pas de mouvement" choisi est Eco (18.5°C) et la temporisation du mouvement est de 30 sec lors de la détection et de 5 minutes sur fin de détection.
- la pièce est vide depuis un moment (aucune activité détectée), la température de cette pièce est de 18,5°
- quelqu'un entre dans la pièce, une activité est détectée si le mouvement est présent pendant au moins 30 sec. La température passe alors à 21,5°
- si le mouvement est présent pendant moins de 30 sec (passage rapide), la température reste sur 18,5°,
- imaginons que la température soit passée sur 21,5°, lorsque la personne quitte la pièce, au bout de 5 min la température est ramenée à 18,5°.
- si la personne revient avant les 5 minutes, la température reste sur 21,5°
Pour que cela fonctionne, le thermostat climatique doit être en mode préréglé « Activité ».
Pour que cela fonctionne, le thermostat doit être en mode préréglé « Activité ».
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
1. Sachez que comme pour les autres modes prédéfinis, ``Activity`` ne sera proposé que s'il est correctement configuré. En d'autres termes, les 4 clés de configuration doivent être définies si vous souhaitez voir l'activité dans l'interface de l'assistant domestique
@@ -315,6 +318,65 @@ Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglag
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.
## Synthèse des paramètres
| Paramètre | Libellé | "over switch" | "over climate" |
| ----------| --------| --- | ---|
| ``name`` | Nom | X | X |
| ``thermostat_type`` | Type de thermostat | X | X |
| ``temperature_sensor_entity_id`` | Temperature sensor entity id | X | - |
| ``external_temperature_sensor_entity_id`` | Température exterieure sensor entity id | X | - |
| ``cycle_min`` | Durée du cycle (minutes) | X | X |
| ``temp_min`` | Température minimale permise | X | X |
| ``temp_max`` | Température maximale permise | X | X |
| ``device_power`` | Puissance de l'équipement | X | X |
| ``use_window_feature`` | Avec détection des ouvertures | X | X |
| ``use_motion_feature`` | Avec détection de mouvement | X | X |
| ``use_power_feature`` | Avec gestion de la puissance | X | X |
| ``use_presence_feature`` | Avec détection de présence | X | X |
| ``heater_entity1_id`` | 1er radiateur | X | - |
| ``heater_entity2_id`` | 2ème radiateur | X | - |
| ``heater_entity3_id`` | 3ème radiateur | X | - |
| ``heater_entity4_id`` | 4ème radiateur | X | - |
| ``proportional_function`` | Algorithme | X | - |
| ``climate_entity1_id`` | Thermostat sous-jacent | - | X |
| ``climate_entity2_id`` | 2ème thermostat sous-jacent | - | X |
| ``climate_entity3_id`` | 3ème thermostat sous-jacent | - | X |
| ``climate_entity4_id`` | 4ème thermostat sous-jacent | - | X |
| ``ac_mode`` | utilisation de l'air conditionné (AC) ? | - | X |
| ``tpi_coef_int`` | Coefficient à utiliser pour le delta de température interne | X | - |
| ``tpi_coef_ext`` | Coefficient à utiliser pour le delta de température externe | X | - |
| ``eco_temp`` | Température en preset Eco | X | X |
| ``comfort_temp`` | Température en preset Confort | X | X |
| ``boost_temp`` | Température en preset Boost | X | X |
| ``eco_ac_temp`` | Température en preset Eco en mode AC | X | X |
| ``comfort_ac_temp`` | Température en preset Confort en mode AC | X | X |
| ``boost_ac_temp`` | Température en preset Boost en mode AC | X | X |
| ``window_sensor_entity_id`` | Détecteur d'ouverture (entity id) | X | X |
| ``window_delay`` | Délai avant extinction (secondes) | X | X |
| ``window_auto_open_threshold`` | Seuil haut de chute de température pour la détection automatique (en °/min) | X | X |
| ``window_auto_close_threshold`` | Seuil bas de chute de température pour la fin de détection automatique (en °/min) | X | X |
| ``window_auto_max_duration`` | Durée maximum d'une extinction automatique (en min) | X | X |
| ``motion_sensor_entity_id`` | Détecteur de mouvement entity id | X | X |
| ``motion_delay`` | Délai avant prise en compte du mouvement (seconds) | X | X |
| ``motion_off_delay`` | Délai avant prise en compte de la fin de mouvement (seconds) | X | X |
| ``motion_preset`` | Preset à utiliser si mouvement détecté | X | X |
| ``no_motion_preset`` | Preset à utiliser si pas de mouvement détecté | X | X |
| ``power_sensor_entity_id`` | Capteur de puissance totale (entity id) | X | X |
| ``max_power_sensor_entity_id`` | Capteur de puissance Max (entity id) | X | X |
| ``power_temp`` | Température si délestaqe | X | X |
| ``presence_sensor_entity_id`` | Capteur de présence entity id (true si quelqu'un est présent) | X | X |
| ``eco_away_temp`` | Température en preset Eco en cas d'absence | X | X |
| ``comfort_away_temp`` | Température en preset Comfort en cas d'absence | X | X |
| ``boost_away_temp`` | Température en preset Boost en cas d'absence | X | X |
| ``eco_ac_away_temp`` | Température en preset Eco en cas d'absence en mode AC | X | X |
| ``comfort_ac_away_temp`` | Température en preset Comfort en cas d'absence en mode AC | X | X |
| ``boost_ac_away_temp`` | Température en preset Boost en cas d'absence en mode AC | X | X |
| ``minimal_activation_delay`` | Délai minimal d'activation | X | - |
| ``security_delay_min`` | Délai maximal entre 2 mesures de températures | X | - |
| ``security_min_on_percent`` | Pourcentage minimal de puissance pour passer en mode sécurité | X | - |
| ``security_default_on_percent`` | Pourcentage de puissance a utiliser en mode securité | X | - |
# Exemples de réglage
## Chauffage électrique
@@ -451,6 +513,17 @@ target:
entity_id : climate.my_thermostat
```
Ou pour changer le pré-réglage du mode Air Conditionné (AC) ajoutez un préfixe `_ac`` au nom du preset comme ceci :
```
service: versatile_thermostat.set_preset_temperature
data:
preset: boost_ac
temperature: 25
temperature_away: 30
target:
entity_id: climate.my_thermostat
```
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
- après un redémarrage, les préréglages sont réinitialisés à la température configurée. Si vous souhaitez que votre changement soit permanent, vous devez modifier le préréglage de la température dans la configuration de l'intégration.

View File

@@ -26,6 +26,7 @@
- [Configure the power management](#configure-the-power-management)
- [Configure the presence or occupancy](#configure-the-presence-or-occupancy)
- [Advanced configuration](#advanced-configuration)
- [Parameters synthesis](#parameters-synthesis)
- [Examples tuning](#examples-tuning)
- [Electrical heater](#electrical-heater)
- [Central heating (gaz or fuel heating system)](#central-heating-gaz-or-fuel-heating-system)
@@ -53,6 +54,7 @@
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
>![New](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*News*_
> * **Release 3.6**: Add a `motion_off_delay` parameter for activity management,
> * **Release 3.5**: Multiple thermostats when using "thermostat over another thermostat" mode [#113](https://github.com/jmcollin78/versatile_thermostat/issues/113)
> * **Release 3.4**: bug fixes and expose preset temperatures for AC mode [#103](https://github.com/jmcollin78/versatile_thermostat/issues/103)
> * **Release 3.3**: add the Air Conditionned mode (AC). This feature allow to use the eventual AC mode of your underlying climate entity. You have to check the "Use AC mode" checkbox in configuration and give preset temperature value for AC mode and AC mode when absent if absence is configured
@@ -64,19 +66,17 @@ This custom component for Home Assistant is an upgrade and is a complete rewrite
> * **major release 2.0**: addition of the "over climate" thermostat allowing you to transform any thermostat into a Versatile Thermostat and add all the functions of the latter.
# Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Many thanks to @salabur, @pvince83 and @bergoglio for the beers. It's very pleasing.
Many thanks to @@salabur, @pvince83, @bergoglio, @EPicLURcher, @Kriss1670, @maia for the beers. It's very pleasing.
# When to use / not use
This thermostat can control 2 types of equipment:
1. a heater that only works in on/off mode (named ```thermostat_over_switch```). The minimum configuration required to use this type of thermostat is:
1. a heater that only works in on/off mode (named ```thermostat_over_switch```). Versatile Thermostat will regulate the length of a heating cycle and the pauses in-between by controlling a binary on/off switch. This mode is e.g. suitable for an electrical radiator controlled by a switch. The minimum configuration required to use this type of thermostat is:
- an equipment such as a radiator (a ```switch``` or equivalent),
- a temperature probe for the room (or an input_number),
- an external temperature sensor (think about weather integration if you don't have one)
2. another thermostat that has its own operating modes (named ```thermostat_over_climate```). For this type of thermostat, the minimum configuration requires:
- an equipment such as air conditioning which is controlled by its own ```climate``` type entity,
- a temperature probe for the room (or an input_number),
- an external temperature sensor (think about weather integration if you don't have one)
2. another thermostat that has its own operating modes (named ```thermostat_over_climate```). Versatile Thermostat will regulate the target temperature of a climate entity. Common examples for this mode are the control of thermostatic radiator valves (TRV), air-conditions (AC), floor heating systems and pellet heating. For this type of thermostat, the minimum configuration requires:
- an equipment such as air conditioning or thermostatic valve (TRV) which is controlled by its own ```climate``` type entity,
The ```thermostat_over_climate``` type allows you to add all the functionality provided by VersatileThermostat to your existing equipment. The climate VersatileThermostat entity will control your existing climate entity, turning it off if the windows are open, switching it to Eco mode if no one is present, etc. See [here](#why-a-new-implementation-of-the-thermostat). For this type of thermostat, any heating cycles are controlled by the underlying climate entity and not by the Versatile Thermostat itself.
@@ -229,15 +229,17 @@ We will now see how to configure the new Activity mode.
What we need:
- a **motion sensor**. The entity id of a motion sensor. Motion sensor states should be 'on' (motion detected) or 'off' (no motion detected)
- a **motion delay** (in seconds) duration defining how long we wait for motion confirmation before considering the motion
- a **end of motion delay** (in seconds) duration defining how long we wait for end of motion confirmation before considering the end of motion
- a **target "motion" preset**. We will used the temperature of this preset when an activity is detected.
- a **target "no motion" preset**. We will used the temperature of this second preset when no activity is detected.
So imagine we want to have the following behavior :
- we have room with a thermostat set in activity mode, the "motion" mode chosen is comfort (21.5C), the "no motion" mode chosen is Eco (18.5 C) and the motion delay is 5 min.
- the room is empty for a while (no activity detected), the temperature of this room is 18.5 C
- somebody enters into the room, an activity is detected the temperature is set to 21.5 C
- the person leaves the room, after 5 min the temperature is set back to 18.5 C
- we have a room with a thermostat set to activity mode, the "movement" mode chosen is comfort (21.5°C), the "no movement" mode chosen is Eco (18.5°C) and the movement delay is 30 sec during detection and 5 minutes at the end of detection.
- the room has been empty for a while (no activity detected), the temperature of this room is 18.5°
- someone enters the room, activity is detected if movement is present for at least 30 seconds. The temperature then rises to 21.5°
- if the movement is present for less than 30 seconds (rapid passage), the temperature remains at 18.5°,
- imagine that the temperature has risen to 21.5°, when the person leaves the room, after 5 minutes the temperature is reduced to 18.5°.
- if the person returns before 5 minutes, the temperature remains at 21.5°
For this to work, the climate thermostat should be in ``Activity`` preset mode.
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
@@ -300,6 +302,65 @@ See [example tuning](#examples-tuning) for common tuning examples
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.
## Parameters synthesis
| Paramètre | Libellé | "over switch" | "over climate" |
| ----------| --------| --- | --- |
| ``name`` | Name | X | X |
| ``thermostat_type`` | Thermostat type | X | X |
| ``temperature_sensor_entity_id`` | Temperature sensor entity id | X | - |
| ``external_temperature_sensor_entity_id`` | External temperature sensor entity id | X | - |
| ``cycle_min`` | Cycle duration (minutes) | X | X |
| ``temp_min`` | Minimal temperature allowed | X | X |
| ``temp_max`` | Maximal temperature allowed | X | X |
| ``device_power`` | Device power | X | X |
| ``use_window_feature`` | Use window detection | X | X |
| ``use_motion_feature`` | Use motion detection | X | X |
| ``use_power_feature`` | Use power management | X | X |
| ``use_presence_feature`` | Use presence detection | X | X |
| ``heater_entity1_id`` | 1rst heater switch | X | - |
| ``heater_entity2_id`` | 2nd heater switch | X | - |
| ``heater_entity3_id`` | 3rd heater switch | X | - |
| ``heater_entity4_id`` | 4th heater switch | X | - |
| ``proportional_function`` | Algorithm | X | - |
| ``climate_entity1_id`` | 1rst underlying climate | - | X |
| ``climate_entity2_id`` | 2nd underlying climate | - | X |
| ``climate_entity3_id`` | 3rd underlying climate | - | X |
| ``climate_entity4_id`` | 4th underlying climate | - | X |
| ``ac_mode`` | Use the Air Conditioning (AC) mode | - | X |
| ``tpi_coef_int`` | Coefficient to use for internal temperature delta | X | - |
| ``tpi_coef_ext`` | Coefficient to use for external temperature delta | X | - |
| ``eco_temp`` | Temperature in Eco preset | X | X |
| ``comfort_temp`` | Temperature in Comfort preset | X | X |
| ``boost_temp`` | Temperature in Boost preset | X | X |
| ``eco_ac_temp`` | Temperature in Eco preset for AC mode | X | X |
| ``comfort_ac_temp`` | Temperature in Comfort preset for AC mode | X | X |
| ``boost_ac_temp`` | Temperature in Boost preset for AC mode | X | X |
| ``window_sensor_entity_id`` | Window sensor entity id | X | X |
| ``window_delay`` | Window sensor delay (seconds) | X | X |
| ``window_auto_open_threshold`` | Temperature decrease threshold for automatic window open detection (in °/min) | X | X |
| ``window_auto_close_threshold`` | Temperature increase threshold for end of automatic detection (in °/min) | X | X |
| ``window_auto_max_duration`` | Maximum duration of automatic window open detection (in min) | X | X |
| ``motion_sensor_entity_id`` | Motion sensor entity id | X | X |
| ``motion_delay`` | Delay before considering the motion (seconds) | X | X |
| ``motion_off_delay`` | Delay before considering the end of motion (seconds) | X | X |
| ``motion_preset`` | Preset to use when motion is detected | X | X |
| ``no_motion_preset`` | Preset to use when no motion is detected | X | X |
| ``power_sensor_entity_id`` | Power sensor entity id | X | X |
| ``max_power_sensor_entity_id`` | Max power sensor entity id | X | X |
| ``power_temp`` | Temperature for Power shedding | X | X |
| ``presence_sensor_entity_id`` | Presence sensor entity id | X | X |
| ``eco_away_temp`` | Temperature in Eco preset when no presence | X | X |
| ``comfort_away_temp`` | Temperature in Comfort preset when no presence | X | X |
| ``boost_away_temp`` | Temperature in Boost preset when no presence | X | X |
| ``eco_ac_away_temp`` | Temperature in Eco preset when no presence in AC mode | X | X |
| ``comfort_ac_away_temp`` | Temperature in Comfort preset when no presence in AC mode | X | X |
| ``boost_ac_away_temp`` | Temperature in Boost preset when no presence in AC mode | X | X |
| ``minimal_activation_delay`` | Minimal activation delay | X | - |
| ``security_delay_min`` | Security delay (in minutes) | X | X |
| ``security_min_on_percent`` | Minimal power percent to enable security mode | X | X |
| ``security_default_on_percent`` | Power percent to use in security mode | X | X |
# Examples tuning
## Electrical heater
@@ -436,6 +497,17 @@ target:
entity_id: climate.my_thermostat
```
Or to change the preset of the AC mode, add _ac to the preset name like this:
```
service: versatile_thermostat.set_preset_temperature
data:
preset: boost_ac
temperature: 25
temperature_away: 30
target:
entity_id: climate.my_thermostat
```
> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
- after a restart the preset are resetted to the configured temperature. If you want your change to be permanent you should modify the temperature preset into the confguration of the integration.

View File

@@ -4,35 +4,43 @@
. .bashrc
cd $HA
function get_dev() {
pip install -r requirements_dev.txt
pip install -r requirements_test.txt
if [ -d /home/vscode/core ]; then
sudo chown -R vscode: /home/vscode/core
fi
}
echo "arguments are: "$*
# Post installation of container
command=$1
if [ "$command" == "install" ]; then
echo "Running container post installation"
script/setup
fi
echo "arguments are: "$1
if [ "$command" == "start" ]; then
echo "Running container start"
hass -c ./config --debug
fi
if [ "$command" == "translations" ]; then
echo "Running container start"
python3 -m script.translations develop
fi
if [ "$command" == "hassfest" ]; then
echo "Running container start"
python3 -m script.hassfest
# python -m script.hassfest --requirements --action validate --integration-path config/custom_components/versatile_thermostat/
fi
if [ "$command" == "restart" ]; then
echo "Killing existing container"
pkill hass
echo "Killing existing container"
hass -c ./config
fi
case $1 in
start)
echo "Running container start"
./scripts/starts_ha.sh
;;
dev-setup)
get_dev
;;
install)
echo "Running container post installation"
script/setup
;;
translations)
echo "Running container start"
cd $HA
python3 -m script.translations develop
;;
hassfest)
echo "Running container start"
python3 -m script.hassfest
# python -m script.hassfest --requirements --action validate --integration-path config/custom_components/versatile_thermostat/
;;
restart)
echo "Killing existing container"
pkill hass
echo "Restarting existing container"
pwd
./scripts/starts_ha.sh
;;
esac

View File

@@ -3,7 +3,7 @@ import logging
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import STATE_ON
from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@@ -54,7 +54,9 @@ async def async_setup_entry(
class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the security state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
def __init__(
self, hass: HomeAssistant, unique_id, name, entry_infos
) -> None: # pylint: disable=unused-argument
"""Initialize the SecurityState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Security state"
@@ -87,7 +89,9 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the overpowering state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
def __init__(
self, hass: HomeAssistant, unique_id, name, entry_infos
) -> None: # pylint: disable=unused-argument
"""Initialize the OverpoweringState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Overpowering state"
@@ -120,7 +124,9 @@ class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity
class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the window state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
def __init__(
self, hass: HomeAssistant, unique_id, name, entry_infos
) -> None: # pylint: disable=unused-argument
"""Initialize the WindowState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Window state"
@@ -133,12 +139,17 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = (
self.my_climate.window_state == STATE_ON
or self.my_climate.window_auto_state == STATE_ON
)
if old_state != self._attr_is_on:
self.async_write_ha_state()
# Issue 120 - only take defined presence value
if self.my_climate.window_state in [
STATE_ON,
STATE_OFF,
] or self.my_climate.window_auto_state in [STATE_ON, STATE_OFF]:
self._attr_is_on = (
self.my_climate.window_state == STATE_ON
or self.my_climate.window_auto_state == STATE_ON
)
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
@@ -159,7 +170,9 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the motion state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
def __init__(
self, hass: HomeAssistant, unique_id, name, entry_infos
) -> None: # pylint: disable=unused-argument
"""Initialize the MotionState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Motion state"
@@ -171,9 +184,11 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
# Issue 120 - only take defined presence value
if self.my_climate.motion_state in [STATE_ON, STATE_OFF]:
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
@@ -191,7 +206,9 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the presence state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
def __init__(
self, hass: HomeAssistant, unique_id, name, entry_infos
) -> None: # pylint: disable=unused-argument
"""Initialize the PresenceState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Presence state"
@@ -204,9 +221,11 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
# Issue 120 - only take defined presence value
if self.my_climate.presence_state in [STATE_ON, STATE_OFF]:
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

View File

@@ -1,3 +1,6 @@
# pylint: disable=line-too-long
# pylint: disable=too-many-lines
# pylint: disable=invalid-name
""" Implements the VersatileThermostat climate component """
import math
import logging
@@ -95,6 +98,7 @@ from .const import (
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_OFF_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
CONF_DEVICE_POWER,
@@ -220,6 +224,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_presence_state: bool
_window_auto_state: bool
_underlyings: list[UnderlyingEntity]
_last_change_time: datetime
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
@@ -284,6 +289,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
self._last_change_time = None
self._underlyings = []
self.post_init(entry_infos)
@@ -297,7 +304,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
entry_infos,
)
self._ac_mode = entry_infos.get(CONF_AC_MODE) == True
self._ac_mode = entry_infos.get(CONF_AC_MODE) is True
# convert entry_infos into usable attributes
presets = {}
items = CONF_PRESETS_WITH_AC.items() if self._ac_mode else CONF_PRESETS.items()
@@ -335,7 +342,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE)
if self._thermostat_type == CONF_THERMOSTAT_CLIMATE:
self._is_over_climate = True
for climate in [CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4]:
for climate in [
CONF_CLIMATE,
CONF_CLIMATE_2,
CONF_CLIMATE_3,
CONF_CLIMATE_4,
]:
if entry_infos.get(climate):
self._underlyings.append(
UnderlyingClimate(
@@ -396,6 +408,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR)
self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY)
self._motion_off_delay_sec = entry_infos.get(CONF_MOTION_OFF_DELAY)
if not self._motion_off_delay_sec:
self._motion_off_delay_sec = self._motion_delay_sec
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
self._no_motion_preset = entry_infos.get(CONF_NO_MOTION_PRESET)
self._motion_on = (
@@ -639,7 +655,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Initialize all UnderlyingEntities
for under in self._underlyings:
under.startup()
try:
under.startup()
except UnknownEntity:
# Not found, we will try later
pass
temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
if temperature_state and temperature_state.state not in (
@@ -761,7 +781,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.async_write_ha_state()
if self._prop_algorithm:
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
self._target_temp,
self._cur_temp,
self._cur_ext_temp,
self._hvac_mode == HVACMode.COOL,
)
self.hass.create_task(self._check_switch_initial_state())
@@ -778,6 +801,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else:
self.hass.create_task(self._async_control_heating())
self.reset_last_change_time()
await self.get_my_previous_state()
if self.hass.state == CoreState.running:
@@ -951,10 +976,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""Return current operation."""
# Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different
# delta will be managed by climate_state_change event.
# TODO remove this when ok
# if self._is_over_climate:
# if one not OFF -> return it
# else OFF
# if one not OFF -> return it
# else OFF
# for under in self._underlyings:
# if (mode := under.hvac_mode) not in [HVACMode.OFF]
# return mode
@@ -974,7 +998,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# else OFF
one_idle = False
for under in self._underlyings:
if (action := under.hvac_action) not in [HVACAction.IDLE, HVACAction.OFF]:
if (action := under.hvac_action) not in [
HVACAction.IDLE,
HVACAction.OFF,
]:
return action
if under.hvac_action == HVACAction.IDLE:
one_idle = True
@@ -986,6 +1013,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return HVACAction.OFF
if not self._is_device_active:
return HVACAction.IDLE
if self._hvac_mode == HVACMode.COOL:
return HVACAction.COOLING
return HVACAction.HEATING
@property
@@ -1057,7 +1086,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
@property
def mean_cycle_power(self) -> float | None:
"""Returns tne mean power consumption during the cycle"""
"""Returns the mean power consumption during the cycle"""
if not self._device_power or self._is_over_climate:
return None
@@ -1233,6 +1262,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Ensure we update the current operation after changing the mode
self.reset_last_temperature_time()
self.reset_last_change_time()
self.update_custom_attributes()
self.async_write_ha_state()
self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode})
@@ -1288,6 +1319,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.recalculate()
self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode})
def reset_last_change_time(
self, old_preset_mode=None
): # pylint: disable=unused-argument
"""Reset to now the last change time"""
self._last_change_time = datetime.now(tz=self._current_tz)
_LOGGER.debug("%s - last_change_time is now %s", self, self._last_change_time)
def reset_last_temperature_time(self, old_preset_mode=None):
"""Reset to now the last temperature time if conditions are satisfied"""
if (
@@ -1363,6 +1401,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self._async_internal_set_temperature(temperature)
self._attr_preset_mode = PRESET_NONE
self.recalculate()
self.reset_last_change_time()
await self._async_control_heating(force=True)
async def _async_internal_set_temperature(self, temperature):
@@ -1516,11 +1555,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Check delay condition
async def try_motion_condition(_):
try:
delay = (
self._motion_delay_sec
if new_state.state == STATE_ON
else self._motion_off_delay_sec
)
long_enough = condition.state(
self.hass,
self._motion_sensor_entity_id,
new_state.state,
timedelta(seconds=self._motion_delay_sec),
timedelta(seconds=delay),
)
except ConditionError:
long_enough = False
@@ -1529,45 +1573,84 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(
"Motion delay condition is not satisfied. Ignore motion event"
)
return
else:
_LOGGER.debug("%s - Motion delay condition is satisfied", self)
self._motion_state = new_state.state
if self._attr_preset_mode == PRESET_ACTIVITY:
new_preset = (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
_LOGGER.info(
"%s - Motion condition have changes. New preset temp will be %s",
self,
new_preset,
)
# We do not change the preset which is kept to ACTIVITY but only the target_temperature
# We take the presence into account
await self._async_internal_set_temperature(
self.find_preset_temp(new_preset)
)
self.recalculate()
await self._async_control_heating(force=True)
self._motion_call_cancel = None
_LOGGER.debug("%s - Motion delay condition is satisfied", self)
self._motion_state = new_state.state
if self._attr_preset_mode == PRESET_ACTIVITY:
new_preset = (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
_LOGGER.info(
"%s - Motion condition have changes. New preset temp will be %s",
self,
new_preset,
)
# We do not change the preset which is kept to ACTIVITY but only the target_temperature
# We take the presence into account
await self._async_internal_set_temperature(
self.find_preset_temp(new_preset)
)
self.recalculate()
await self._async_control_heating(force=True)
im_on = self._motion_state == STATE_ON
delay_running = self._motion_call_cancel is not None
event_on = new_state.state == STATE_ON
if self._motion_call_cancel:
def arm():
"""Arm the timer"""
delay = (
self._motion_delay_sec
if new_state.state == STATE_ON
else self._motion_off_delay_sec
)
self._motion_call_cancel = async_call_later(
self.hass, timedelta(seconds=delay), try_motion_condition
)
def desarm():
# restart the timer
self._motion_call_cancel()
self._motion_call_cancel = None
self._motion_call_cancel = async_call_later(
self.hass, timedelta(seconds=self._motion_delay_sec), try_motion_condition
)
# For testing purpose we need to access the inner function
return try_motion_condition
# if I'm off
if not im_on:
if event_on and not delay_running:
_LOGGER.debug(
"%s - Arm delay cause i'm off and event is on and no delay is running",
self,
)
arm()
return try_motion_condition
# Ignore the event
_LOGGER.debug("%s - Event ignored cause i'm already off", self)
return None
else: # I'm On
if not event_on and not delay_running:
_LOGGER.info("%s - Arm delay cause i'm on and event is off", self)
arm()
return try_motion_condition
if event_on and delay_running:
_LOGGER.debug(
"%s - Desarm off delay cause i'm on and event is on and a delay is running",
self,
)
desarm()
return None
# Ignore the event
_LOGGER.debug("%s - Event ignored cause i'm already on", self)
return None
@callback
async def _check_switch_initial_state(self):
"""Prevent the device from keep running if HVAC_MODE_OFF."""
_LOGGER.debug("%s - Calling _check_switch_initial_state", self)
if self.is_over_climate:
return
# We need to do the same check for over_climate underlyings
# if self.is_over_climate:
# return
for under in self._underlyings:
await under.check_initial_state(self._hvac_mode)
@@ -1584,7 +1667,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
@callback
async def _async_climate_changed(self, event):
"""Handle unerdlying climate state changes."""
"""Handle unerdlying climate state changes.
This method takes the underlying values and update the VTherm with them.
To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received
less than 10 sec after the last command. What we want here is to take the values
from underlyings ONLY if someone have change directly on the underlying and not
as a return of the command. The only thing we take all the time is the HVACAction
which is important for feedaback and which cannot generates loops.
"""
async def end_climate_changed(changes):
"""To end the event management"""
if changes:
self.async_write_ha_state()
self.update_custom_attributes()
await self._async_control_heating()
new_state = event.data.get("new_state")
_LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state)
if not new_state:
@@ -1605,9 +1703,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else None
)
old_state_date_changed = (
old_state.last_changed if old_state and old_state.last_changed else None
)
old_state_date_updated = (
old_state.last_updated if old_state and old_state.last_updated else None
)
new_state_date_changed = (
new_state.last_changed if new_state and new_state.last_changed else None
)
new_state_date_updated = (
new_state.last_updated if new_state and new_state.last_updated else None
)
# Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command
# Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is
#if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE:
# if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE:
# _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF")
# new_hvac_mode = HVACMode.OFF
@@ -1620,24 +1731,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
old_hvac_action,
)
if new_hvac_mode in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.DRY,
HVACMode.AUTO,
HVACMode.FAN_ONLY,
None
] and self._hvac_mode != new_hvac_mode:
changes = True
self._hvac_mode = new_hvac_mode
# Do not try to update all underlying state, else we will have a loop
if self._is_over_climate:
for under in self._underlyings:
await under.set_hvac_mode(new_hvac_mode)
_LOGGER.debug(
"%s - last_change_time=%s old_state_date_changed=%s old_state_date_updated=%s new_state_date_changed=%s new_state_date_updated=%s",
self,
self._last_change_time,
old_state_date_changed,
old_state_date_updated,
new_state_date_changed,
new_state_date_updated,
)
# Interpretation of hvac
# Interpretation of hvac action
HVAC_ACTION_ON = [ # pylint: disable=invalid-name
HVACAction.COOLING,
HVACAction.DRYING,
@@ -1676,18 +1780,61 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
changes = True
# Issue #120 - Some TRV are chaning target temperature a very long time (6 sec) after the change.
# In that case a loop is possible if a user change multiple times during this 6 sec.
if new_state_date_updated and self._last_change_time:
delta = (new_state_date_updated - self._last_change_time).total_seconds()
if delta < 10:
_LOGGER.info(
"%s - underlying event is received less than 10 sec after command. Forget it to avoid loop",
self,
)
await end_climate_changed(changes)
return
if (
new_hvac_mode
in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.DRY,
HVACMode.AUTO,
HVACMode.FAN_ONLY,
None,
]
and self._hvac_mode != new_hvac_mode
):
changes = True
self._hvac_mode = new_hvac_mode
# Update all underlyings state
if self._is_over_climate:
for under in self._underlyings:
await under.set_hvac_mode(new_hvac_mode)
if not changes:
# try to manage new target temperature set if state
_LOGGER.debug("Do temperature check. temperature is %s, new_state.attributes is %s", self.target_temperature, new_state.attributes)
if self._is_over_climate and new_state.attributes and (new_target_temp := new_state.attributes.get("temperature")) and new_target_temp != self.target_temperature:
_LOGGER.info("%s - Target temp have change to %s", self, new_target_temp)
await self.async_set_temperature(temperature = new_target_temp)
_LOGGER.debug(
"Do temperature check. temperature is %s, new_state.attributes is %s",
self.target_temperature,
new_state.attributes,
)
if (
self._is_over_climate
and new_state.attributes
and (new_target_temp := new_state.attributes.get("temperature"))
and new_target_temp != self.target_temperature
):
_LOGGER.info(
"%s - Target temp in underlying have change to %s",
self,
new_target_temp,
)
await self.async_set_temperature(temperature=new_target_temp)
changes = True
if changes:
self.async_write_ha_state()
self.update_custom_attributes()
await self._async_control_heating()
await end_climate_changed(changes)
@callback
async def _async_update_temp(self, state: State):
@@ -2102,9 +2249,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
# TODO before change:
# mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
# fixed into this. Why if _is_over_climate we could into security even if HVACMode is OFF ?
mode_cond = self._hvac_mode != HVACMode.OFF
temp_cond: bool = (
@@ -2134,13 +2278,19 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
# Issue 99 - a climate is regulated by the device itself and not by VTherm. So a VTherm should never be in security !
shouldClimateBeInSecurity = False # temp_cond and climate_cond
shouldClimateBeInSecurity = False # temp_cond and climate_cond
shouldSwitchBeInSecurity = temp_cond and switch_cond
shouldBeInSecurity = shouldClimateBeInSecurity or shouldSwitchBeInSecurity
shouldStartSecurity = mode_cond and not self._security_state and shouldBeInSecurity
shouldStartSecurity = (
mode_cond and not self._security_state and shouldBeInSecurity
)
# attr_preset_mode is not necessary normaly. It is just here to be sure
shouldStopSecurity = self._security_state and not shouldBeInSecurity and self._attr_preset_mode == PRESET_SECURITY
shouldStopSecurity = (
self._security_state
and not shouldBeInSecurity
and self._attr_preset_mode == PRESET_SECURITY
)
# Logging and event
if shouldStartSecurity:
@@ -2259,21 +2409,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
try:
under.startup()
except UnknownEntity as err:
except UnknownEntity:
# still not found, we an stop here
raise err
return False
# Check overpowering condition
# Not necessary for switch because each switch is checking at startup
overpowering: bool = await self.check_overpowering()
if overpowering:
_LOGGER.debug("%s - End of cycle (overpowering)", self)
return
return True
security: bool = await self.check_security()
if security and self._is_over_climate:
_LOGGER.debug("%s - End of cycle (security and over climate)", self)
return
return True
# Stop here if we are off
if self._hvac_mode == HVACMode.OFF:
@@ -2281,7 +2431,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# A security to force stop heater if still active
if self._is_device_active:
await self._async_underlying_entity_turn_off()
return
return True
if not self._is_over_climate:
for under in self._underlyings:
@@ -2293,19 +2443,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
self.update_custom_attributes()
return True
def recalculate(self):
"""A utility function to force the calculation of a the algo and
update the custom attributes and write the state
"""
if self._is_over_climate:
self.update_custom_attributes()
return
_LOGGER.debug("%s - recalculate all", self)
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
if not self._is_over_climate:
self._prop_algorithm.calculate(
self._target_temp,
self._cur_temp,
self._cur_ext_temp,
self._hvac_mode == HVACMode.COOL,
)
self.update_custom_attributes()
self.async_write_ha_state()
@@ -2380,24 +2531,29 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz),
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
}
if self._is_over_climate:
self._attr_extra_state_attributes["underlying_climate_1"] = self._underlyings[
0
].entity_id
self._attr_extra_state_attributes["underlying_climate_1"] = self._underlyings[
1
].entity_id if len(self._underlyings) > 1 else None
self._attr_extra_state_attributes["underlying_climate_2"] = self._underlyings[
2
].entity_id if len(self._underlyings) > 2 else None
self._attr_extra_state_attributes["underlying_climate_3"] = self._underlyings[
3
].entity_id if len(self._underlyings) > 3 else None
self._attr_extra_state_attributes[
"underlying_climate_0"
] = self._underlyings[0].entity_id
self._attr_extra_state_attributes["underlying_climate_1"] = (
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
)
self._attr_extra_state_attributes["underlying_climate_2"] = (
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
)
self._attr_extra_state_attributes["underlying_climate_3"] = (
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
)
self._attr_extra_state_attributes[
"start_hvac_action_date"
@@ -2487,8 +2643,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
# If the changed preset is active, change the current temperature
if self._attr_preset_mode == preset:
await self._async_set_preset_mode_internal(preset, force=True)
# Issue #119 - reload new preset temperature also in ac mode
if preset.startswith(self._attr_preset_mode):
await self._async_set_preset_mode_internal(
preset.rstrip(PRESET_AC_SUFFIX), force=True
)
await self._async_control_heating(force=True)
async def service_set_security(self, delay_min, min_on_percent, default_on_percent):

View File

@@ -55,6 +55,7 @@ from .const import (
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_OFF_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
CONF_DEVICE_POWER,
@@ -226,6 +227,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
PROPORTIONAL_FUNCTION_TPI,
]
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
}
)
@@ -243,7 +245,6 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
vol.Optional(CONF_CLIMATE_4): selector.EntitySelector(
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
}
)
@@ -292,6 +293,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
),
),
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_OFF_DELAY, default=300): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
CONF_PRESETS_SELECTIONABLE
),

View File

@@ -49,6 +49,7 @@ CONF_CYCLE_MIN = "cycle_min"
CONF_PROP_FUNCTION = "proportional_function"
CONF_WINDOW_DELAY = "window_delay"
CONF_MOTION_DELAY = "motion_delay"
CONF_MOTION_OFF_DELAY = "motion_off_delay"
CONF_MOTION_PRESET = "motion_preset"
CONF_NO_MOTION_PRESET = "no_motion_preset"
CONF_TPI_COEF_INT = "tpi_coef_int"

View File

@@ -14,6 +14,6 @@
"quality_scale": "silver",
"requirements": [],
"ssdp": [],
"version": "3.0.0",
"version": "3.5.3",
"zeroconf": []
}

View File

@@ -45,19 +45,33 @@ class PropAlgorithm:
self._default_on_percent = 0
def calculate(
self, target_temp: float, current_temp: float, ext_current_temp: float
self,
target_temp: float,
current_temp: float,
ext_current_temp: float,
cooling=False,
):
"""Do the calculation of the duration"""
if target_temp is None or current_temp is None:
_LOGGER.warning(
"Proportional algorithm: calculation is not possible cause target_temp or current_temp is null. Heating will be disabled" # pylint: disable=line-too-long
"Proportional algorithm: calculation is not possible cause target_temp or current_temp is null. Heating/cooling will be disabled" # pylint: disable=line-too-long
)
self._calculated_on_percent = 0
else:
delta_temp = target_temp - current_temp
delta_ext_temp = (
target_temp - ext_current_temp if ext_current_temp is not None else 0
)
if cooling:
delta_temp = current_temp - target_temp
delta_ext_temp = (
ext_current_temp
if ext_current_temp is not None
else 0 - target_temp
)
else:
delta_temp = target_temp - current_temp
delta_ext_temp = (
target_temp - ext_current_temp
if ext_current_temp is not None
else 0
)
if self._function == PROPORTIONAL_FUNCTION_TPI:
self._calculated_on_percent = (

View File

@@ -1,2 +0,0 @@
homeassistant==2023.10.1
ffmpeg

View File

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

View File

@@ -14,7 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"device_power": "Device power",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -25,12 +25,12 @@
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater switch",
"heater_entity_id": "1rst heater switch",
"heater_entity2_id": "2nd heater switch",
"heater_entity3_id": "3rd heater switch",
"heater_entity4_id": "4th heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying climate",
"climate_entity_id": "1rst underlying climate",
"climate_entity2_id": "2nd underlying climate",
"climate_entity3_id": "3rd underlying climate",
"climate_entity4_id": "4th underlying climate",
@@ -92,7 +92,15 @@
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
"motion_delay": "Motion delay (seconds)",
"motion_delay": "Activation delay",
"motion_off_delay": "Deactivation delay",
"motion_preset": "Motion preset",
"no_motion_preset": "No motion preset"
},
"data_description": {
"motion_sensor_entity_id": "The entity id of the motion sensor",
"motion_delay": "Motion activation activation delay (seconds)",
"motion_off_delay": "Motion deactivation delay (seconds)",
"motion_preset": "Preset to use when motion is detected",
"no_motion_preset": "Preset to use when no motion is detected"
}
@@ -110,7 +118,7 @@
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"presence_sensor_entity_id": "Presence sensor entity id",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence",
@@ -125,7 +133,7 @@
"data": {
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_min_on_percent": "Minimal power percent to enable security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
@@ -237,7 +245,15 @@
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
"motion_delay": "Motion delay (seconds)",
"motion_delay": "Activation delay",
"motion_off_delay": "Deactivation delay",
"motion_preset": "Motion preset",
"no_motion_preset": "No motion preset"
},
"data_description": {
"motion_sensor_entity_id": "The entity id of the motion sensor",
"motion_delay": "Motion activation activation delay (seconds)",
"motion_off_delay": "Motion deactivation delay (seconds)",
"motion_preset": "Preset to use when motion is detected",
"no_motion_preset": "Preset to use when no motion is detected"
}

View File

@@ -14,7 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"device_power": "Device power",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -25,12 +25,12 @@
"title": "Linked entities",
"description": "Linked entities attributes",
"data": {
"heater_entity_id": "Heater switch",
"heater_entity_id": "1rst heater switch",
"heater_entity2_id": "2nd heater switch",
"heater_entity3_id": "3rd heater switch",
"heater_entity4_id": "4th heater switch",
"proportional_function": "Algorithm",
"climate_entity_id": "Underlying climate",
"climate_entity_id": "1rst underlying climate",
"climate_entity2_id": "2nd underlying climate",
"climate_entity3_id": "3rd underlying climate",
"climate_entity4_id": "4th underlying climate",
@@ -92,7 +92,15 @@
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
"motion_delay": "Motion delay (seconds)",
"motion_delay": "Activation delay",
"motion_off_delay": "Deactivation delay",
"motion_preset": "Motion preset",
"no_motion_preset": "No motion preset"
},
"data_description": {
"motion_sensor_entity_id": "The entity id of the motion sensor",
"motion_delay": "Motion activation activation delay (seconds)",
"motion_off_delay": "Motion deactivation delay (seconds)",
"motion_preset": "Preset to use when motion is detected",
"no_motion_preset": "Preset to use when no motion is detected"
}
@@ -110,7 +118,7 @@
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"presence_sensor_entity_id": "Presence sensor entity id",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence",
@@ -125,7 +133,7 @@
"data": {
"minimal_activation_delay": "Minimal activation delay",
"security_delay_min": "Security delay (in minutes)",
"security_min_on_percent": "Minimal power percent for security mode",
"security_min_on_percent": "Minimal power percent to enable security mode",
"security_default_on_percent": "Power percent to use in security mode"
},
"data_description": {
@@ -237,7 +245,15 @@
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
"motion_delay": "Motion delay (seconds)",
"motion_delay": "Activation delay",
"motion_off_delay": "Deactivation delay",
"motion_preset": "Motion preset",
"no_motion_preset": "No motion preset"
},
"data_description": {
"motion_sensor_entity_id": "The entity id of the motion sensor",
"motion_delay": "Motion activation activation delay (seconds)",
"motion_off_delay": "Motion deactivation delay (seconds)",
"motion_preset": "Preset to use when motion is detected",
"no_motion_preset": "Preset to use when no motion is detected"
}

View File

@@ -8,8 +8,9 @@
"description": "Principaux attributs obligatoires",
"data": {
"name": "Nom",
"thermostat_type": "Type de thermostat",
"temperature_sensor_entity_id": "Température sensor entity id",
"external_temperature_sensor_entity_id": "Temperature exterieure sensor entity id",
"external_temperature_sensor_entity_id": "Température exterieure sensor entity id",
"cycle_min": "Durée du cycle (minutes)",
"temp_min": "Température minimale permise",
"temp_max": "Température maximale permise",
@@ -74,7 +75,7 @@
"data": {
"window_sensor_entity_id": "Détecteur d'ouverture (entity id)",
"window_delay": "Délai avant extinction (secondes)",
"window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)",
"window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/min)",
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)",
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)"
},
@@ -90,8 +91,16 @@
"title": "Gestion de la détection de mouvement",
"description": "Le preset s'ajuste automatiquement si un mouvement est détecté\nLaissez l'entity id vide si non utilisé.\n'Preset mouvement' et 'Preset sans mouvement' doivent être choisis avec les preset à utiliser.",
"data": {
"motion_sensor_entity_id": "Détecteur de mouvement",
"motion_delay": "Délai d'activation",
"motion_off_delay": "Délai de désactivation",
"motion_preset": "Preset si mouvement",
"no_motion_preset": "Preset si pas de mouvement"
},
"data_description": {
"motion_sensor_entity_id": "Détecteur de mouvement entity id",
"motion_delay": "Délai avant changement (seconds)",
"motion_delay": "Délai avant activation lorsqu'un mouvement est détecté (secondss)",
"motion_off_delai": "Délai avant désactivation lorsqu'aucun mouvement n'est détecté (secondes)",
"motion_preset": "Preset à utiliser si mouvement détecté",
"no_motion_preset": "Preset à utiliser si pas de mouvement détecté"
}
@@ -236,8 +245,16 @@
"title": "Gestion de la détection de mouvement",
"description": "Le preset s'ajuste automatiquement si un mouvement est détecté\nLaissez l'entity id vide si non utilisé.\n'Preset mouvement' et 'Preset sans mouvement' doivent être choisis avec les preset à utiliser.",
"data": {
"motion_sensor_entity_id": "Détecteur de mouvement",
"motion_delay": "Délai d'activation",
"motion_off_delay": "Délai de désactivation",
"motion_preset": "Preset si mouvement",
"no_motion_preset": "Preset si pas de mouvement"
},
"data_description": {
"motion_sensor_entity_id": "Détecteur de mouvement entity id",
"motion_delay": "Délai avant changement (seconds)",
"motion_delay": "Délai avant activation lorsqu'un mouvement est détecté (secondss)",
"motion_off_delai": "Délai avant désactivation lorsqu'aucun mouvement n'est détecté (secondes)",
"motion_preset": "Preset à utiliser si mouvement détecté",
"no_motion_preset": "Preset à utiliser si pas de mouvement détecté"
}

View File

@@ -93,6 +93,7 @@
"data": {
"motion_sensor_entity_id": "Entity id sensore di movimento",
"motion_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione",
"motion_off_delay": "Ritardo in secondi di disattivazione prima che del sensore sia preso in considerazione",
"motion_preset": "Preset da utilizzare quando viene rilevato il movimento",
"no_motion_preset": "Preset da utilizzare quando non viene rilevato il movimento"
}
@@ -238,6 +239,7 @@
"data": {
"motion_sensor_entity_id": "Entity id sensore di movimento",
"motion_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione",
"motion_off_delay": "Ritardo in secondi di disattivazione prima che del sensore sia preso in considerazione",
"motion_preset": "Preset da utilizzare quando viene rilevato il movimento",
"no_motion_preset": "Preset da utilizzare quando non viene rilevato il movimento"
}

View File

@@ -6,7 +6,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature
from homeassistant.exceptions import ServiceNotFound
from homeassistant.backports.enum import StrEnum
from enum import StrEnum
from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE
from homeassistant.components.climate import (
ClimateEntity,
@@ -131,6 +131,23 @@ class UnderlyingEntity:
"""Remove the underlying entity"""
return
async def check_initial_state(self, hvac_mode: HVACMode):
"""Prevent the underlying to be on but thermostat is off"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
"%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
elif hvac_mode != HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
"%s - The hvac mode is ON, but the underlying device is not ON. Turning on device %s",
self,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
# override to be able to mock the call
def call_later(
self, hass: HomeAssistant, delay_sec: int, called_method
@@ -193,16 +210,6 @@ class UnderlyingSwitch(UnderlyingEntity):
"""If the toggleable device is currently active."""
return self._hass.states.is_state(self._entity_id, STATE_ON)
async def check_initial_state(self, hvac_mode: HVACMode):
"""Prevent the heater to be on but thermostat is off"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
"%s - The hvac mode is OFF, but the switch device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.turn_off()
async def start_cycle(
self,
hvac_mode: HVACMode,
@@ -239,7 +246,7 @@ class UnderlyingSwitch(UnderlyingEntity):
return
# If we should heat, starts the cycle with delay
if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0:
if self._hvac_mode in [HVACMode.HEAT, HVACMode.COOL] and on_time_sec > 0:
# Starts the cycle after the initial delay
self._async_cancel_cycle = self.call_later(
self._hass, self._initial_delay_sec, self._turn_on_later

View File

@@ -3,5 +3,5 @@
"content_in_root": false,
"render_readme": true,
"hide_default_branch": false,
"homeassistant": "2023.7.3"
"homeassistant": "2023.10.3"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 46 KiB

2
requirements_dev.txt Normal file
View File

@@ -0,0 +1,2 @@
homeassistant==2023.10.3
ffmpeg

6
requirements_test.txt Normal file
View File

@@ -0,0 +1,6 @@
# Warning: For automatic run of test in Gitlab CI, we must not include other things that pytest-homeassistant-custom-component
-r requirements_dev.txt
aiodiscover
ulid_transform
pytest-asyncio
pytest-homeassistant-custom-component

28
scripts/starts_ha.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
set -x
cd "$(dirname "$0")/.."
pwd
# Create config dir if not present
if [[ ! -d "${PWD}/config" ]]; then
mkdir -p "${PWD}/config"
# Add defaults configuration
hass --config "${PWD}/config" --script ensure_config
# Overwrite configuration.yaml if provided
if [ -f ${PWD}/.devcontainer/configuration.yaml ]; then
rm -f ${PWD}/config/configuration.yaml
ln -s ${PWD}/.devcontainer/configuration.yaml ${PWD}/config/configuration.yaml
fi
fi
# Set the path to custom_components
## This let's us have the structure we want <root>/custom_components/integration_blueprint
## while at the same time have Home Assistant configuration inside <root>/config
## without resulting to symlinks.
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
# Start Home Assistant
hass --config "${PWD}/config" --debug

3
setup.cfg Normal file
View File

@@ -0,0 +1,3 @@
[tool:pytest]
testpaths = tests
asyncio_mode = auto

View File

@@ -20,9 +20,9 @@ from homeassistant.components.climate import (
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import
from ..underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.climate import VersatileThermostat
from custom_components.versatile_thermostat.const import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from .const import ( # pylint: disable=unused-import
MOCK_TH_OVER_SWITCH_USER_CONFIG,
@@ -94,7 +94,7 @@ class MockClimate(ClimateEntity):
self._attr_extra_state_attributes = {}
self._unique_id = unique_id
self._name = name
self._attr_hvac_action = HVACAction.OFF
self._attr_hvac_action = HVACAction.OFF if hvac_mode == HVACMode.OFF else HVACAction.HEATING
self._attr_hvac_mode = hvac_mode
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
@@ -106,6 +106,11 @@ class MockClimate(ClimateEntity):
self._attr_target_temperature = temperature
self.async_write_ha_state()
def async_set_hvac_mode(self, hvac_mode):
""" The hvac mode"""
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
class MockUnavailableClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""

View File

@@ -1,4 +1,6 @@
"""Global fixtures for integration_blueprint integration."""
# pylint: disable=line-too-long
# 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.
@@ -34,7 +36,7 @@ pytest_plugins = "pytest_homeassistant_custom_component" # pylint: disable=inva
# 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):
def auto_enable_custom_integrations(enable_custom_integrations): # pylint: disable=unused-argument
"""Enable all integration in tests"""
yield

View File

@@ -40,6 +40,7 @@ from custom_components.versatile_thermostat.const import (
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_OFF_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
CONF_POWER_SENSOR,
@@ -96,6 +97,7 @@ MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_AC_MODE: False,
}
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = {
@@ -104,6 +106,7 @@ MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = {
CONF_HEATER_3: "switch.mock_4switch2",
CONF_HEATER_4: "switch.mock_4switch3",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_AC_MODE: False,
}
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
@@ -113,7 +116,6 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: False,
}
MOCK_PRESETS_CONFIG = {
@@ -136,6 +138,7 @@ MOCK_WINDOW_AUTO_CONFIG = {
MOCK_MOTION_CONFIG = {
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
CONF_MOTION_DELAY: 10,
CONF_MOTION_OFF_DELAY: 30,
CONF_MOTION_PRESET: PRESET_COMFORT,
CONF_NO_MOTION_PRESET: PRESET_ECO,
}

View File

@@ -9,9 +9,8 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from pytest_homeassistant_custom_component.common import MockConfigEntry
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from ..climate import VersatileThermostat
from ..binary_sensor import (
from custom_components.versatile_thermostat.climate import VersatileThermostat
from custom_components.versatile_thermostat.binary_sensor import (
SecurityBinarySensor,
OverpoweringBinarySensor,
WindowBinarySensor,
@@ -29,7 +28,7 @@ async def test_security_binary_sensors(
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
): # pylint: disable=unused-argument
"""Test the security binary sensors in thermostat type"""
entry = MockConfigEntry(

View File

@@ -62,11 +62,9 @@ async def test_bug_56(
# try to call _async_control_heating
try:
await entity._async_control_heating()
ret = await entity._async_control_heating()
# an exception should be send
assert False
except UnknownEntity:
pass
assert ret is False
except Exception: # pylint: disable=broad-exception-caught
assert False
@@ -391,8 +389,8 @@ async def test_bug_82(
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.hvac_mode is None
assert entity.hvac_mode is HVACMode.OFF
# assert entity.hvac_mode is None
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
@@ -429,7 +427,7 @@ async def test_bug_82(
# Tries to turns on the Thermostat
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode == None
assert entity.hvac_mode == HVACMode.HEAT
# 2. activate security feature when date is expired
with patch(
@@ -466,6 +464,7 @@ async def test_bug_101(
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
)
# Underlying is in HEAT mode but should be shutdown at startup
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT)
with patch(
@@ -473,7 +472,9 @@ async def test_bug_101(
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
) as mock_find_climate:
) as mock_find_climate, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
@@ -491,8 +492,17 @@ async def test_bug_101(
assert entity.name == "TheOverClimateMockName"
assert entity._is_over_climate is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_mode is HVACMode.OFF
# because the underlying is heating. In real life the underlying should be shut-off
assert entity.hvac_action is HVACAction.HEATING
# Underlying should have been shutdown
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.OFF),
]
)
assert entity.target_temperature == entity.min_temp
assert entity.preset_mode is PRESET_NONE
@@ -518,9 +528,16 @@ async def test_bug_101(
await entity.async_set_preset_mode(PRESET_COMFORT)
assert entity.preset_mode == PRESET_COMFORT
# 2. Change the target temp of underlying thermostat
# 2. Change the target temp of underlying thermostat at now -> the event will be disgarded because to fast (to avoid loop cf issue 121)
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, now, 12.75)
# Should have been switched to Manual preset
# Should NOT have been switched to Manual preset
assert entity.target_temperature == 17
assert entity.preset_mode is PRESET_COMFORT
# 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken
# Wait 11 sec
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75)
assert entity.target_temperature == 12.75
assert entity.preset_mode is PRESET_NONE

View File

@@ -29,7 +29,7 @@ async def test_show_form(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_states_get):
async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_states_get): # pylint: disable=unused-argument
"""Test the config flow with all thermostat_over_switch features"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@@ -128,7 +128,7 @@ async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_state
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_states_get):
async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_states_get): # pylint: disable=unused-argument
"""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}
@@ -157,38 +157,6 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat
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"] == {}
@@ -216,7 +184,7 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_window_auto_ok(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
hass: HomeAssistant, skip_hass_states_get, skip_control_heating # pylint: disable=unused-argument
):
"""Test the config flow with only window auto feature"""
result = await hass.config_entries.flow.async_init(
@@ -313,7 +281,7 @@ async def test_user_config_flow_window_auto_ok(
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_window_auto_ko(
hass: HomeAssistant, skip_hass_states_get
hass: HomeAssistant, skip_hass_states_get # pylint: disable=unused-argument
):
"""Test the config flow with window auto and window features -> not allowed"""
result = await hass.config_entries.flow.async_init(
@@ -385,7 +353,7 @@ async def test_user_config_flow_window_auto_ko(
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_4_switches(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
hass: HomeAssistant, skip_hass_states_get, skip_control_heating # pylint: disable=unused-argument
):
"""Test the config flow with 4 switchs thermostat_over_switch features"""
@@ -410,6 +378,7 @@ async def test_user_config_flow_over_4_switches(
CONF_HEATER_3: "switch.mock_switch3",
CONF_HEATER_4: "switch.mock_switch4",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_AC_MODE: False,
}
result = await hass.config_entries.flow.async_init(

View File

@@ -43,10 +43,11 @@ async def test_movement_management_time_not_enough(
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_DELAY_MIN: 10,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
CONF_MOTION_DELAY: 10, # important to not been obliged to wait
CONF_MOTION_OFF_DELAY: 30,
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
@@ -61,7 +62,7 @@ async def test_movement_management_time_not_enough(
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# start heating, in boost mode. We block the control_heating to avoid running a cycle
# start heating, in boost mode, when someone is present. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
@@ -75,14 +76,14 @@ async def test_movement_management_time_not_enough(
assert entity.motion_state is None
assert entity.presence_state is None
event_timestamp = now - timedelta(minutes=4)
event_timestamp = now - timedelta(minutes=5)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
await send_presence_change_event(entity, True, False, event_timestamp)
assert entity.presence_state is "on"
# starts detecting motion
# starts detecting motion with time not enough
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
@@ -91,17 +92,21 @@ async def test_movement_management_time_not_enough(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
return_value=False,
), patch(
"homeassistant.helpers.condition.state", return_value=False
):
event_timestamp = now - timedelta(minutes=3)
await send_motion_change_event(entity, True, False, event_timestamp)
) as mock_condition:
event_timestamp = now - timedelta(minutes=4)
try_condition = await send_motion_change_event(entity, True, False, event_timestamp)
# Will return False -> we will stay on movement False
await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
# state is not changed if time is not enough
assert entity.motion_state is None
assert entity.presence_state is "on"
@@ -111,7 +116,65 @@ async def test_movement_management_time_not_enough(
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# stop detecting motion with confirmation of stop
# starts detecting motion with time enough this time
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False,
), patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition:
event_timestamp = now - timedelta(minutes=3)
try_condition = await send_motion_change_event(entity, True, False, event_timestamp)
# Will return True -> we will switch to movement On
await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because motion is detected yet
assert entity.target_temperature == 19
assert entity.motion_state is "on"
assert entity.presence_state is "on"
# stop detecting motion with off delay too low
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active, patch(
"homeassistant.helpers.condition.state", return_value=False
) as mock_condition:
event_timestamp = now - timedelta(minutes=2)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp)
# Will return False -> we will stay to movement On
await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 19
assert entity.motion_state is "on"
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
# The heater must heat now
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
# stop detecting motion with off delay enough long
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
@@ -124,8 +187,11 @@ async def test_movement_management_time_not_enough(
) as mock_device_active, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition:
event_timestamp = now - timedelta(minutes=2)
await send_motion_change_event(entity, False, True, event_timestamp)
event_timestamp = now - timedelta(minutes=1)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp)
# Will return True -> we will switch to movement Off
await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
@@ -135,9 +201,8 @@ async def test_movement_management_time_not_enough(
assert entity.presence_state is "on"
assert mock_send_event.call_count == 0
# Change is not confirmed
# The heater must stop heating now
assert mock_heater_on.call_count == 0
# Because device is active
assert mock_heater_off.call_count == 1
assert mock_send_event.call_count == 0
@@ -405,3 +470,131 @@ async def test_movement_management_time_enoughand_not_presence(
assert entity.proportional_algorithm.on_percent == 0.11
assert mock_heater_off.call_count == 0
assert mock_send_event.call_count == 0
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_movement_management_with_stop_during_condition(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Presence management when the movement sensor switch to off and then to on during the test condition"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"eco_away_temp": 17,
"comfort_away_temp": 18,
"boost_away_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 10,
CONF_MOTION_OFF_DELAY: 30,
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_ACTIVITY)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is None
event_timestamp = now - timedelta(minutes=6)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
await send_presence_change_event(entity, False, True, event_timestamp)
assert entity.presence_state is "off"
# starts detecting motion
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch("homeassistant.helpers.condition.state", return_value=True): # Not needed for this test
event_timestamp = now - timedelta(minutes=5)
try_condition1 = await send_motion_change_event(entity, True, False, event_timestamp)
assert try_condition1 is not None
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because motion is detected yet -> switch to Boost mode
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is "off"
# Send a stop detection
event_timestamp = now - timedelta(minutes=4)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp)
assert try_condition is None # The timer should not have been stopped
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is "off"
# Resend a start detection
event_timestamp = now - timedelta(minutes=3)
try_condition = await send_motion_change_event(entity, True, False, event_timestamp)
assert try_condition is None # The timer should not have been restarted (we keep the first one)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# still no motion detected
assert entity.target_temperature == 18
assert entity.motion_state is None
assert entity.presence_state is "off"
await try_condition1(None)
# We should have switch this time
assert entity.target_temperature == 19 # Boost
assert entity.motion_state is "on" # switch to movement on
assert entity.presence_state is "off" # Non change

View File

@@ -1,11 +1,11 @@
""" Test the Multiple switch management """
import asyncio
from unittest.mock import patch, call, ANY
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
import logging
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
logging.getLogger().setLevel(logging.DEBUG)
@@ -15,7 +15,7 @@ async def test_one_switch_cycle(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
):
): # pylint: disable=unused-argument
"""Test that when multiple switch are configured the activation is distributed"""
tz = get_tz(hass) # pylint: disable=invalid-name
@@ -75,7 +75,7 @@ async def test_one_switch_cycle(
with patch(
"homeassistant.core.StateMachine.is_state", return_value=False
) as mock_is_state:
assert entity._is_device_active is False
assert entity._is_device_active is False # pylint: disable=protected-access
# Should be call for the Switch
assert mock_is_state.call_count == 1
@@ -132,7 +132,8 @@ async def test_one_switch_cycle(
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be turned on but is already on but because above we mock call_later the heater is not on. But this time it will be really on
# The first heater should be turned on but is already on but because above we mock
# call_later the heater is not on. But this time it will be really on
assert mock_heater_on.call_count == 1
# Set another temperature at middle level
@@ -153,17 +154,20 @@ async def test_one_switch_cycle(
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The heater is already on cycle. So we wait that the cycle ends and no heater action is done
# The heater is already on cycle. So we wait that the cycle ends and no heater action
# is done
assert mock_heater_on.call_count == 0
# assert entity.underlying_entity(0)._should_relaunch_control_heating is True
# Simulate the relaunch
await entity.underlying_entity(0)._turn_on_later(None)
await entity.underlying_entity(0)._turn_on_later( # pylint: disable=protected-access
None
)
# wait restart
await asyncio.sleep(0.1)
assert mock_heater_on.call_count == 1
# TODO normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False
# normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False
# Simulate the end of heater on cycle
event_timestamp = now - timedelta(minutes=3)
@@ -177,7 +181,9 @@ async def test_one_switch_cycle(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active:
await entity.underlying_entity(0)._turn_off_later(None)
await entity.underlying_entity(0)._turn_off_later( # pylint: disable=protected-access
None
)
# No special event
assert mock_send_event.call_count == 0
@@ -198,7 +204,9 @@ async def test_one_switch_cycle(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
) as mock_device_active:
await entity.underlying_entity(0)._turn_on_later(None)
await entity.underlying_entity(0)._turn_on_later( # pylint: disable=protected-access
None
)
# No special event
assert mock_send_event.call_count == 0
@@ -214,7 +222,7 @@ async def test_multiple_switchs(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
):
): # pylint: disable=unused-argument
"""Test that when multiple switch are configured the activation is distributed"""
tz = get_tz(hass) # pylint: disable=invalid-name
@@ -257,11 +265,14 @@ async def test_multiple_switchs(
)
assert entity
assert entity.is_over_climate is False
assert entity.nb_underlying_entities == 4
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
):
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
@@ -273,14 +284,16 @@ async def test_multiple_switchs(
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Checks that all heaters are off
with patch(
"homeassistant.core.StateMachine.is_state", return_value=False
) as mock_is_state:
assert entity._is_device_active is False
# Checks that all climates are off
assert entity._is_device_active is False # pylint: disable=protected-access
# Should be call for all Switch
assert mock_is_state.call_count == 4
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
# Set temperature to a low level
with patch(
@@ -337,5 +350,241 @@ async def test_multiple_switchs(
assert mock_send_event.call_count == 0
assert mock_heater_off.call_count == 0
# The first heater should be turned on but is already on but because call_later is mocked, it is only turned on here
# The first heater should be turned on but is already on but because call_later
# is mocked, it is only turned on here
assert mock_heater_on.call_count == 1
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_multiple_climates(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
): # pylint: disable=unused-argument
"""Test that when multiple climates are configured the activation and deactivation
is propagated to all climates"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOver4ClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOver4ClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "switch.mock_climate1",
CONF_CLIMATE_2: "switch.mock_climate2",
CONF_CLIMATE_3: "switch.mock_climate3",
CONF_CLIMATE_4: "switch.mock_climate4",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theover4climatemockname"
)
assert entity
assert entity.is_over_climate is True
assert entity.nb_underlying_entities == 4
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
assert entity._is_device_active is False # pylint: disable=protected-access
# Stop heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
await entity.async_set_hvac_mode(HVACMode.OFF)
assert entity.hvac_mode is HVACMode.OFF
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.OFF),
]
)
assert entity._is_device_active is False # pylint: disable=protected-access
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_multiple_climates_underlying_changes(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_send_event,
): # pylint: disable=unused-argument
"""Test that when multiple switch are configured the activation of one underlying
climate activate the others"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOver4ClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOver4ClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "switch.mock_climate1",
CONF_CLIMATE_2: "switch.mock_climate2",
CONF_CLIMATE_3: "switch.mock_climate3",
CONF_CLIMATE_4: "switch.mock_climate4",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theover4climatemockname"
)
assert entity
assert entity.is_over_climate is True
assert entity.nb_underlying_entities == 4
# start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.window_state is None
event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 15, event_timestamp)
# Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
assert entity._is_device_active is False # pylint: disable=protected-access
# Stop heating on one underlying climate
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
# Wait 11 sec so that the event will not be discarded
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event(
entity,
HVACMode.OFF,
HVACMode.HEAT,
HVACAction.OFF,
HVACAction.HEATING,
event_timestamp,
)
# Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.OFF),
]
)
assert entity.hvac_mode == HVACMode.OFF
assert entity._is_device_active is False # pylint: disable=protected-access
# Start heating on one underlying climate
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode, patch(
# notice that there is no need of return_value=HVACAction.IDLE because this is not
# a function but a property
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.hvac_action",
HVACAction.IDLE,
):
# Wait 11 sec so that the event will not be discarded
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event(
entity,
HVACMode.HEAT,
HVACMode.OFF,
HVACAction.IDLE,
HVACAction.OFF,
event_timestamp,
)
# Should be call for all Switch
assert mock_underlying_set_hvac_mode.call_count == 4
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
assert entity.hvac_mode == HVACMode.HEAT
assert entity.hvac_action == HVACAction.IDLE
assert entity._is_device_active is False # pylint: disable=protected-access

View File

@@ -2,7 +2,7 @@
from datetime import datetime, timedelta
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from ..open_window_algorithm import WindowOpenDetectionAlgorithm
from custom_components.versatile_thermostat.open_window_algorithm import WindowOpenDetectionAlgorithm
async def test_open_window_algo(

View File

@@ -236,8 +236,10 @@ async def test_security_over_climate(
assert entity.name == "TheOverClimateMockName"
assert entity._is_over_climate is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.HEAT
# Because the underlying is HEATING. In real life the underlying will be shut-off
assert entity.hvac_action is HVACAction.HEATING
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
@@ -252,6 +254,7 @@ async def test_security_over_climate(
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
# At startup
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
@@ -276,6 +279,17 @@ async def test_security_over_climate(
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode == HVACMode.HEAT
# One call more
assert mock_send_event.call_count == 3
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.HEAT},
),
]
)
# 2. activate security feature when date is expired
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"

View File

@@ -12,8 +12,8 @@ from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAG
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..sensor import (
from custom_components.versatile_thermostat.climate import VersatileThermostat
from custom_components.versatile_thermostat.sensor import (
EnergySensor,
MeanPowerSensor,
OnPercentSensor,

View File

@@ -10,7 +10,7 @@ from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DO
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from custom_components.versatile_thermostat.climate import VersatileThermostat
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import

View File

@@ -5,7 +5,9 @@ from .commons import * # pylint: disable=wildcard-import, unused-wildcard-impor
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
async def test_tpi_calculation(
hass: HomeAssistant, skip_hass_states_is_state: None
): # pylint: disable=unused-argument
"""Test the TPI calculation"""
entry = MockConfigEntry(
@@ -40,7 +42,7 @@ async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
)
assert entity
tpi_algo = entity._prop_algorithm
tpi_algo = entity._prop_algorithm # pylint: disable=protected-access
assert tpi_algo
tpi_algo.calculate(15, 10, 7)
@@ -50,36 +52,52 @@ async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
assert tpi_algo.off_time_sec == 0
assert entity.mean_cycle_power is None # no device power configured
tpi_algo.calculate(15, 14, 5)
tpi_algo.calculate(15, 14, 5, False)
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)
tpi_algo.calculate(15, 14, 5, False)
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)
tpi_algo.calculate(15, 14, 5, False)
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)
tpi_algo.calculate(15, 14.7, 15, False)
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)
tpi_algo.calculate(15, 14.7, 15, False)
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.unset_security()
tpi_algo.calculate(25, 30, 35, True)
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.set_security(0.09)
tpi_algo.calculate(25, 30, 35, True)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 1
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300
assert entity.mean_cycle_power is None # no device power configured