diff --git a/.bashrc b/.bashrc index 83cda4b..a2a9a0d 100644 --- a/.bashrc +++ b/.bashrc @@ -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 diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 280eef1..b84cce7 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -60,6 +60,9 @@ input_boolean: fake_heater_switch1: name: Heater 1 icon: mdi:radiator + fake_heater_ac1: + name: Air contionner 1 + icon: mdi:air-conditioner fake_heater_4switch1: name: Heater (multiswitch1) icon: mdi:radiator @@ -114,22 +117,22 @@ climate: name: Underlying thermostat 4-1 heater: input_boolean.fake_heater_4climate1 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-2 heater: input_boolean.fake_heater_4climate2 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-3 heater: input_boolean.fake_heater_4climate3 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-4 heater: input_boolean.fake_heater_4climate4 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat9 heater: input_boolean.fake_heater_switch3 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1f06e7b..5628f0c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,43 +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" + "8123: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=${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 + } } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index f2bb3d8..c4a90ad 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,6 @@ dist # init file required for unittest custom_components/__init__.py -__pycache__ \ No newline at end of file +__pycache__ + +config/** \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index c0e409f..622909c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" ] } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 1107050..70d273a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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/versatile_thermostat/custom_components/versatile_thermostat" + ], + "python.formatting.provider": "none" } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f01c64a..1e3f53a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,13 +2,13 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", "command": "./container start", "problemMatcher": [] }, { - "label": "Restart Home Assistant on port 9123", + "label": "Restart Home Assistant on port 8123", "type": "shell", "command": "./container restart", "problemMatcher": [] diff --git a/CONTRIBUTING-fr.md b/CONTRIBUTING-fr.md new file mode 100644 index 0000000..1dce83c --- /dev/null +++ b/CONTRIBUTING-fr.md @@ -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. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b9c9516 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. \ No newline at end of file diff --git a/README-fr.md b/README-fr.md index b463d19..37ac3aa 100644 --- a/README-fr.md +++ b/README-fr.md @@ -55,7 +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.6**: Ajout du paramètre `motion_off_delay` pour améliorer la gestion de des mouvements [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https://github.com/jmcollin78/versatile_thermostat/issues/128). Ajout du mode AC (air conditionné) pour un VTherm over switch. Préparation du projet Github pour faciliter les contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127) > * **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 @@ -83,6 +83,9 @@ Le type ```thermostat_over_climate``` permet d'ajouter à votre équipement exis Parce que cette intégration vise à commander le radiateur en tenant compte du préréglage configuré (preset) et de la température ambiante, ces informations sont obligatoires. +Certains thermostat de type TRV sont réputés incompatibles avec le Versatile Thermostat. C'est le cas des vannes suivantes : +1. les vannes POPP de Danfoss avec retour de température. Il est impossible d'éteindre cette vanne et elle d'auto-régule d'elle-même causant des conflits avec le VTherm, + # Pourquoi une nouvelle implémentation du thermostat ? Ce composant nommé __Versatile thermostat__ gère les cas d'utilisation suivants : @@ -158,10 +161,14 @@ Si plusieurs entités de type sont configurées, la thermostat décale les activ Exemple de déclenchement synchronisé : ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/multi-switch-activation.png?raw=true) +Il est possible de choisir un thermostat over switch qui commande une climatisation en cochant la case "AC Mode". Dans ce cas, seul le mode refroidissement sera visible. + Pour un thermostat de type ```thermostat_over_climate```: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity2.png?raw=true) +Il est possible de choisir un thermostat over climate qui commande une climatisation réversible en cochant la case "AC Mode". Dans ce cas, selon l'équipement commandé vous aurez accès au chauffage et/ou au réfroidissement. + ## Configurez les coefficients de l'algorithme TPI Si vous avez choisi un thermostat de type ```thermostat_over_switch``` vous arriverez sur cette page : @@ -185,6 +192,8 @@ Le mode préréglé (preset) vous permet de préconfigurer la température cibl - **Confort** : l'appareil est en mode confort - **Boost** : l'appareil tourne toutes les vannes à fond + Si le mode AC est utilisé, vous pourrez aussi configurer les températures lorsque l'équipement en mode climatisation. + **Aucun** est toujours ajouté dans la liste des modes, car c'est un moyen de ne pas utiliser les preset mais une **température manuelle** à la place. > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ @@ -290,6 +299,8 @@ Pour cela, vous devez configurer : 3. La **température utilisée en Confort** préréglée en cas d'absence, 4. La **température utilisée en Boost** préréglée en cas d'absence +Si le mode AC est utilisé, vous pourrez aussi configurer les températures lorsque l'équipement en mode climatisation. + > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ 1. le changement de température est immédiat et se répercute sur le volet avant. Le calcul prendra en compte la nouvelle température cible au prochain calcul du cycle, 2. vous pouvez utiliser le capteur direct person.xxxx ou un groupe de capteurs de Home Assistant. Le capteur de présence gère les états ``on`` ou ``home`` comme présents et les états ``off`` ou ``not_home`` comme absents. @@ -343,7 +354,7 @@ Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglag | ``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 | +| ``ac_mode`` | utilisation de l'air conditionné (AC) ? | X | 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 | @@ -544,7 +555,7 @@ target: # Notifications Les évènements marquant du thermostat sont notifiés par l'intermédiaire du bus de message. -Les évènements notifiés sont les suivants: +Les évènements notifiés sont les suivants: - ``versatile_thermostat_security_event`` : un thermostat entre ou sort du preset ``security`` - ``versatile_thermostat_power_event`` : un thermostat entre ou sort du preset ``power`` diff --git a/README.md b/README.md index 535def3..f446f8c 100644 --- a/README.md +++ b/README.md @@ -54,7 +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.6**: Added the `motion_off_delay` parameter to improve motion management [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https ://github.com/jmcollin78/versatile_thermostat/issues/128). Added AC (air conditioning) mode for a VTherm over switch. Preparing the Github project to facilitate contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127) > * **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 @@ -154,9 +154,13 @@ If several type entities are configured, the thermostat staggers the activations Example of synchronized triggering: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/multi-switch-activation.png?raw=true) +It is possible to choose an over switch thermostat which controls air conditioning by checking the "AC Mode" box. In this case, only the cooling mode will be visible. + For a ```thermostat_over_climate``` thermostat: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity2.png?raw=true) +It is possible to choose an over climate thermostat which controls reversible air conditioning by checking the “AC Mode” box. In this case, depending on the equipment ordered, you will have access to heating and/or cooling. + ## Configure the TPI algorithm coefficients Click on 'Validate' on the previous page and you will get there: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-tpi.png?raw=true) @@ -172,6 +176,8 @@ The preset mode allows you to pre-configurate targeted temperature. Used in conj - **Comfort** : device is in comfort mode - **Boost** : device turn all valve full up + If AC mode is used, you will also be able to configure temperatures when the equipment is in cooling mode. + **None** is always added in the list of modes, as it is a way to not use the presets modes but a **manual temperature** instead. > ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ @@ -239,7 +245,7 @@ What we need: - 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*_ @@ -274,6 +280,8 @@ For this you need to configure: 3. The **temperature used in Comfort** preset when absent, 4. The **temperature used in Boost** preset when absent +Si le mode AC est utilisé, vous pourrez aussi configurer les températures lorsque l'équipement en mode climatisation. + > ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ 1. the switch of temperature is immediate and is reflected on the front component. The calculation will take the new target temperature into account at the next cycle calculation, 2. you can use direct person.xxxx sensor or group of sensors of Home Assistant. The presence sensor handles ``on`` or ``home`` states as present and ``off`` or ``not_home`` state as absent. @@ -327,7 +335,7 @@ See [example tuning](#examples-tuning) for common tuning examples | ``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 | +| ``ac_mode`` | Use the Air Conditioning (AC) mode | X | 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 | diff --git a/container b/container index 4fc92ea..b602188 100755 --- a/container +++ b/container @@ -4,15 +4,12 @@ . .bashrc -cd $HA - function get_dev() { - cd /workspaces/versatile_thermostat/custom_components/versatile_thermostat/ - pip install pytest pip install -r requirements_dev.txt pip install -r requirements_test.txt - sudo chown -R vscode: /home/vscode/core - cd - + if [ -d /home/vscode/core ]; then + sudo chown -R vscode: /home/vscode/core + fi } echo "arguments are: "$1 @@ -20,8 +17,7 @@ echo "arguments are: "$1 case $1 in start) echo "Running container start" - cd $HA - hass -c ./config --debug + ./scripts/starts_ha.sh ;; dev-setup) get_dev @@ -43,8 +39,8 @@ case $1 in restart) echo "Killing existing container" pkill hass - echo "Killing existing container" - cd $HA - hass -c ./config + echo "Restarting existing container" + pwd + ./scripts/starts_ha.sh ;; esac diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 6874a45..c86a23e 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -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" @@ -134,7 +140,10 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): old_state = self._attr_is_on # 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]: + 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 @@ -161,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" @@ -195,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" diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 7e2eb39..950a5a4 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -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 @@ -425,7 +428,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._presence_on = self._presence_sensor_entity_id is not None if self._ac_mode: - self._hvac_list = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + self._hvac_list = [HVACMode.COOL, HVACMode.OFF] else: self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] @@ -652,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 ( @@ -912,6 +919,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return self._hvac_list + @property + def ac_mode(self) -> bool: + """ Get the ac_mode of the Themostat""" + return self._ac_mode + @property def fan_mode(self) -> str | None: """Return the fan setting. @@ -1312,7 +1324,9 @@ 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): + 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) @@ -1336,8 +1350,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if preset_mode == PRESET_POWER: return self._power_temp else: - # Select _ac presets if in COOL Mode - if self._ac_mode and self._hvac_mode == HVACMode.COOL: + # Select _ac presets if in COOL Mode (or over_switch with _ac_mode) + if self._ac_mode and (self._hvac_mode == HVACMode.COOL or not self._is_over_climate): preset_mode = preset_mode + PRESET_AC_SUFFIX if self._presence_on is False or self._presence_state in [ @@ -1546,7 +1560,11 @@ 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 + 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, @@ -1583,13 +1601,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): await self._async_control_heating(force=True) self._motion_call_cancel = None - im_on = (self._motion_state == STATE_ON) - delay_running = (self._motion_call_cancel is not None) - event_on = (new_state.state == STATE_ON) + im_on = self._motion_state == STATE_ON + delay_running = self._motion_call_cancel is not None + event_on = new_state.state == STATE_ON def arm(): - """ Arm the timer""" - delay = self._motion_delay_sec if new_state.state == STATE_ON else self._motion_off_delay_sec + """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 ) @@ -1602,7 +1624,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): # 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) + _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 @@ -1614,7 +1639,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): 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) + _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 @@ -1696,9 +1724,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): # 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: - # _LOGGER.debug( - # "The underlying switch to idle instead of OFF. We will consider it as OFF" - # ) + # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") # new_hvac_mode = HVACMode.OFF _LOGGER.info( @@ -1710,17 +1736,15 @@ 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, - ]: - self._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 action HVAC_ACTION_ON = [ # pylint: disable=invalid-name @@ -1955,25 +1979,25 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: return - # Change temperature with preset named _way - new_temp = None - if new_state == STATE_ON or new_state == STATE_HOME: - new_temp = self._presets[self._attr_preset_mode] - _LOGGER.info( - "%s - Someone is back home. Restoring temperature to %.2f", - self, - new_temp, - ) - else: - new_temp = self._presets_away[ - self.get_preset_away_name(self._attr_preset_mode) - ] - _LOGGER.info( - "%s - No one is at home. Apply temperature %.2f", - self, - new_temp, - ) - + # Change temperature with preset named _away + # new_temp = None + #if new_state == STATE_ON or new_state == STATE_HOME: + # new_temp = self._presets[self._attr_preset_mode] + # _LOGGER.info( + # "%s - Someone is back home. Restoring temperature to %.2f", + # self, + # new_temp, + # ) + #else: + # new_temp = self._presets_away[ + # self.get_preset_away_name(self._attr_preset_mode) + # ] + # _LOGGER.info( + # "%s - No one is at home. Apply temperature %.2f", + # self, + # new_temp, + # ) + new_temp = self.find_preset_temp(self.preset_mode) if new_temp is not None: _LOGGER.debug( "%s - presence change in temperature mode new_temp will be: %.2f", @@ -2135,6 +2159,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def restore_hvac_mode(self, need_control_heating=False): """Restore a previous hvac_mod""" + old_hvac_mode = self.hvac_mode await self.async_set_hvac_mode(self._saved_hvac_mode, need_control_heating) _LOGGER.debug( "%s - Restored hvac_mode - saved_hvac_mode is %s, hvac_mode is %s", @@ -2142,6 +2167,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._saved_hvac_mode, self._hvac_mode, ) + # Issue 133 - force the temperature in over_climate mode if unerlying are turned on + if old_hvac_mode == HVACMode.OFF and self.hvac_mode != HVACMode.OFF and self._is_over_climate: + _LOGGER.info("%s - force resent target temp cause we turn on some over climate") + await self._async_internal_set_temperature(self._target_temp) async def check_overpowering(self) -> bool: """Check the overpowering condition @@ -2484,6 +2513,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): "target_temp": self.target_temperature, "current_temp": self._cur_temp, "ext_current_temperature": self._cur_ext_temp, + "ac_mode": self._ac_mode, "current_power": self._current_power, "current_power_max": self._current_power_max, "saved_preset_mode": self._saved_preset_mode, diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index cf8c32f..a198771 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -245,6 +245,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): vol.Optional(CONF_CLIMATE_4): selector.EntitySelector( selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, } ) diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index b6753bd..82f6541 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -14,6 +14,6 @@ "quality_scale": "silver", "requirements": [], "ssdp": [], - "version": "3.5.3", + "version": "3.6.0", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 3ac3f78..b8580a2 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -1,12 +1,12 @@ """ Underlying entities classes """ import logging from typing import Any +from enum import StrEnum from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature from homeassistant.exceptions import ServiceNotFound -from enum import StrEnum from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE from homeassistant.components.climate import ( ClimateEntity, diff --git a/custom_components/versatile_thermostat/requirements_dev.txt b/requirements_dev.txt similarity index 100% rename from custom_components/versatile_thermostat/requirements_dev.txt rename to requirements_dev.txt diff --git a/custom_components/versatile_thermostat/requirements_test.txt b/requirements_test.txt similarity index 93% rename from custom_components/versatile_thermostat/requirements_test.txt rename to requirements_test.txt index 0d72775..398c1c7 100644 --- a/custom_components/versatile_thermostat/requirements_test.txt +++ b/requirements_test.txt @@ -2,4 +2,5 @@ -r requirements_dev.txt aiodiscover ulid_transform +pytest-asyncio pytest-homeassistant-custom-component \ No newline at end of file diff --git a/scripts/starts_ha.sh b/scripts/starts_ha.sh new file mode 100755 index 0000000..28263bf --- /dev/null +++ b/scripts/starts_ha.sh @@ -0,0 +1,29 @@ +#!/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 +fi + +# 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 + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c3b2916 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[tool:pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/custom_components/versatile_thermostat/tests/__init__.py b/tests/__init__.py similarity index 100% rename from custom_components/versatile_thermostat/tests/__init__.py rename to tests/__init__.py diff --git a/custom_components/versatile_thermostat/tests/commons.py b/tests/commons.py similarity index 93% rename from custom_components/versatile_thermostat/tests/commons.py rename to tests/commons.py index 1380fea..c7d721c 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/tests/commons.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock import pytest # pylint: disable=unused-import from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State -from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF +from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF, ATTR_TEMPERATURE from homeassistant.config_entries import ConfigEntryState from homeassistant.util import dt as dt_util @@ -20,23 +20,26 @@ 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, MOCK_TH_OVER_4SWITCH_USER_CONFIG, MOCK_TH_OVER_CLIMATE_USER_CONFIG, MOCK_TH_OVER_SWITCH_TYPE_CONFIG, + MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG, MOCK_TH_OVER_4SWITCH_TYPE_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_PRESETS_CONFIG, + MOCK_PRESETS_AC_CONFIG, MOCK_WINDOW_CONFIG, MOCK_MOTION_CONFIG, MOCK_POWER_CONFIG, MOCK_PRESENCE_CONFIG, + MOCK_PRESENCE_AC_CONFIG, MOCK_ADVANCED_CONFIG, # MOCK_DEFAULT_FEATURE_CONFIG, PRESET_BOOST, @@ -58,6 +61,19 @@ FULL_SWITCH_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +FULL_SWITCH_AC_CONFIG = ( + MOCK_TH_OVER_SWITCH_USER_CONFIG + | MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_AC_CONFIG + | MOCK_WINDOW_CONFIG + | MOCK_MOTION_CONFIG + | MOCK_POWER_CONFIG + | MOCK_PRESENCE_AC_CONFIG + | MOCK_ADVANCED_CONFIG +) + + PARTIAL_CLIMATE_CONFIG = ( MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG @@ -83,7 +99,7 @@ _LOGGER = logging.getLogger(__name__) class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: # pylint: disable=unused-argument """Initialize the thermostat.""" super().__init__() @@ -101,12 +117,13 @@ class MockClimate(ClimateEntity): self._attr_target_temperature = 20 self._attr_current_temperature = 15 - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """ Set the target temperature""" + temperature = kwargs.get(ATTR_TEMPERATURE) self._attr_target_temperature = temperature self.async_write_ha_state() - def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode): """ The hvac mode""" self._attr_hvac_mode = hvac_mode self.async_write_ha_state() @@ -114,7 +131,7 @@ class MockClimate(ClimateEntity): class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - 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 thermostat.""" super().__init__() diff --git a/custom_components/versatile_thermostat/tests/conftest.py b/tests/conftest.py similarity index 98% rename from custom_components/versatile_thermostat/tests/conftest.py rename to tests/conftest.py index 08ff58c..2260dfd 100644 --- a/custom_components/versatile_thermostat/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/custom_components/versatile_thermostat/tests/const.py b/tests/const.py similarity index 84% rename from custom_components/versatile_thermostat/tests/const.py rename to tests/const.py index 1eb3a15..c935bb0 100644 --- a/custom_components/versatile_thermostat/tests/const.py +++ b/tests/const.py @@ -51,7 +51,6 @@ from custom_components.versatile_thermostat.const import ( PRESET_AWAY_SUFFIX, CONF_CLIMATE, ) - MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_NAME: "TheOverSwitchMockName", CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, @@ -100,6 +99,12 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { CONF_AC_MODE: False, } +MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = { + CONF_HEATER: "switch.mock_air_conditioner", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_AC_MODE: True, +} + MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = { CONF_HEATER: "switch.mock_4switch0", CONF_HEATER_2: "switch.mock_4switch1", @@ -116,6 +121,7 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = { MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, } MOCK_PRESETS_CONFIG = { @@ -124,6 +130,15 @@ MOCK_PRESETS_CONFIG = { PRESET_BOOST + "_temp": 18, } +MOCK_PRESETS_AC_CONFIG = { + PRESET_ECO + "_temp": 17, + PRESET_COMFORT + "_temp": 19, + PRESET_BOOST + "_temp": 20, + PRESET_ECO + "_ac_temp": 25, + PRESET_COMFORT + "_ac_temp": 23, + PRESET_BOOST + "_ac_temp": 21, +} + MOCK_WINDOW_CONFIG = { CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", CONF_WINDOW_DELAY: 10, @@ -156,6 +171,16 @@ MOCK_PRESENCE_CONFIG = { PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, } +MOCK_PRESENCE_AC_CONFIG = { + CONF_PRESENCE_SENSOR: "person.presence_sensor", + PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16, + PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17, + PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, + PRESET_ECO + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 27, + PRESET_COMFORT + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 26, + PRESET_BOOST + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 25, +} + MOCK_ADVANCED_CONFIG = { CONF_MINIMAL_ACTIVATION_DELAY: 10, CONF_SECURITY_DELAY_MIN: 5, diff --git a/custom_components/versatile_thermostat/tests/test_binary_sensors.py b/tests/test_binary_sensors.py similarity index 99% rename from custom_components/versatile_thermostat/tests/test_binary_sensors.py rename to tests/test_binary_sensors.py index 70e5970..c9f8d53 100644 --- a/custom_components/versatile_thermostat/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -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( diff --git a/custom_components/versatile_thermostat/tests/test_bugs.py b/tests/test_bugs.py similarity index 100% rename from custom_components/versatile_thermostat/tests/test_bugs.py rename to tests/test_bugs.py diff --git a/custom_components/versatile_thermostat/tests/test_config_flow.py b/tests/test_config_flow.py similarity index 98% rename from custom_components/versatile_thermostat/tests/test_config_flow.py rename to tests/test_config_flow.py index a7545ec..1630c6a 100644 --- a/custom_components/versatile_thermostat/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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} @@ -184,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( @@ -281,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( @@ -353,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""" diff --git a/custom_components/versatile_thermostat/tests/test_movement.py b/tests/test_movement.py similarity index 100% rename from custom_components/versatile_thermostat/tests/test_movement.py rename to tests/test_movement.py index ab1d31c..d95ae93 100644 --- a/custom_components/versatile_thermostat/tests/test_movement.py +++ b/tests/test_movement.py @@ -1,10 +1,10 @@ """ Test the Window management """ import asyncio +from datetime import datetime, timedelta +import logging from unittest.mock import patch, call, PropertyMock from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import -from datetime import datetime, timedelta -import logging logging.getLogger().setLevel(logging.DEBUG) diff --git a/custom_components/versatile_thermostat/tests/test_multiple_switch.py b/tests/test_multiple_switch.py similarity index 91% rename from custom_components/versatile_thermostat/tests/test_multiple_switch.py rename to tests/test_multiple_switch.py index 28a2351..09408d2 100644 --- a/custom_components/versatile_thermostat/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -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,12 +154,15 @@ 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) @@ -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 @@ -277,7 +285,7 @@ async def test_multiple_switchs( await send_temperature_change_event(entity, 15, event_timestamp) # Checks that all climates are off - assert entity._is_device_active is False + assert entity._is_device_active is False # pylint: disable=protected-access # Should be call for all Switch assert mock_underlying_set_hvac_mode.call_count == 4 @@ -342,17 +350,20 @@ 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, -): - """Test that when multiple climates are configured the activation and deactivation is propagated to all climates""" +): # 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) @@ -416,7 +427,7 @@ async def test_multiple_climates( call.set_hvac_mode(HVACMode.HEAT), ] ) - assert entity._is_device_active is False + 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( @@ -441,7 +452,8 @@ async def test_multiple_climates( call.set_hvac_mode(HVACMode.OFF), ] ) - assert entity._is_device_active is False + 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]) @@ -449,8 +461,9 @@ async def test_multiple_climates_underlying_changes( hass: HomeAssistant, skip_hass_states_is_state, skip_send_event, -): - """Test that when multiple switch are configured the activation of one underlying climate activate the others""" +): # 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) @@ -514,7 +527,7 @@ async def test_multiple_climates_underlying_changes( call.set_hvac_mode(HVACMode.HEAT), ] ) - assert entity._is_device_active is False + assert entity._is_device_active is False # pylint: disable=protected-access # Stop heating on one underlying climate with patch( @@ -524,7 +537,14 @@ async def test_multiple_climates_underlying_changes( ) 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) + 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 @@ -534,7 +554,7 @@ async def test_multiple_climates_underlying_changes( ] ) assert entity.hvac_mode == HVACMode.OFF - assert entity._is_device_active is False + assert entity._is_device_active is False # pylint: disable=protected-access # Start heating on one underlying climate with patch( @@ -542,12 +562,21 @@ async def test_multiple_climates_underlying_changes( ), 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 - ) as mock_underlying_get_hvac_action: + # 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) + 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 @@ -558,5 +587,4 @@ async def test_multiple_climates_underlying_changes( ) assert entity.hvac_mode == HVACMode.HEAT assert entity.hvac_action == HVACAction.IDLE - assert entity._is_device_active is False - + assert entity._is_device_active is False # pylint: disable=protected-access diff --git a/custom_components/versatile_thermostat/tests/test_open_window_algo.py b/tests/test_open_window_algo.py similarity index 97% rename from custom_components/versatile_thermostat/tests/test_open_window_algo.py rename to tests/test_open_window_algo.py index 7d3c87c..3834eda 100644 --- a/custom_components/versatile_thermostat/tests/test_open_window_algo.py +++ b/tests/test_open_window_algo.py @@ -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( diff --git a/custom_components/versatile_thermostat/tests/test_power.py b/tests/test_power.py similarity index 100% rename from custom_components/versatile_thermostat/tests/test_power.py rename to tests/test_power.py diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/tests/test_security.py similarity index 100% rename from custom_components/versatile_thermostat/tests/test_security.py rename to tests/test_security.py diff --git a/custom_components/versatile_thermostat/tests/test_sensors.py b/tests/test_sensors.py similarity index 99% rename from custom_components/versatile_thermostat/tests/test_sensors.py rename to tests/test_sensors.py index 8755f91..41b58b7 100644 --- a/custom_components/versatile_thermostat/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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, diff --git a/custom_components/versatile_thermostat/tests/test_start.py b/tests/test_start.py similarity index 99% rename from custom_components/versatile_thermostat/tests/test_start.py rename to tests/test_start.py index 3c11564..450dac7 100644 --- a/custom_components/versatile_thermostat/tests/test_start.py +++ b/tests/test_start.py @@ -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 diff --git a/tests/test_switch_ac.py b/tests/test_switch_ac.py new file mode 100644 index 0000000..e263352 --- /dev/null +++ b/tests/test_switch_ac.py @@ -0,0 +1,140 @@ +""" Test the normal start of a Switch AC Thermostat """ +from unittest.mock import patch, call +from datetime import datetime, timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.config_entries import ConfigEntryState + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.versatile_thermostat.climate import VersatileThermostat + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_switch_ac_full_start(hass: HomeAssistant, skip_hass_states_is_state): # pylint: disable=unused-argument + """Test the normal full start of a thermostat in thermostat_over_switch type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchACMockName", + unique_id="uniqueId", + data=FULL_SWITCH_AC_CONFIG, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + # The name is in the CONF and not the title of the entry + entity: VersatileThermostat = find_my_entity("climate.theoverswitchmockname") + + assert entity + + assert entity.name == "TheOverSwitchMockName" + assert entity._is_over_climate is False # pylint: disable=protected-access + assert entity.ac_mode is True + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_modes == [HVACMode.COOL, HVACMode.OFF] + assert entity.target_temperature == entity.max_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + PRESET_ACTIVITY, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False # pylint: disable=protected-access + assert entity._window_state is None # pylint: disable=protected-access + assert entity._motion_state is None # pylint: disable=protected-access + assert entity._presence_state is None # pylint: disable=protected-access + assert entity._prop_algorithm is not None # pylint: disable=protected-access + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.COOL) + assert entity.hvac_mode is HVACMode.COOL + + event_timestamp = now - timedelta(minutes=4) + await send_presence_change_event(entity, True, False, event_timestamp) + assert entity._presence_state == STATE_ON # pylint: disable=protected-access + + await entity.async_set_hvac_mode(HVACMode.COOL) + assert entity.hvac_mode is HVACMode.COOL + + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode is PRESET_COMFORT + assert entity.target_temperature == 23 + + # switch to Eco + await entity.async_set_preset_mode(PRESET_ECO) + assert entity.preset_mode is PRESET_ECO + assert entity.target_temperature == 25 + + # Unset the presence + event_timestamp = now - timedelta(minutes=3) + await send_presence_change_event(entity, False, True, event_timestamp) + assert entity._presence_state == STATE_OFF # pylint: disable=protected-access + assert entity.target_temperature == 27 # eco_ac_away + + # Open a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=2) + try_condition = await send_window_change_event(entity, True, False, event_timestamp) + + # Confirme the window event + await try_condition(None) + + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_action is HVACAction.OFF + assert entity.target_temperature == 27 # eco_ac_away + + # Close a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=2) + try_condition = await send_window_change_event(entity, False, True, event_timestamp) + + # Confirme the window event + await try_condition(None) + + assert entity.hvac_mode is HVACMode.COOL + assert (entity.hvac_action is HVACAction.OFF or entity.hvac_action is HVACAction.IDLE) + assert entity.target_temperature == 27 # eco_ac_away + + diff --git a/custom_components/versatile_thermostat/tests/test_tpi.py b/tests/test_tpi.py similarity index 94% rename from custom_components/versatile_thermostat/tests/test_tpi.py rename to tests/test_tpi.py index 7e73b5d..b27b39f 100644 --- a/custom_components/versatile_thermostat/tests/test_tpi.py +++ b/tests/test_tpi.py @@ -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: None): +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: N ) 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) diff --git a/custom_components/versatile_thermostat/tests/test_window.py b/tests/test_window.py similarity index 100% rename from custom_components/versatile_thermostat/tests/test_window.py rename to tests/test_window.py