From 0db7b36704b5612b96519ade9dc299698a862109 Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sat, 28 Oct 2023 19:53:21 +0200 Subject: [PATCH 1/9] Add Window ByPass --- .../versatile_thermostat/binary_sensor.py | 37 +++++++++++++- .../versatile_thermostat/climate.py | 49 +++++++++++++++++++ .../versatile_thermostat/const.py | 2 + .../versatile_thermostat/services.yaml | 16 ++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index c86a23e7..72436bfc 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -38,7 +38,7 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) - entities = [SecurityBinarySensor(hass, unique_id, name, entry.data)] + entities = [SecurityBinarySensor(hass, unique_id, name, entry.data),WindowByPassBinarySensor(hass, unique_id, name, entry.data)] if entry.data.get(CONF_USE_MOTION_FEATURE): entities.append(MotionBinarySensor(hass, unique_id, name, entry.data)) if entry.data.get(CONF_USE_WINDOW_FEATURE): @@ -238,3 +238,38 @@ def icon(self) -> str | None: return "mdi:home-account" else: return "mdi:nature-people" + +#PR - Adding Window ByPass +class WindowByPassBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the Window ByPass state""" + + def __init__( + self, hass: HomeAssistant, unique_id, name, entry_infos + ) -> None: # pylint: disable=unused-argument + """Initialize the WindowByPass Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Window ByPass" + self._attr_unique_id = f"{self._device_name}_window_bypass_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on + if self.my_climate.window_bypass_state in [True, False]: + self._attr_is_on = self.my_climate.window_bypass_state + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.RUNNING + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:autorenew-off" + else: + return "mdi:autorenew" \ No newline at end of file diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 950a5a42..e623d4a4 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -119,6 +119,8 @@ SERVICE_SET_PRESENCE, SERVICE_SET_PRESET_TEMPERATURE, SERVICE_SET_SECURITY, + #PR - Adding Window ByPass + SERVICE_SET_WINDOW_BYPASS, PRESET_AWAY_SUFFIX, CONF_SECURITY_DELAY_MIN, CONF_SECURITY_MIN_ON_PERCENT, @@ -207,6 +209,15 @@ async def async_setup_entry( "service_set_security", ) + #PR - Adding Window ByPass + platform.async_register_entity_service( + SERVICE_SET_WINDOW_BYPASS, + { + vol.Required("window_bypass"): vol.In([True, False] + ), + }, + "service_set_window_bypass_state", + ) class VersatileThermostat(ClimateEntity, RestoreEntity): """Representation of a Versatile Thermostat device.""" @@ -223,6 +234,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _motion_state: bool _presence_state: bool _window_auto_state: bool + #PR - Adding Window ByPass + _window_bypass_state: bool _underlyings: list[UnderlyingEntity] _last_change_time: datetime @@ -287,6 +300,9 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: self._window_auto_on = False self._window_auto_algo = None + # PR - Adding Window ByPass + self._window_bypass_state = False + self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) self._last_change_time = None @@ -1196,6 +1212,12 @@ def nb_underlying_entities(self) -> int: """Returns the number of underlying entities""" return len(self._underlyings) + #PR - Adding Window ByPass + @property + def window_bypass_state(self) -> bool | None: + """Get the Window Bypass""" + return self._window_bypass_state + def underlying_entity_id(self, index=0) -> str | None: """The climate_entity_id. Added for retrocompatibility reason""" if index < self.nb_underlying_entities: @@ -1515,6 +1537,14 @@ async def try_window_condition(_): return self._window_state = new_state.state + + #PR - Adding Window ByPass + _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) + if self._window_bypass_state: + _LOGGER.debug("Window ByPass is activated. Ignore window event") + self.update_custom_attributes() + return + if self._window_state == STATE_OFF: _LOGGER.info( "%s - Window is closed. Restoring hvac_mode '%s'", @@ -2524,6 +2554,8 @@ def update_custom_attributes(self): "overpowering_state": self._overpowering_state, "presence_state": self._presence_state, "window_auto_state": self._window_auto_state, + #PR - Adding Window ByPass + "window_bypass_state": self._window_bypass_state, "security_delay_min": self._security_delay_min, "security_min_on_percent": self._security_min_on_percent, "security_default_on_percent": self._security_default_on_percent, @@ -2691,6 +2723,23 @@ async def service_set_security(self, delay_min, min_on_percent, default_on_perce await self._async_control_heating() self.update_custom_attributes() + #PR - Adding Window ByPass + async def service_set_window_bypass_state(self, window_bypass): + """Called by a service call: + service: versatile_thermostat.set_window_bypass + data: + window_bypass: True + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info("%s - Calling service_set_window_bypass, window_bypass: %s", self, window_bypass) + self._window_bypass_state = window_bypass + if not self._window_bypass_state and self._window_state == STATE_ON: + _LOGGER.info("%s - Last window state was open & ByPass is now off. Set hvac_mode to '%s'", self, HVACMode.OFF) + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + self.update_custom_attributes() + def send_event(self, event_type: EventType, data: dict): """Send an event""" _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 4ef34e2b..df280466 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -192,6 +192,8 @@ SERVICE_SET_PRESENCE = "set_presence" SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" SERVICE_SET_SECURITY = "set_security" +#PR - Adding Window ByPass +SERVICE_SET_WINDOW_BYPASS = "set_window_bypass" DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5 DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 diff --git a/custom_components/versatile_thermostat/services.yaml b/custom_components/versatile_thermostat/services.yaml index b32536b9..e57381a0 100644 --- a/custom_components/versatile_thermostat/services.yaml +++ b/custom_components/versatile_thermostat/services.yaml @@ -122,3 +122,19 @@ set_security: step: 0.05 unit_of_measurement: "%" mode: slider + +set_window_bypass: + name: Set Window ByPass + description: Bypass the window state to enable heating with window open. + target: + entity: + integration: versatile_thermostat + fields: + window_bypass: + name: Window ByPass + description: ByPass value + required: true + advanced: false + default: true + selector: + boolean: \ No newline at end of file From c10047478eaa38a2e1b1bb1bacdcde19ff8fceae Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sat, 28 Oct 2023 20:02:05 +0200 Subject: [PATCH 2/9] Adding Documentation --- README-fr.md | 15 ++++++++++++++- README.md | 13 ++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README-fr.md b/README-fr.md index e4cfd616..5e235275 100644 --- a/README-fr.md +++ b/README-fr.md @@ -546,7 +546,7 @@ Si le thermostat est en mode ``security`` les nouveaux paramètres sont appliqu Pour changer les paramètres de sécurité utilisez le code suivant : ``` service : thermostat_polyvalent.set_security -date: +data: min_on_percent: "0.5" default_on_percent: "0.1" delay_min: 60 @@ -554,6 +554,19 @@ target: entity_id : climate.my_thermostat ``` +## ByPass Window Check +Ce service permet d'activer ou non un bypass de la vérification des fenetres. +Il permet de continuer à chauffer même si la fenetre est detecté ouverte. +Mis à ``true`` les modifications de status de la fenetre n'auront plus d'effet sur le thermostat, remis à ``false`` cela s'assurera de désactiver le thermostat si la fenetre est toujours ouverte. + +Pour changer le paramétre de bypass utilisez le code suivant : +``` +service : thermostat_polyvalent.set_window_bypass +data: + window_bypass: true +target: + entity_id : climate.my_thermostat + # Notifications Les évènements marquant du thermostat sont notifiés par l'intermédiaire du bus de message. Les évènements notifiés sont les suivants: diff --git a/README.md b/README.md index 8e13f3f0..749a37f1 100644 --- a/README.md +++ b/README.md @@ -532,13 +532,24 @@ If the thermostat is in ``security`` mode the new settings are applied immediate To change the security settings use the following code: ``` service : thermostat_polyvalent.set_security -date: +data: min_on_percent: "0.5" default_on_percent: "0.1" delay_min: 60 target: entity_id : climate.my_thermostat ``` +## ByPass Window Check +This service is used to bypass the window check implemented to stop thermostat when an open window is detected. +When set to ``true`` window event won't have any effect on the thermostat, when set back to ``false`` it will make sure to disable the thermostat if window is still open. + +To change the bypass setting use the following code: +``` +service : thermostat_polyvalent.set_window_bypass +data: + window_bypass: true +target: + entity_id : climate.my_thermostat # Notifications Significant thermostat events are notified via the message bus. From 8e0d79e38260851afdcb2848e8a8036eaf024dcd Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sat, 28 Oct 2023 20:23:33 +0200 Subject: [PATCH 3/9] Change icon --- custom_components/versatile_thermostat/binary_sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 72436bfc..e14392ae 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -248,7 +248,7 @@ def __init__( ) -> None: # pylint: disable=unused-argument """Initialize the WindowByPass Binary sensor""" super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) - self._attr_name = "Window ByPass" + self._attr_name = "Window bypass" self._attr_unique_id = f"{self._device_name}_window_bypass_state" self._attr_is_on = False @@ -270,6 +270,6 @@ def device_class(self) -> BinarySensorDeviceClass | None: @property def icon(self) -> str | None: if self._attr_is_on: - return "mdi:autorenew-off" + return "mdi:check-circle-outline" else: - return "mdi:autorenew" \ No newline at end of file + return "mdi:alpha-b-circle-outline" \ No newline at end of file From db376e5efa12118b0d45b9dac9f716c9955efd1b Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sat, 28 Oct 2023 21:29:22 +0200 Subject: [PATCH 4/9] Rebase --- .devcontainer/configuration.yaml | 6 + .vscode/settings.json | 2 +- README-fr.md | 230 +- README.md | 235 +- .../versatile_thermostat/__init__.py | 2 +- .../versatile_thermostat/base_thermostat.py | 2527 ++++++++++++++++ .../versatile_thermostat/climate.py | 2670 +---------------- .../versatile_thermostat/commons.py | 8 +- .../versatile_thermostat/config_flow.py | 59 +- .../versatile_thermostat/const.py | 33 +- .../versatile_thermostat/sensor.py | 48 +- .../versatile_thermostat/strings.json | 37 +- .../thermostat_climate.py | 140 + .../versatile_thermostat/thermostat_switch.py | 130 + .../versatile_thermostat/thermostat_valve.py | 318 ++ .../versatile_thermostat/translations/en.json | 3 +- .../versatile_thermostat/translations/fr.json | 27 +- .../versatile_thermostat/translations/it.json | 37 +- .../versatile_thermostat/underlyings.py | 151 +- images/config-linked-entity3.png | Bin 0 -> 26303 bytes images/config-main.png | Bin 44727 -> 40961 bytes tests/commons.py | 30 +- tests/conftest.py | 10 +- tests/test_binary_sensors.py | 18 +- tests/test_bugs.py | 43 +- tests/test_movement.py | 26 +- tests/test_multiple_switch.py | 42 +- tests/test_power.py | 12 +- tests/test_security.py | 12 +- tests/test_sensors.py | 10 +- tests/test_start.py | 24 +- tests/test_switch_ac.py | 12 +- tests/test_valve.py | 259 ++ tests/test_window.py | 26 +- 34 files changed, 4141 insertions(+), 3046 deletions(-) create mode 100644 custom_components/versatile_thermostat/base_thermostat.py create mode 100644 custom_components/versatile_thermostat/thermostat_climate.py create mode 100644 custom_components/versatile_thermostat/thermostat_switch.py create mode 100644 custom_components/versatile_thermostat/thermostat_valve.py create mode 100644 images/config-linked-entity3.png create mode 100644 tests/test_valve.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index b84cce7d..14cb6fac 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -44,6 +44,12 @@ input_number: step: 10 icon: mdi:flash unit_of_measurement: kW + fake_valve1: + name: The valve 1 + min: 0 + max: 100 + icon: mdi:pipe-valve + unit_of_measurement: percentage input_boolean: # input_boolean to simulate the windows entity. Only for development environment. diff --git a/.vscode/settings.json b/.vscode/settings.json index 70d273a3..1f76fb89 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "ms-python.python" }, "python.linting.pylintEnabled": true, "python.linting.enabled": true, diff --git a/README-fr.md b/README-fr.md index 5e235275..5ec9d774 100644 --- a/README-fr.md +++ b/README-fr.md @@ -10,6 +10,7 @@ - [Merci pour la bière buymecoffee](#merci-pour-la-bière-buymecoffee) - [Quand l'utiliser et ne pas l'utiliser](#quand-lutiliser-et-ne-pas-lutiliser) + - [Incompatibilités](#incompatibilités) - [Pourquoi une nouvelle implémentation du thermostat ?](#pourquoi-une-nouvelle-implémentation-du-thermostat-) - [Comment installer cet incroyable Thermostat Versatile ?](#comment-installer-cet-incroyable-thermostat-versatile-) - [HACS installation (recommendé)](#hacs-installation-recommendé) @@ -17,6 +18,9 @@ - [Configuration](#configuration) - [Choix des attributs de base](#choix-des-attributs-de-base) - [Sélectionnez des entités pilotées](#sélectionnez-des-entités-pilotées) + - [Pour un thermostat de type ```thermostat_over_switch```](#pour-un-thermostat-de-type-thermostat_over_switch) + - [Pour un thermostat de type ```thermostat_over_climate```:](#pour-un-thermostat-de-type-thermostat_over_climate) + - [Pour un thermostat de type ```thermostat_over_valve```:](#pour-un-thermostat-de-type-thermostat_over_valve) - [Configurez les coefficients de l'algorithme TPI](#configurez-les-coefficients-de-lalgorithme-tpi) - [Configurer la température préréglée](#configurer-la-température-préréglée) - [Configurer les portes/fenêtres en allumant/éteignant les thermostats](#configurer-les-portesfenêtres-en-allumantéteignant-les-thermostats) @@ -55,36 +59,41 @@ 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.7**: Ajout du type de Versatile Thermostat `over valve` pour piloter une vanne TRV directement ou tout autre équipement type gradateur pour le chauffage. La régulation se fait alors directement en agissant sur le pourcentage d'ouverture de l'entité sous-jacente : 0 la vanne est coupée, 100 : la vanne est ouverte à fond. Cf. [#131](https://github.com/jmcollin78/versatile_thermostat/issues/131) > * **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 > * **Release 3.2** : ajout de la possibilité de commander plusieurs switch à partir du même thermostat. Dans ce mode, les switchs sont déclenchés avec un délai pour minimiser la puissance nécessaire à un instant (on minimise les périodes de recouvrement). Voir [Configuration](#sélectionnez-des-entités-pilotées) +
+Autres versions + > * **Release 3.1** : ajout d'une détection de fenêtres/portes ouvertes par chute de température. Cette nouvelle fonction permet de stopper automatiquement un radiateur lorsque la température chute brutalement. Voir [Le mode auto](#le-mode-auto) > * **Release majeure 3.0** : ajout d'un équipement thermostat et de capteurs (binaires et non binaires) associés. Beaucoup plus proche de la philosphie Home Assistant, vous avez maintenant un accès direct à l'énergie consommée par le radiateur piloté par le thermostat et à plein d'autres capteurs qui seront utiles dans vos automatisations et dashboard. > * **release 2.3** : ajout de la mesure de puissance et d'énergie du radiateur piloté par le thermostat. > * **release 2.2** : ajout de fonction de sécurité permettant de ne pas laisser éternellement en chauffe un radiateur en cas de panne du thermomètre > * **release majeure 2.0** : ajout du thermostat "over climate" permettant de transformer n'importe quel thermostat en Versatile Thermostat et lui ajouter toutes les fonctions de ce dernier. +
# Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78) -Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil pour les bières. Ca fait très plaisir. +Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69 pour les bières. Ca fait très plaisir. # Quand l'utiliser et ne pas l'utiliser -Ce thermostat peut piloter 2 types d'équipement: +Ce thermostat peut piloter 3 types d'équipement: 1. un radiateur qui ne fonctionne qu'en mode marche/arrêt (nommé ```thermostat_over_switch```). La configuration minimale nécessaire pour utiliser ce type thermostat est : - a. un équipement comme un radiateur (un ```switch``` ou équivalent), - 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) + 1. un équipement comme un radiateur (un ```switch``` ou équivalent), + 2. une sonde de température pour la pièce (ou un input_number), + 3. 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 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. + 1. un équipement - comme une climatisation, une valve thermostatique - qui est pilotée par sa propre entity de type ```climate```, +3. un équipement qui peut prendre une valeur de 0 à 100% (nommée ```thermostat_over_valve```). A 0 le chauffage est coupé, 100% il est ouvert à fond. Ce type permet de piloter une valve thermostatique (cf. valve Shelly) qui expose une entité de type `number.` permetttant de piloter directement l'ouverture de la vanne. Versatile Thermostat régule la température de la pièce en jouant sur le pourcentage d'ouverture, à l'aide des capteurs de température intérieur et extérieur en utilisant l'algorithme TPI décrit ci-dessous. -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. +The ```thermostat_over_climate``` type allows you to add to your existing equipment all the functionalities provided by VersatileThermostat. The VersatileThermostat climate entity will control your 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-thermostat-implementation). For this type of thermostat, any heating cycles are controlled by the underlying climate entity and not by the Versatile Thermostat itself. +## Incompatibilités 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, +1. les vannes POPP de Danfoss avec retour de température. Il est impossible d'éteindre cette vanne et elle s'auto-régule d'elle-même causant des conflits avec le VTherm, 2. les vannes thermstatiques "Homematic radio". Elles ont un cycle de service incompatible avec une commande par le Versatile Thermostat # Pourquoi une nouvelle implémentation du thermostat ? @@ -106,10 +115,10 @@ Ce composant nommé __Versatile thermostat__ gère les cas d'utilisation suivant ## HACS installation (recommendé) 1. Installez [HACS](https://hacs.xyz/). De cette façon, vous obtenez automatiquement les mises à jour. -2. Ajoutez ce repository Github en tant que repository personnalisé dans les paramètres HACS. +2. L'intégration Versatile Thermostat est maintenant proposée directement depuis l'interface HACF (onglet intégrations), 3. recherchez et installez "Versatile Thermostat" dans HACS et cliquez sur "installer". 4. Redémarrez Home Assistant. -5. Ensuite, vous pouvez ajouter une intégration de Versatile Thermostat dans la page d'intégration. Vous ajoutez autant de thermostats dont vous avez besoin (généralement un par radiateur qui doit être géré ou par pompe dans le cas d'un chauffage centralisé) +5. Ensuite, vous pouvez ajouter une intégration de Versatile Thermostat dans la page Paramètres / Intégrations. Vous ajoutez autant de thermostats dont vous avez besoin (généralement un par radiateur ou par groupe de radiateurs qui doivent être gérés ou par pompe dans le cas d'un chauffage centralisé) ## Installation manuelle @@ -140,22 +149,30 @@ Suivez ensuite les étapes de configuration comme suit : Donnez les principaux attributs obligatoires : 1. un nom (sera le nom de l'intégration et aussi le nom de l'entité climate) -2. le type de thermostat ```thermostat_over_switch``` pour piloter un radiateur commandé par un switch ou ```thermostat_over_climate``` pour piloter un autre thermostat. Cf. [ci-dessus](#pourquoi-une-nouvelle-implémentation-du-thermostat) +2. le type de thermostat ```thermostat_over_switch``` pour piloter un radiateur commandé par un switch ou ```thermostat_over_climate``` pour piloter un autre thermostat, ou ```thermostat_over_valve``` Cf. [ci-dessus](#pourquoi-une-nouvelle-implémentation-du-thermostat) 4. un identifiant d'entité de capteur de température qui donne la température de la pièce dans laquelle le radiateur est installé, 5. une entité capteur de température donnant la température extérieure. Si vous n'avez pas de capteur externe, vous pouvez utiliser l'intégration météo locale -6. une durée de cycle en minutes. A chaque cycle, le radiateur s'allumera puis s'éteindra pendant une durée calculée afin d'atteindre la température ciblée (voir [preset](#configure-the-preset-temperature) ci-dessous), +6. une durée de cycle en minutes. A chaque cycle, le radiateur s'allumera puis s'éteindra pendant une durée calculée afin d'atteindre la température ciblée (voir [preset](#configure-the-preset-temperature) ci-dessous). En mode ```over_climate```, le cycle ne sert qu'à faire des controles de base mais ne régule pas directement la température. C'est le ```climate``` sous-jacent qui le fait, 7. les températures minimales et maximales du thermostat, 8. une puissance de l'équipement ce qui va activer les capteurs de puissance et énergie consommée par l'appareil, 9. la liste des fonctionnalités qui seront utilisées pour ce thermostat. En fonction de vos choix, les écrans de configuration suivants s'afficheront ou pas. > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. avec le type ```thermostat_over_swutch```, les calculs sont effectués à chaque cycle. Donc en cas de changement de conditions, il faudra attendre le prochain cycle pour voir un changement. Pour cette raison, le cycle ne doit pas être trop long. **5 min est une bonne valeur**, - 2. si le cycle est trop court, le radiateur ne pourra jamais atteindre la température cible en effet pour le radiateur à accumulation et il sera sollicité inutilement +> 1. avec les types ```over_switch``` et ```over_valve```, les calculs sont effectués à chaque cycle. Donc en cas de changement de conditions, il faudra attendre le prochain cycle pour voir un changement. Pour cette raison, le cycle ne doit pas être trop long. **5 min est une bonne valeur**, +> 2. si le cycle est trop court, le radiateur ne pourra jamais atteindre la température cible. Pour le radiateur à accumulation par exemple il sera sollicité inutilement. ## Sélectionnez des entités pilotées -En fonction de votre choix sur le type de thermostat, vous devrez choisir une ou plusieurs entités de type switch ou une entité de type climate. Seules les entités compatibles sont présentées. +En fonction de votre choix sur le type de thermostat, vous devrez choisir une ou plusieurs entités de type `switch`, `climate` ou `number`. Seules les entités compatibles avec le type sont présentées. -Pour un thermostat de type ```thermostat_over_switch```: +> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Comment choisir le type*_ +> Le choix du type est important. Même si il toujours possible de le modifier ensuite via l'IHM de configuration, il est préférable de se poser les quelques questions suivantes : +> 1. **quel type d'équipement je vais piloter ?** Dans l'ordre voici ce qu'il faut faire : +> 1. si vous avez une vanne thermostatique (TRV) commandable dans Home Assistant via une entité de type ```number``` (par exemple une _Shelly TRV_), choisissez le type `over_valve`. C'est le type le plus direct et qui assure la meilleure régulation, +> 2. si vous avez un radiateur électrique (avec ou sans fil pilote) et qu'une entité de type ```switch``` permet de l'allumer ou de l'éteindre, alors le type ```over_switch``` est préférable. La régulation sera faite par le Versatile Thermostat en fonction de la température mesuré par votre thermomètre, à l'endroit ou vous l'avez placé, +> 3. dans tous les autres cas, utilisez le mode ```over_climate```. Vous gardez votre entité ```climate``` d'origine et le Versatile Thermostat "ne fait que" piloter le on/off et la température cible de votre thermostat d'origine. La régulation est faite par votre thermostat d'origine dans ce cas. Ce mode est particulièrement adapté aux climatisations réversible tout-en-un dont l'exposition dans Home Assistant se limite à une entité de type ```climate``` +> 2. **quelle type de régulation je veux ?** Si l'équipement piloté possède son propre mécanisme de régulation (clim, certaine vanne TRV) et que cette régulation fonctionne bien, optez pour un ```over_climate``` + +### Pour un thermostat de type ```thermostat_over_switch``` ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity.png?raw=true) L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme). Si plusieurs entités de type sont configurées, la thermostat décale les activations afin de minimiser le nombre de switch actif à un instant t. Ca permet une meilleure répartition de la puissance puisque chaque radiateur va s'allumer à son tour. @@ -165,14 +182,21 @@ Exemple de déclenchement synchronisé : 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```: +### 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. +### Pour un thermostat de type ```thermostat_over_valve```: +![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity3.png?raw=true) +Vous pouvez choisir jusqu'à entité du domaine ```number``` ou ```ìnput_number``` qui vont commander les vannes. +L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme). + +Il est possible de choisir un thermostat over valve qui commande une climatisation en cochant la case "AC Mode". Dans ce cas, seul le mode refroidissement sera visible. + ## Configurez les coefficients de l'algorithme TPI -Si vous avez choisi un thermostat de type ```thermostat_over_switch``` vous arriverez sur cette page : +Si vous avez choisi un thermostat de type ```over_switch``` ou ```over_valve``` vous arriverez sur cette page : ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-tpi.png?raw=true) @@ -198,11 +222,11 @@ Le mode préréglé (preset) vous permet de préconfigurer la température cibl **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*_ - 1. En modifiant manuellement la température cible, réglez le préréglage sur Aucun (pas de préréglage). De cette façon, vous pouvez toujours définir une température cible même si aucun préréglage n'est disponible. - 2. Le préréglage standard ``Away`` est un préréglage caché qui n'est pas directement sélectionnable. Versatile Thermostat utilise la gestion de présence ou la gestion de mouvement pour régler automatiquement et dynamiquement la température cible en fonction d'une présence dans le logement ou d'une activité dans la pièce. Voir [gestion de la présence](#configure-the-presence-management). - 3. Si vous utilisez la gestion du délestage, vous verrez un préréglage caché nommé ``power``. Le préréglage de l'élément chauffant est réglé sur « puissance » lorsque des conditions de surpuissance sont rencontrées et que le délestage est actif pour cet élément chauffant. Voir [gestion de l'alimentation](#configure-the-power-management). - 4. si vous utilisez la configuration avancée, vous verrez le préréglage défini sur ``sécurité`` si la température n'a pas pu être récupérée après un certain délai - 5. Si vous ne souhaitez pas utiliser le préréglage, indiquez 0 comme température. Le préréglage sera alors ignoré et ne s'affichera pas dans le composant front +> 1. En modifiant manuellement la température cible, réglez le préréglage sur Aucun (pas de préréglage). De cette façon, vous pouvez toujours définir une température cible même si aucun préréglage n'est disponible. +> 2. Le préréglage standard ``Away`` est un préréglage caché qui n'est pas directement sélectionnable. Versatile Thermostat utilise la gestion de présence ou la gestion de mouvement pour régler automatiquement et dynamiquement la température cible en fonction d'une présence dans le logement ou d'une activité dans la pièce. Voir [gestion de la présence](#configure-the-presence-management). +> 3. Si vous utilisez la gestion du délestage, vous verrez un préréglage caché nommé ``power``. Le préréglage de l'élément chauffant est réglé sur « puissance » lorsque des conditions de surpuissance sont rencontrées et que le délestage est actif pour cet élément chauffant. Voir [gestion de l'alimentation](#configure-the-power-management). +> 4. si vous utilisez la configuration avancée, vous verrez le préréglage défini sur ``sécurité`` si la température n'a pas pu être récupérée après un certain délai +> 5. Si vous ne souhaitez pas utiliser le préréglage, indiquez 0 comme température. Le préréglage sera alors ignoré et ne s'affichera pas dans le composant front ## Configurer les portes/fenêtres en allumant/éteignant les thermostats Vous devez avoir choisi la fonctionnalité ```Avec détection des ouvertures``` dans la première page pour arriver sur cette page. @@ -240,10 +264,10 @@ Pour bien régler il est conseillé d'affocher sur un même graphique historique Et c'est tout ! votre thermostat s'éteindra lorsque les fenêtres seront ouvertes et se rallumera lorsqu'il sera fermé. > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. Si vous souhaitez utiliser **plusieurs capteurs de porte/fenêtre** pour automatiser votre thermostat, créez simplement un groupe avec le comportement habituel (https://www.home-assistant.io/integrations/binary_sensor.group/) - 2. Si vous n'avez pas de capteur de fenêtre/porte dans votre chambre, laissez simplement l'identifiant de l'entité du capteur vide, - 3. **Un seul mode est permis**. On ne peut pas configurer un thermostat avec un capteur et une détection automatique. Les 2 modes risquant de se contredire, il n'est pas possible d'avoir les 2 modes en même temps, - 4. Il est déconseillé d'utiliser le mode automatique pour un équipement soumis à des variations de température fréquentes et normales (couloirs, zones ouvertes, ...) +> 1. Si vous souhaitez utiliser **plusieurs capteurs de porte/fenêtre** pour automatiser votre thermostat, créez simplement un groupe avec le comportement habituel (https://www.home-assistant.io/integrations/binary_sensor.group/) +> 2. Si vous n'avez pas de capteur de fenêtre/porte dans votre chambre, laissez simplement l'identifiant de l'entité du capteur vide, +> 3. **Un seul mode est permis**. On ne peut pas configurer un thermostat avec un capteur et une détection automatique. Les 2 modes risquant de se contredire, il n'est pas possible d'avoir les 2 modes en même temps, +> 4. Il est déconseillé d'utiliser le mode automatique pour un équipement soumis à des variations de température fréquentes et normales (couloirs, zones ouvertes, ...) ## Configurer le mode d'activité ou la détection de mouvement Si vous avez choisi la fonctionnalité ```Avec détection de mouvement```, cliquez sur 'Valider' sur la page précédente et vous y arriverez : @@ -283,10 +307,10 @@ Notez que toutes les valeurs de puissance doivent avoir les mêmes unités (kW o Cela vous permet de modifier la puissance maximale au fil du temps à l'aide d'un planificateur ou de ce que vous voulez. > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. En cas de délestage, le radiateur est réglé sur le préréglage nommé ```power```. Il s'agit d'un préréglage caché, vous ne pouvez pas le sélectionner manuellement. - 2. Je l'utilise pour éviter de dépasser la limite de mon contrat d'électricité lorsqu'un véhicule électrique est en charge. Cela crée une sorte d'autorégulation. - 3. Gardez toujours une marge, car la puissance max peut être brièvement dépassée en attendant le calcul du prochain cycle typiquement ou par des équipements non régulés. - 4. Si vous ne souhaitez pas utiliser cette fonctionnalité, laissez simplement l'identifiant des entités vide +> 1. En cas de délestage, le radiateur est réglé sur le préréglage nommé ```power```. Il s'agit d'un préréglage caché, vous ne pouvez pas le sélectionner manuellement. +> 2. Je l'utilise pour éviter de dépasser la limite de mon contrat d'électricité lorsqu'un véhicule électrique est en charge. Cela crée une sorte d'autorégulation. +> 3. Gardez toujours une marge, car la puissance max peut être brièvement dépassée en attendant le calcul du prochain cycle typiquement ou par des équipements non régulés. +> 4. Si vous ne souhaitez pas utiliser cette fonctionnalité, laissez simplement l'identifiant des entités vide ## Configurer la présence ou l'occupation Si sélectionnée en première page, cette fonction vous permet de modifier dynamiquement la température de tous les préréglages du thermostat configurés lorsque personne n'est à la maison ou lorsque quelqu'un rentre à la maison. Pour cela, vous devez configurer la température qui sera utilisée pour chaque préréglage lorsque la présence est désactivée. Lorsque le capteur de présence s'éteint, ces températures seront utilisées. Lorsqu'il se rallume, la température "normale" configurée pour le préréglage est utilisée. Voir [gestion des préréglages](#configure-the-preset-temperature). @@ -303,8 +327,8 @@ Pour cela, vous devez configurer : 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. +> 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. ## Configuration avancée Ces paramètres permettent d'affiner le réglage du thermostat. @@ -324,70 +348,74 @@ Le quatrième param§tre (``security_default_on_percent``) est la valeur de ``on Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglage communs > ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. Lorsque le capteur de température viendra à la vie et renverra les températures, le préréglage sera restauré à sa valeur précédente, - 3. Attention, deux températures sont nécessaires : la température interne et la température externe et chacune doit donner la température, sinon le thermostat sera en préréglage "security", - 4. Un service est disponible qui permet de régler les 3 paramètres de sécurité. Ca peut servir à adapter la fonction de sécurité à votre usage, - 5. Pour un usage naturel, le ``security_default_on_percent`` doit être inférieur à ``security_min_on_percent``, - 6. Lorsqu'un thermostat de type ``thermostat_over_climate`` passe en mode ``security`` il est éteint. Les paramètres ``security_min_on_percent`` et ``security_default_on_percent`` ne sont alors pas utilisés. +> 1. Lorsque le capteur de température viendra à la vie et renverra les températures, le préréglage sera restauré à sa valeur précédente, +> 2. Attention, deux températures sont nécessaires : la température interne et la température externe et chacune doit donner la température, sinon le thermostat sera en préréglage "security", +> 3. Un service est disponible qui permet de régler les 3 paramètres de sécurité. Ca peut servir à adapter la fonction de sécurité à votre usage, +> 4. Pour un usage naturel, le ``security_default_on_percent`` doit être inférieur à ``security_min_on_percent``, +> 5. 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 | 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 | - | +| Paramètre | Libellé | "over switch" | "over climate" | over valve | +| - | - | - | - | - | +| ``name`` | Nom | X | X | X | +| ``thermostat_type`` | Type de thermostat | X | X | X | +| ``temperature_sensor_entity_id`` | Temperature sensor entity id | X | - | X | +| ``external_temperature_sensor_entity_id`` | Température exterieure sensor entity id | X | - | X | +| ``cycle_min`` | Durée du cycle (minutes) | X | X | X | +| ``temp_min`` | Température minimale permise | X | X | X | +| ``temp_max`` | Température maximale permise | X | X | X | +| ``device_power`` | Puissance de l'équipement | X | X | X | +| ``use_window_feature`` | Avec détection des ouvertures | X | X | X | +| ``use_motion_feature`` | Avec détection de mouvement | X | X | X | +| ``use_power_feature`` | Avec gestion de la puissance | X | X | X | +| ``use_presence_feature`` | Avec détection de présence | X | 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 | - | +| ``valve_entity1_id`` | Vanne sous-jacente | - | - | X | +| ``valve_entity2_id`` | 2ème vanne sous-jacente | - | - | X | +| ``valve_entity3_id`` | 3ème vanne sous-jacente | - | - | X | +| ``valve_entity4_id`` | 4ème vanne sous-jacente | - | - | X | +| ``ac_mode`` | utilisation de l'air conditionné (AC) ? | X | X | X | +| ``tpi_coef_int`` | Coefficient à utiliser pour le delta de température interne | X | - | X | +| ``tpi_coef_ext`` | Coefficient à utiliser pour le delta de température externe | X | - | X | +| ``eco_temp`` | Température en preset Eco | X | X | X | +| ``comfort_temp`` | Température en preset Confort | X | X | X | +| ``boost_temp`` | Température en preset Boost | X | X | X | +| ``eco_ac_temp`` | Température en preset Eco en mode AC | X | X | X | +| ``comfort_ac_temp`` | Température en preset Confort en mode AC | X | X | X | +| ``boost_ac_temp`` | Température en preset Boost en mode AC | X | X | X | +| ``window_sensor_entity_id`` | Détecteur d'ouverture (entity id) | X | X | X | +| ``window_delay`` | Délai avant extinction (secondes) | X | X | X | +| ``window_auto_open_threshold`` | Seuil haut de chute de température pour la détection automatique (en °/min) | X | X | X | +| ``window_auto_close_threshold`` | Seuil bas de chute de température pour la fin de détection automatique (en °/min) | X | X | X | +| ``window_auto_max_duration`` | Durée maximum d'une extinction automatique (en min) | X | X | X | +| ``motion_sensor_entity_id`` | Détecteur de mouvement entity id | X | X | X | +| ``motion_delay`` | Délai avant prise en compte du mouvement (seconds) | X | X | X | +| ``motion_off_delay`` | Délai avant prise en compte de la fin de mouvement (seconds) | X | X | X | +| ``motion_preset`` | Preset à utiliser si mouvement détecté | X | X | X | +| ``no_motion_preset`` | Preset à utiliser si pas de mouvement détecté | X | X | X | +| ``power_sensor_entity_id`` | Capteur de puissance totale (entity id) | X | X | X | +| ``max_power_sensor_entity_id`` | Capteur de puissance Max (entity id) | X | X | X | +| ``power_temp`` | Température si délestaqe | X | X | X | +| ``presence_sensor_entity_id`` | Capteur de présence entity id (true si quelqu'un est présent) | X | X | X | +| ``eco_away_temp`` | Température en preset Eco en cas d'absence | X | X | X | +| ``comfort_away_temp`` | Température en preset Comfort en cas d'absence | X | X | X | +| ``boost_away_temp`` | Température en preset Boost en cas d'absence | X | X | X | +| ``eco_ac_away_temp`` | Température en preset Eco en cas d'absence en mode AC | X | X | X | +| ``comfort_ac_away_temp`` | Température en preset Comfort en cas d'absence en mode AC | X | X | X | +| ``boost_ac_away_temp`` | Température en preset Boost en cas d'absence en mode AC | X | X | X | +| ``minimal_activation_delay`` | Délai minimal d'activation | X | - | - | +| ``security_delay_min`` | Délai maximal entre 2 mesures de températures | X | - | X | +| ``security_min_on_percent`` | Pourcentage minimal de puissance pour passer en mode sécurité | X | - | X | +| ``security_default_on_percent`` | Pourcentage de puissance a utiliser en mode securité | X | - | X | # Exemples de réglage @@ -438,12 +466,12 @@ Cette intégration utilise un algorithme proportionnel. Un algorithme proportion Cet algorithme fait converger la température et arrête d'osciller. ## Algorithme TPI -L'algorithme TPI consiste à calculer à chaque cycle un pourcentage d'état On vs Off pour le radiateur en utilisant la température cible, la température actuelle dans la pièce et la température extérieure actuelle. +L'algorithme TPI consiste à calculer à chaque cycle un pourcentage d'état On vs Off pour le radiateur en utilisant la température cible, la température actuelle dans la pièce et la température extérieure actuelle. Cet algorithme n'est donc valable que pour les Versatile Thermostat qui régulent : `over_switch` et `over_valve`. Le pourcentage est calculé avec cette formule : on_percent = coef_int * (température cible - température actuelle) + coef_ext * (température cible - température extérieure) - Ensuite, faites 0 <= on_percent <= 1 + Ensuite, l'algo fait en sorte que 0 <= on_percent <= 1 Les valeurs par défaut pour coef_int et coef_ext sont respectivement : ``0.6`` et ``0.01``. Ces valeurs par défaut conviennent à une pièce standard bien isolée. @@ -451,7 +479,9 @@ Pour régler ces coefficients, gardez à l'esprit que : 1. **si la température cible n'est pas atteinte** après une situation stable, vous devez augmenter le ``coef_ext`` (le ``on_percent`` est trop bas), 2. **si la température cible est dépassée** après une situation stable, vous devez diminuer le ``coef_ext`` (le ``on_percent`` est trop haut), 3. **si l'atteinte de la température cible est trop lente**, vous pouvez augmenter le ``coef_int`` pour donner plus de puissance au réchauffeur, -4. **si l'atteinte de la température cible est trop rapide et que des oscillations apparaissent** autour de la cible, vous pouvez diminuer le ``coef_int`` pour donner moins de puissance au radiateur +4. **si l'atteinte de la température cible est trop rapide et que des oscillations apparaissent** autour de la cible, vous pouvez diminuer le ``coef_int`` pour donner moins de puissance au radiateur. + +En type `over_valve` le `on_percent` est ramené à une valeur entre 0 et 100% et sert directement à commander l'ouverture de la vanne. Voir quelques situations à [examples](#some-results). @@ -475,6 +505,7 @@ Dans l'ordre, il y a : 11. l'état de sécurité, 12. l'état de l'ouverture (si la gestion des ouvertures est configurée), 13. l'état du mouvement (si la gestion du mouvements est configurée) +14. le pourcentage d'ouverture de la vanne (pour le type `over_valve`) Pour colorer les capteurs, ajouter ces lignes et personnalisez les au besoin, dans votre configuration.yaml : @@ -629,6 +660,7 @@ Les attributs personnalisés sont les suivants : | ``last_update_datetime`` | La date et l'heure au format ISO8866 de cet état | | ``friendly_name`` | Le nom du thermostat | | ``supported_features`` | Une combinaison de toutes les fonctionnalités prises en charge par ce thermostat. Voir la documentation officielle sur l'intégration climatique pour plus d'informations | +| ``valve_open_percent`` | Le pourcentage d'ouverture de la vanne | # Quelques résultats diff --git a/README.md b/README.md index 749a37f1..23d752b2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Thanks for the beer buymecoffee](#thanks-for-the-beer-buymecoffee) - [When to use / not use](#when-to-use--not-use) + - [Incompatibilities](#incompatibilities) - [Why another thermostat implementation ?](#why-another-thermostat-implementation-) - [How to install this incredible Versatile Thermostat ?](#how-to-install-this-incredible-versatile-thermostat-) - [HACS installation (recommended)](#hacs-installation-recommended) @@ -17,6 +18,9 @@ - [Configuration](#configuration) - [Minimal configuration update](#minimal-configuration-update) - [Select the driven entity](#select-the-driven-entity) + - [For a ```thermostat_over_switch``` type thermostat](#for-a-thermostat_over_switch-type-thermostat) + - [For a thermostat of type ```thermostat_over_climate```:](#for-a-thermostat-of-type-thermostat_over_climate) + - [For a thermostat of type ```thermostat_over_valve```:](#for-a-thermostat-of-type-thermostat_over_valve) - [Configure the TPI algorithm coefficients](#configure-the-tpi-algorithm-coefficients) - [Configure the preset temperature](#configure-the-preset-temperature) - [Configure the doors/windows turning on/off the thermostats](#configure-the-doorswindows-turning-onoff-the-thermostats) @@ -54,33 +58,38 @@ 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.7**: Addition of the Versatile Thermostat type `over valve` to control a TRV valve directly or any other dimmer type equipment for heating. Regulation is then done directly by acting on the opening percentage of the underlying entity: 0 the valve is cut off, 100: the valve is fully opened. See [#131](https://github.com/jmcollin78/versatile_thermostat/issues/131) > * **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 > * **Release 3.2**: add the ability to control multiple switches from the same thermostat. In this mode, the switches are triggered with a delay to minimize the power required at one time (we minimize the recovery periods). See [Configuration](#select-the-driven-entity) +
+Others releases + > * **Release 3.1**: added detection of open windows/doors by temperature drop. This new function makes it possible to automatically stop a radiator when the temperature drops suddenly. See [Auto mode](#auto-mode) > * **Major release 3.0**: addition of thermostat equipment and associated sensors (binary and non-binary). Much closer to the Home Assistant philosophy, you now have direct access to the energy consumed by the radiator controlled by the thermostat and many other sensors that will be useful in your automations and dashboard. > * **release 2.3**: addition of the power and energy measurement of the radiator controlled by the thermostat. > * **release 2.2**: addition of a safety function allowing a radiator not to be left heating forever in the event of a thermometer failure > * **major release 2.0**: addition of the "over climate" thermostat allowing you to transform any thermostat into a Versatile Thermostat and add all the functions of the latter. +
# Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78) -Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil for the beers. It's very pleasing. +Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69 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```). 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```). 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, - +This thermostat can control 3 types of equipment: +1. a radiator that only operates in on/off mode (called ``thermostat_over_switch```). The minimum configuration necessary to use this type thermostat is: + 1. equipment such as a radiator (a ``switch``` or equivalent), + 2. a temperature probe for the room (or an input_number), + 3. an external temperature sensor (consider weather integration if you don't have one) +2. another thermostat which has its own operating modes (named ``thermostat_over_climate```). For this type of thermostat the minimum configuration requires: + 1. equipment - such as air conditioning, a thermostatic valve - which is controlled by its own ``climate'' type entity, +3. equipment which can take a value from 0 to 100% (called `thermostat_over_valve`). At 0 the heating is cut off, 100% it is fully opened. This type allows you to control a thermostatic valve (see Shelly valve) which exposes an entity of type `number.` allowing you to directly control the opening of the valve. Versatile Thermostat regulates the room temperature by adjusting the opening percentage, using the interior and exterior temperature sensors using the TPI algorithm described below. 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. -Because this integration aims to control the radiator taking into account the configured preset and the ambient temperature, this information is mandatory. +## Incompatibilities Some TRV type thermostats are known to be incompatible with the Versatile Thermostat. This is the case for the following valves: 1. Danfoss POPP valves with temperature feedback. It is impossible to turn off this valve and it self-regulates, causing conflicts with the VTherm, @@ -88,7 +97,6 @@ Some TRV type thermostats are known to be incompatible with the Versatile Thermo # Why another thermostat implementation ? - This component named __Versatile thermostat__ manage the following use cases : - Configuration through standard integration GUI (using Config Entry flow), - Full uses of **presets mode**, @@ -106,7 +114,7 @@ This component named __Versatile thermostat__ manage the following use cases : ## HACS installation (recommended) 1. Install [HACS](https://hacs.xyz/). That way you get updates automatically. -2. Add this Github repository as custom repository in HACS settings. +2. The Versatile Thermostat integration is now offered directly from the HACF interface (integration tab). 3. search and install "Versatile Thermostat" in HACS and click `install`. 4. Restart Home Assistant, 5. Then you can add an Versatile Thermostat integration in the integration page. You add as many Versatile Thermostat that you need (typically one per heater that should be managed) @@ -138,37 +146,54 @@ Then follow the configurations steps as follow: Give the main mandatory attributes: 1. a name (will be the name of the integration and also the name of the climate entity) -2. the type of thermostat ```thermostat_over_switch``` to control a radiator controlled by a switch or ```thermostat_over_climate``` to control another thermostat. Cf. [above](#why-a-new-thermostat-implementation) +2. the thermostat type ```thermostat_over_switch``` to control a radiator controlled by a switch or ```thermostat_over_climate``` to control another thermostat, or ```thermostat_over_valve``` Cf. [above](# why-a-new-thermostat-implementation) 4. a temperature sensor entity identifier which gives the temperature of the room in which the radiator is installed, 5. a temperature sensor entity giving the outside temperature. If you don't have an external sensor, you can use local weather integration -6. a cycle duration in minutes. On each cycle, the heater will cycle on and then off for a calculated time to reach the target temperature (see [preset](#configure-the-preset-temperature) below), +6. a cycle duration in minutes. On each cycle, the heater will cycle on and then off for a calculated time to reach the target temperature (see [preset](#configure-the-preset-temperature) below). In ```over_climate``` mode, the cycle is only used to carry out basic controls but does not directly regulate the temperature. It's the underlying climate that does it, 7. minimum and maximum thermostat temperatures, 8. the power of the l'équipement which will activate the power and energy sensors of the device, 9. the list of features that will be used for this thermostat. Depending on your choices, the following configuration screens will appear or not. > ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. With the ```thermostat_over_switch``` type, calculation are done at each cycle. So in case of conditions change, you will have to wait for the next cycle to see a change. For this reason, the cycle should not be too long. **5 min is a good value**, - 2. if the cycle is too short, the heater could never reach the target temperature indeed for heater with accumulation features and it will be unnecessary solicited +> 1. With the ```thermostat_over_switch``` type, calculation are done at each cycle. So in case of conditions change, you will have to wait for the next cycle to see a change. For this reason, the cycle should not be too long. **5 min is a good value**, +> 2. if the cycle is too short, the heater could never reach the target temperature. For the storage radiator for example it will be used unnecessarily. ## Select the driven entity -Depending on your choice on the type of thermostat, you will have to choose a switch type entity or a climate type entity. Only compatible entities are shown. +Depending on your choice of thermostat type, you will need to choose one or more `switch`, `climate` or `number` type entities. Only entities compatible with the type are presented. + +> ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*How to choose the type*_ +> The choice of type is important. Even if it is always possible to modify it afterwards via the configuration HMI, it is preferable to ask yourself the following few questions: +> 1. **what type of equipment am I going to pilot?** In order, here is what to do: +> 1. if you have a thermostatic valve (TRV) that can be controlled in Home Assistant via a ```number``` type entity (for example a _Shelly TRV_), choose the `over_valve` type. It is the most direct type and which ensures the best regulation, +> 2. if you have an electric radiator (with or without pilot wire) and a ```switch``` type entity allows you to turn it on or off, then the ```over_switch``` type is preferable. Regulation will be done by the Versatile Thermostat according to the temperature measured by your thermometer, where you have placed it, +> 3. in all other cases, use the ```over_climate``` mode. You keep your original `climate` entity and the Versatile Thermostat "only" controls the on/off and the target temperature of your original thermostat. Regulation is done by your original thermostat in this case. This mode is particularly suitable for all-in-one reversible air conditioning systems whose exposure in Home Assistant is limited to a `climate` type entity. +> 2. **what type of regulation do I want?** If the controlled equipment has its own regulation mechanism (air conditioning, certain TRV valve) and this regulation works well, opt for an ``over_climate``` +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_switch``` thermostat: +### For a ```thermostat_over_switch``` type thermostat ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity.png?raw=true) -The algorithm to be used today is limited to TPI is available. See [algorithm](#algorithm) -If several type entities are configured, the thermostat staggers the activations in order to minimize the number of active switches at a time t. This allows a better distribution of power since each radiator will turn on in turn. +The algorithm to use is currently limited to TPI is available. See [algorithm](#algorithm). +If several type entities are configured, the thermostat shifts the activations in order to minimize the number of switches active at a time t. This allows for better power distribution since each radiator will turn on in turn. Example of synchronized triggering: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/multi-switch-activation.png?raw=true) 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: + +### For a thermostat of type ```thermostat_over_climate```: ![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. +### For a thermostat of type ```thermostat_over_valve```: +![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity3.png?raw=true) +You can choose up to domain entity ```number``` or ```ìnput_number``` which will control the valves. +The algorithm to use is currently limited to TPI is available. See [algorithm](#algorithm). + +It is possible to choose an over valve thermostat which controls air conditioning by checking the "AC Mode" box. In this case, only the cooling mode will be visible. + ## Configure the TPI algorithm coefficients -Click on 'Validate' on the previous page and you will get there: +click on 'Validate' on the previous page, and if you choose a ```over_switch``` or ```over_valve``` thermostat and you will get there: ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-tpi.png?raw=true) For more informations on the TPI algorithm and tuned please refer to [algorithm](#algorithm). @@ -187,11 +212,11 @@ The preset mode allows you to pre-configurate targeted temperature. Used in conj **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*_ - 1. Changing manually the target temperature, set the preset to None (no preset). This way you can always set a target temperature even if no preset are available. - 2. standard ``Away`` preset is a hidden preset which is not directly selectable. Versatile Thermostat uses the presence management or movement management to set automatically and dynamically the target temperature depending on a presence in the home or an activity in the room. See [presence management](#configure-the-presence-management). - 3. if you uses the power shedding management, you will see a hidden preset named ``power``. The heater preset is set to ``power`` when overpowering conditions are encountered and shedding is active for this heater. See [power management](#configure-the-power-management). - 4. if you uses the advanced configuration you will see the preset set to ``security`` if the temperature could not be retrieved after a certain delay - 5. ff you don't want to use the preseet, give 0 as temperature. The preset will then been ignored and will not displayed in the front component +> 1. Changing manually the target temperature, set the preset to None (no preset). This way you can always set a target temperature even if no preset are available. +> 2. standard ``Away`` preset is a hidden preset which is not directly selectable. Versatile Thermostat uses the presence management or movement management to set automatically and dynamically the target temperature depending on a presence in the home or an activity in the room. See [presence management](#configure-the-presence-management). +> 3. if you uses the power shedding management, you will see a hidden preset named ``power``. The heater preset is set to ``power`` when overpowering conditions are encountered and shedding is active for this heater. See [power management](#configure-the-power-management). +> 4. if you uses the advanced configuration you will see the preset set to ``security`` if the temperature could not be retrieved after a certain delay +> 5. ff you don't want to use the preseet, give 0 as temperature. The preset will then been ignored and will not displayed in the front component ## Configure the doors/windows turning on/off the thermostats You must have chosen the ```With opening detection``` feature on the first page to arrive on this page. @@ -228,10 +253,10 @@ To properly adjust it is advisable to display on the same historical graph the t And that's all ! your thermostat will turn off when the windows are open and turn back on when closed. > ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. If you want to use **multiple door/window sensors** to automate your thermostat, just create a group with the usual behavior (https://www.home-assistant.io/integrations/binary_sensor.group/) - 2. If you don't have a window/door sensor in your room, just leave the sensor entity id blank, - 3. **Only one mode is allowed**. You cannot configure a thermostat with a sensor and automatic detection. The 2 modes may contradict each other, it is not possible to have the 2 modes at the same time, - 4. It is not recommended to use the automatic mode for equipment subject to frequent and normal temperature variations (corridors, open areas, ...) +> 1. If you want to use **multiple door/window sensors** to automate your thermostat, just create a group with the usual behavior (https://www.home-assistant.io/integrations/binary_sensor.group/) +> 2. If you don't have a window/door sensor in your room, just leave the sensor entity id blank, +> 3. **Only one mode is allowed**. You cannot configure a thermostat with a sensor and automatic detection. The 2 modes may contradict each other, it is not possible to have the 2 modes at the same time, +> 4. It is not recommended to use the automatic mode for equipment subject to frequent and normal temperature variations (corridors, open areas, ...) ## Configure the activity mode or motion detection If you choose the ```Motion management``` feature, lick on 'Validate' on the previous page and you will get there: @@ -255,7 +280,7 @@ What we need: 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*_ - 1. Be aware that as for the others preset modes, ``Activity`` will only be proposed if it's correctly configure. In other words, the 4 configuration keys have to be set if you want to see Activity in home assistant Interface +> 1. Be aware that as for the others preset modes, ``Activity`` will only be proposed if it's correctly configure. In other words, the 4 configuration keys have to be set if you want to see Activity in home assistant Interface ## Configure the power management @@ -269,10 +294,10 @@ Note that all power values should have the same units (kW or W for example). This allows you to change the max power along time using a Scheduler or whatever you like. > ![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. When shedding is encountered, the heater is set to the preset named ``power``. This is a hidden preset, you cannot select it manually. - 2. I use this to avoid exceeded the limit of my electrical power contract when an electrical vehicle is charging. This makes a kind of auto-regulation. - 3. Always keep a margin, because max power can be briefly exceeded while waiting for the next cycle calculation typically or by not regulated equipement. - 4. If you don't want to use this feature, just leave the entities id empty +> 1. When shedding is encountered, the heater is set to the preset named ``power``. This is a hidden preset, you cannot select it manually. +> 2. I use this to avoid exceeded the limit of my electrical power contract when an electrical vehicle is charging. This makes a kind of auto-regulation. +> 3. Always keep a margin, because max power can be briefly exceeded while waiting for the next cycle calculation typically or by not regulated equipement. +> 4. If you don't want to use this feature, just leave the entities id empty ## Configure the presence or occupancy If you choose the ```Presence management``` feature, this feature allows you to dynamically changes the temperature of all configured Versatile thermostat's presets when nobody is at home or when someone comes back home. For this, you have to configure the temperature that will be used for each preset when presence is off. When the occupancy sensor turns to off, those tempoeratures will be used. When it turns on again the "normal" temperature configured for the preset is used. See [preset management](#configure-the-preset-temperature). @@ -289,8 +314,8 @@ For this you need to configure: 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. +> 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. ## Advanced configuration Those parameters allows to fine tune the thermostat. @@ -310,70 +335,74 @@ The fourth parameter (``security_default_on_percent``) is the ``on_percent`` val See [example tuning](#examples-tuning) for common tuning examples >![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_ - 1. When the temperature sensor comes to life and returns the temperatures, the preset will be restored to its previous value, - 3. Attention, two temperatures are needed: internal temperature and external temperature and each must give the temperature, otherwise the thermostat will be in "security" preset, - 4. A service is available that allows you to set the 3 security parameters. This can be used to adapt the security function to your use. - 5. For natural usage, the ``security_default_on_percent`` should be less than ``security_min_on_percent``, - 6. When a ``thermostat_over_climate`` type thermostat goes into ``security`` mode it is turned off. The ``security_min_on_percent`` and ``security_default_on_percent`` parameters are then not used. +> 1. When the temperature sensor comes to life and returns the temperatures, the preset will be restored to its previous value, +> 2. Attention, two temperatures are needed: internal temperature and external temperature and each must give the temperature, otherwise the thermostat will be in "security" preset, +> 3. A service is available that allows you to set the 3 security parameters. This can be used to adapt the security function to your use. +> 4. For natural usage, the ``security_default_on_percent`` should be less than ``security_min_on_percent``, +> 5. 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 | 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 | +| Paramètre | Libellé | "over switch" | "over climate" | "over valve" | +| ----------| --------| --- | --- | -- | +| ``name`` | Name | X | X | X | +| ``thermostat_type`` | Thermostat type | X | X | X | +| ``temperature_sensor_entity_id`` | Temperature sensor entity id | X | - | X | +| ``external_temperature_sensor_entity_id`` | External temperature sensor entity id | X | - | X | +| ``cycle_min`` | Cycle duration (minutes) | X | X | X | +| ``temp_min`` | Minimal temperature allowed | X | X | X | +| ``temp_max`` | Maximal temperature allowed | X | X | X | +| ``device_power`` | Device power | X | X | X | +| ``use_window_feature`` | Use window detection | X | X | X | +| ``use_motion_feature`` | Use motion detection | X | X | X | +| ``use_power_feature`` | Use power management | X | X | X | +| ``use_presence_feature`` | Use presence detection | X | 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 | - | 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 | - | +| ``valve_entity1_id`` | 1rst underlying valve | - | - | X | +| ``valve_entity2_id`` | 2nd underlying valve | - | - | X | +| ``valve_entity3_id`` | 3rd underlying valve | - | - | X | +| ``valve_entity4_id`` | 4th underlying valve | - | - | X | +| ``ac_mode`` | Use the Air Conditioning (AC) mode | X | X | X | +| ``tpi_coef_int`` | Coefficient to use for internal temperature delta | X | - | X | +| ``tpi_coef_ext`` | Coefficient to use for external temperature delta | X | - | X | +| ``eco_temp`` | Temperature in Eco preset | X | X | X | +| ``comfort_temp`` | Temperature in Comfort preset | X | X | X | +| ``boost_temp`` | Temperature in Boost preset | X | X | X | +| ``eco_ac_temp`` | Temperature in Eco preset for AC mode | X | X | X | +| ``comfort_ac_temp`` | Temperature in Comfort preset for AC mode | X | X | X | +| ``boost_ac_temp`` | Temperature in Boost preset for AC mode | X | X | X | +| ``window_sensor_entity_id`` | Window sensor entity id | X | X | X | +| ``window_delay`` | Window sensor delay (seconds) | X | X | X | +| ``window_auto_open_threshold`` | Temperature decrease threshold for automatic window open detection (in °/min) | X | X | X | +| ``window_auto_close_threshold`` | Temperature increase threshold for end of automatic detection (in °/min) | X | X | X | +| ``window_auto_max_duration`` | Maximum duration of automatic window open detection (in min) | X | X | X | +| ``motion_sensor_entity_id`` | Motion sensor entity id | X | X | X | +| ``motion_delay`` | Delay before considering the motion (seconds) | X | X | X | +| ``motion_off_delay`` | Delay before considering the end of motion (seconds) | X | X | X | +| ``motion_preset`` | Preset to use when motion is detected | X | X | X | +| ``no_motion_preset`` | Preset to use when no motion is detected | X | X | X | +| ``power_sensor_entity_id`` | Power sensor entity id | X | X | X | +| ``max_power_sensor_entity_id`` | Max power sensor entity id | X | X | X | +| ``power_temp`` | Temperature for Power shedding | X | X | X | +| ``presence_sensor_entity_id`` | Presence sensor entity id | X | X | X | +| ``eco_away_temp`` | Temperature in Eco preset when no presence | X | X | X | +| ``comfort_away_temp`` | Temperature in Comfort preset when no presence | X | X | X | +| ``boost_away_temp`` | Temperature in Boost preset when no presence | X | X | X | +| ``eco_ac_away_temp`` | Temperature in Eco preset when no presence in AC mode | X | X | X | +| ``comfort_ac_away_temp`` | Temperature in Comfort preset when no presence in AC mode | X | X | X | +| ``boost_ac_away_temp`` | Temperature in Boost preset when no presence in AC mode | X | X | X | +| ``minimal_activation_delay`` | Minimal activation delay | X | - | X | +| ``security_delay_min`` | Security delay (in minutes) | X | X | X | +| ``security_min_on_percent`` | Minimal power percent to enable security mode | X | X | X | +| ``security_default_on_percent`` | Power percent to use in security mode | X | X | X | # Examples tuning @@ -424,7 +453,7 @@ This integration uses a proportional algorithm. A Proportional algorithm is usef This algorithm make the temperature converge and stop oscillating. ## TPI algorithm -The TPI algorithm consist in the calculation at each cycle of a percentage of On state vs Off state for the heater using the target temperature, the current temperature in the room and the current external temperature. +The TPI algorithm consist in the calculation at each cycle of a percentage of On state vs Off state for the heater using the target temperature, the current temperature in the room and the current external temperature. This algorithm is therefore only valid for Versatile Thermostats which regulate: `over_switch` and `over_valve`. The percentage is calculated with this formula: @@ -439,6 +468,8 @@ To tune those coefficients keep in mind that: 3. **if reaching the target temperature is too slow**, you can increase the ``coef_int`` to give more power to the heater, 4. **if reaching the target temperature is too fast and some oscillations appears** around the target, you can decrease the ``coef_int`` to give less power to the heater +In type `over_valve` the `on_percent` is reduced to a value between 0 and 100% and is used directly to control the opening of the valve. + See some situations at [examples](#some-results). # Sensors @@ -460,7 +491,8 @@ In order, there are: 10. presence status (if presence management is configured), 11. security status, 12. opening status (if opening management is configured), -13. motion status (if motion management is configured) +13. motion status (if motion management is configured), +14. the valve opening percentage (for the `over_valve` type) To color the sensors, add these lines and customize them as needed, in your configuration.yaml: @@ -613,6 +645,7 @@ Custom attributes are the following: | ``last_update_datetime`` | The date and time in ISO8866 format of this state | | ``friendly_name`` | The name of the thermostat | | ``supported_features`` | A combination of all features supported by this thermostat. See official climate integration documentation for more informations | +| ``valve_open_percent`` | The opening percentage of the valve | # Some results diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index e99a36ca..68fa6499 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .climate import VersatileThermostat +from .base_thermostat import BaseThermostat from .const import DOMAIN, PLATFORMS diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py new file mode 100644 index 00000000..8fcf55a4 --- /dev/null +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -0,0 +1,2527 @@ +# pylint: disable=line-too-long +# pylint: disable=too-many-lines +# pylint: disable=invalid-name +""" Implements the VersatileThermostat climate component """ +import math +import logging + +from datetime import timedelta, datetime + +from homeassistant.util import dt as dt_util +from homeassistant.core import ( + HomeAssistant, + callback, + CoreState, + Event, + State, +) + +from homeassistant.components.climate import ClimateEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType + +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_call_later, +) + +from homeassistant.exceptions import ConditionError +from homeassistant.helpers import condition + +from homeassistant.components.climate import ( + ATTR_PRESET_MODE, + # ATTR_FAN_MODE, + HVACMode, + HVACAction, + # HVAC_MODE_COOL, + # HVAC_MODE_HEAT, + # HVAC_MODE_OFF, + PRESET_ACTIVITY, + # PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + # PRESET_HOME, + PRESET_NONE, + # PRESET_SLEEP, + ClimateEntityFeature, +) + +from homeassistant.const import ( + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_OFF, + STATE_ON, + EVENT_HOMEASSISTANT_START, + STATE_HOME, + STATE_NOT_HOME, +) + +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_POWER_SENSOR, + CONF_TEMP_SENSOR, + CONF_EXTERNAL_TEMP_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_WINDOW_SENSOR, + CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, + CONF_WINDOW_AUTO_MAX_DURATION, + CONF_MOTION_SENSOR, + CONF_MOTION_DELAY, + CONF_MOTION_OFF_DELAY, + CONF_MOTION_PRESET, + CONF_NO_MOTION_PRESET, + CONF_DEVICE_POWER, + CONF_PRESETS, + CONF_PRESETS_AWAY, + CONF_PRESETS_WITH_AC, + CONF_PRESETS_AWAY_WITH_AC, + CONF_CYCLE_MIN, + CONF_PROP_FUNCTION, + CONF_TPI_COEF_INT, + CONF_TPI_COEF_EXT, + CONF_PRESENCE_SENSOR, + CONF_PRESET_POWER, + SUPPORT_FLAGS, + PRESET_POWER, + PRESET_SECURITY, + PROPORTIONAL_FUNCTION_TPI, + PRESET_AWAY_SUFFIX, + CONF_SECURITY_DELAY_MIN, + CONF_SECURITY_MIN_ON_PERCENT, + CONF_SECURITY_DEFAULT_ON_PERCENT, + DEFAULT_SECURITY_MIN_ON_PERCENT, + DEFAULT_SECURITY_DEFAULT_ON_PERCENT, + CONF_MINIMAL_ACTIVATION_DELAY, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + HIDDEN_PRESETS, + CONF_AC_MODE, + UnknownEntity, + EventType, + ATTR_MEAN_POWER_CYCLE, + ATTR_TOTAL_ENERGY, + PRESET_AC_SUFFIX, +) + +from .underlyings import UnderlyingEntity + +from .prop_algorithm import PropAlgorithm +from .open_window_algorithm import WindowOpenDetectionAlgorithm + +_LOGGER = logging.getLogger(__name__) + + +class BaseThermostat(ClimateEntity, RestoreEntity): + """Representation of a base class for all Versatile Thermostat device.""" + + # The list of VersatileThermostat entities + _hass: HomeAssistant + _last_temperature_mesure: datetime + _last_ext_temperature_mesure: datetime + _total_energy: float + _overpowering_state: bool + _window_state: bool + _motion_state: bool + _presence_state: bool + _window_auto_state: bool + _underlyings: list[UnderlyingEntity] + _last_change_time: datetime + + _entity_component_unrecorded_attributes = ClimateEntity._entity_component_unrecorded_attributes.union(frozenset( + { + "type", + "eco_temp", + "boost_temp", + "comfort_temp", + "eco_away_temp", + "boost_away_temp", + "comfort_away_temp", + "power_temp", + "ac_mode", + "current_power_max", + "saved_preset_mode", + "saved_target_temp", + "saved_hvac_mode", + "security_delay_min", + "security_min_on_percent", + "security_default_on_percent", + "last_temperature_datetime", + "last_ext_temperature_datetime", + "minimal_activation_delay_sec", + "device_power", + "mean_cycle_power", + "last_update_datetime", + "timezone", + "window_sensor_entity_id", + "window_delay_sec", + "window_auto_open_threshold", + "window_auto_close_threshold", + "window_auto_max_duration", + "motion_sensor_entity_id", + "presence_sensor_entity_id", + "power_sensor_entity_id", + "max_power_sensor_entity_id", + } + )) + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat.""" + + super().__init__() + + self._hass = hass + self._attr_extra_state_attributes = {} + + self._unique_id = unique_id + self._name = name + self._prop_algorithm = None + self._async_cancel_cycle = None + self._hvac_mode = None + self._target_temp = None + self._saved_target_temp = None + self._saved_preset_mode = None + self._fan_mode = None + self._humidity = None + self._swing_mode = None + self._current_power = None + self._current_power_max = None + self._window_state = None + self._motion_state = None + self._saved_hvac_mode = None + self._window_call_cancel = None + self._motion_call_cancel = None + self._cur_ext_temp = None + self._cur_temp = None + self._ac_mode = None + self._last_ext_temperature_mesure = None + self._last_temperature_mesure = None + self._cur_ext_temp = None + self._presence_state = None + self._overpowering_state = None + self._should_relaunch_control_heating = None + + self._security_delay_min = None + self._security_min_on_percent = None + self._security_default_on_percent = None + self._security_state = None + + self._thermostat_type = None + + self._attr_translation_key = "versatile_thermostat" + + self._total_energy = None + + # because energy of climate is calculated in the thermostat we have to keep that here and not in underlying entity + self._underlying_climate_start_hvac_action_date = None + self._underlying_climate_delta_t = 0 + + self._window_sensor_entity_id = None + self._window_delay_sec = None + self._window_auto_open_threshold = 0 + self._window_auto_close_threshold = 0 + self._window_auto_max_duration = 0 + self._window_auto_state = False + self._window_auto_on = False + self._window_auto_algo = None + + self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) + + self._last_change_time = None + + self._underlyings = [] + + self.post_init(entry_infos) + + def post_init(self, entry_infos): + """Finish the initialization of the thermostast""" + + _LOGGER.info( + "%s - Updating VersatileThermostat with infos %s", + self, + entry_infos, + ) + + 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() + for key, value in items: + _LOGGER.debug("looking for key=%s, value=%s", key, value) + if value in entry_infos: + presets[key] = entry_infos.get(value) + else: + _LOGGER.debug("value %s not found in Entry", value) + + presets_away = {} + items = ( + CONF_PRESETS_AWAY_WITH_AC.items() + if self._ac_mode + else CONF_PRESETS_AWAY.items() + ) + for key, value in items: + _LOGGER.debug("looking for key=%s, value=%s", key, value) + if value in entry_infos: + presets_away[key] = entry_infos.get(value) + else: + _LOGGER.debug("value %s not found in Entry", value) + + if self._window_call_cancel is not None: + self._window_call_cancel() + self._window_call_cancel = None + if self._motion_call_cancel is not None: + self._motion_call_cancel() + self._motion_call_cancel = None + + self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) + + # Initialize underlying entities (will be done in subclasses) + self._underlyings = [] + + self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) + self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) + self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) + self._attr_max_temp = entry_infos.get(CONF_TEMP_MAX) + self._attr_min_temp = entry_infos.get(CONF_TEMP_MIN) + self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) + self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) + self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) + self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY) + + self._window_auto_open_threshold = entry_infos.get( + CONF_WINDOW_AUTO_OPEN_THRESHOLD + ) + self._window_auto_close_threshold = entry_infos.get( + CONF_WINDOW_AUTO_CLOSE_THRESHOLD + ) + self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION) + self._window_auto_on = ( + self._window_auto_open_threshold is not None + and self._window_auto_open_threshold > 0.0 + and self._window_auto_close_threshold is not None + and self._window_auto_max_duration is not None + and self._window_auto_max_duration > 0 + ) + self._window_auto_state = False + self._window_auto_algo = WindowOpenDetectionAlgorithm( + alert_threshold=self._window_auto_open_threshold, + end_alert_threshold=self._window_auto_close_threshold, + ) + + self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR) + self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY) + self._motion_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 = ( + self._motion_sensor_entity_id is not None + and self._motion_preset is not None + and self._no_motion_preset is not None + ) + + self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) + self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) + self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR) + self._power_temp = entry_infos.get(CONF_PRESET_POWER) + + self._presence_on = self._presence_sensor_entity_id is not None + + if self._ac_mode: + self._hvac_list = [HVACMode.COOL, HVACMode.OFF] + else: + self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] + + self._unit = self._hass.config.units.temperature_unit + # Will be restored if possible + self._hvac_mode = None # HVAC_MODE_OFF + self._saved_hvac_mode = self._hvac_mode + + self._support_flags = SUPPORT_FLAGS + + self._presets = presets + self._presets_away = presets_away + + _LOGGER.debug( + "%s - presets are set to: %s, away: %s", + self, + self._presets, + self._presets_away, + ) + # Will be restored if possible + self._attr_preset_mode = PRESET_NONE + self._saved_preset_mode = PRESET_NONE + + # Power management + self._device_power = entry_infos.get(CONF_DEVICE_POWER) + self._pmax_on = False + self._current_power = None + self._current_power_max = None + if ( + self._max_power_sensor_entity_id + and self._power_sensor_entity_id + and self._device_power + ): + self._pmax_on = True + else: + _LOGGER.info("%s - Power management is not fully configured", self) + + # will be restored if possible + self._target_temp = None + self._saved_target_temp = PRESET_NONE + self._humidity = None + self._fan_mode = None + self._swing_mode = None + self._cur_temp = None + self._cur_ext_temp = None + + # Fix parameters for TPI + if ( + self._proportional_function == PROPORTIONAL_FUNCTION_TPI + and self._ext_temp_sensor_entity_id is None + ): + _LOGGER.warning( + "Using TPI function but not external temperature sensor is set. Removing the delta temp ext factor. Thermostat will not be fully operationnal" # pylint: disable=line-too-long + ) + self._tpi_coef_ext = 0 + + self._security_delay_min = entry_infos.get(CONF_SECURITY_DELAY_MIN) + self._security_min_on_percent = ( + entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) + if entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) is not None + else DEFAULT_SECURITY_MIN_ON_PERCENT + ) + self._security_default_on_percent = ( + entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) + if entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) is not None + else DEFAULT_SECURITY_DEFAULT_ON_PERCENT + ) + self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY) + self._last_temperature_mesure = datetime.now(tz=self._current_tz) + self._last_ext_temperature_mesure = datetime.now(tz=self._current_tz) + self._security_state = False + + # Initiate the ProportionalAlgorithm + if self._prop_algorithm is not None: + del self._prop_algorithm + if not self.is_over_climate: + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + ) + self._should_relaunch_control_heating = False + + # Memory synthesis state + self._motion_state = None + self._window_state = None + self._overpowering_state = None + self._presence_state = None + + # Calculate all possible presets + self._attr_preset_modes = [PRESET_NONE] + if len(presets): + self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE + + for key, val in CONF_PRESETS.items(): + if val != 0.0: + self._attr_preset_modes.append(key) + + _LOGGER.debug( + "After adding presets, preset_modes to %s", self._attr_preset_modes + ) + else: + _LOGGER.debug("No preset_modes") + + if self._motion_on: + self._attr_preset_modes.append(PRESET_ACTIVITY) + + self._total_energy = 0 + + _LOGGER.debug( + "%s - Creation of a new VersatileThermostat entity: unique_id=%s", + self, + self.unique_id, + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._temp_sensor_entity_id], + self._async_temperature_changed, + ) + ) + + if self._ext_temp_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._ext_temp_sensor_entity_id], + self._async_ext_temperature_changed, + ) + ) + + if self._window_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._window_sensor_entity_id], + self._async_windows_changed, + ) + ) + if self._motion_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._motion_sensor_entity_id], + self._async_motion_changed, + ) + ) + + if self._power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._power_sensor_entity_id], + self._async_power_changed, + ) + ) + + if self._max_power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._max_power_sensor_entity_id], + self._async_max_power_changed, + ) + ) + + if self._presence_on: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._presence_sensor_entity_id], + self._async_presence_changed, + ) + ) + + self.async_on_remove(self.remove_thermostat) + + try: + await self.async_startup() + except UnknownEntity: + # Ingore this error which is possible if underlying climate is not found temporary + pass + + def remove_thermostat(self): + """Called when the thermostat will be removed""" + _LOGGER.info("%s - Removing thermostat", self) + for under in self._underlyings: + under.remove_entity() + + async def async_startup(self): + """Triggered on startup, used to get old state and set internal states accordingly""" + _LOGGER.debug("%s - Calling async_startup", self) + + @callback + async def _async_startup_internal(*_): + _LOGGER.debug("%s - Calling async_startup_internal", self) + need_write_state = False + + # Initialize all UnderlyingEntities + for under in self._underlyings: + 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 ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - temperature sensor have been retrieved: %.1f", + self, + float(temperature_state.state), + ) + await self._async_update_temp(temperature_state) + need_write_state = True + + if self._ext_temp_sensor_entity_id: + ext_temperature_state = self.hass.states.get( + self._ext_temp_sensor_entity_id + ) + if ext_temperature_state and ext_temperature_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - external temperature sensor have been retrieved: %.1f", + self, + float(ext_temperature_state.state), + ) + await self._async_update_ext_temp(ext_temperature_state) + else: + _LOGGER.debug( + "%s - external temperature sensor have NOT been retrieved cause unknown or unavailable", + self, + ) + else: + _LOGGER.debug( + "%s - external temperature sensor have NOT been retrieved cause no external sensor", + self, + ) + + if self._pmax_on: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %.3f", + self, + self._current_power, + ) + need_write_state = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power_max = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %.3f", + self, + self._current_power_max, + ) + need_write_state = True + + # try to acquire window entity state + if self._window_sensor_entity_id: + window_state = self.hass.states.get(self._window_sensor_entity_id) + if window_state and window_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._window_state = window_state.state + _LOGGER.debug( + "%s - Window state have been retrieved: %s", + self, + self._window_state, + ) + need_write_state = True + + # try to acquire motion entity state + if self._motion_sensor_entity_id: + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_state and motion_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._motion_state = motion_state.state + _LOGGER.debug( + "%s - Motion state have been retrieved: %s", + self, + self._motion_state, + ) + # recalculate the right target_temp in activity mode + await self._async_update_motion_temp() + need_write_state = True + + if self._presence_on: + # try to acquire presence entity state + presence_state = self.hass.states.get(self._presence_sensor_entity_id) + if presence_state and presence_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + await self._async_update_presence(presence_state.state) + _LOGGER.debug( + "%s - Presence have been retrieved: %s", + self, + presence_state.state, + ) + need_write_state = True + + if need_write_state: + self.async_write_ha_state() + if self._prop_algorithm: + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode == HVACMode.COOL, + ) + + self.hass.create_task(self._check_switch_initial_state()) + + self.reset_last_change_time() + + await self.get_my_previous_state() + + if self.hass.state == CoreState.running: + await _async_startup_internal() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_startup_internal + ) + + async def get_my_previous_state(self): + """Try to get my previou state""" + # Check If we have an old state + old_state = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling get_my_previous_state old_state is %s", self, old_state + ) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self._ac_mode: + await self._async_internal_set_temperature(self.max_temp) + else: + await self._async_internal_set_temperature(self.min_temp) + _LOGGER.warning( + "%s - Undefined target temperature, falling back to %s", + self, + self._target_temp, + ) + else: + await self._async_internal_set_temperature( + float(old_state.attributes[ATTR_TEMPERATURE]) + ) + + old_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + # Never restore a Power or Security preset + if ( + old_preset_mode in self._attr_preset_modes + and old_preset_mode not in HIDDEN_PRESETS + ): + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + self.save_preset_mode() + else: + self._attr_preset_mode = PRESET_NONE + + if not self._hvac_mode and old_state.state: + self._hvac_mode = old_state.state + else: + self._hvac_mode = HVACMode.OFF + + old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY) + if old_total_energy: + self._total_energy = old_total_energy + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self._ac_mode: + await self._async_internal_set_temperature(self.max_temp) + else: + await self._async_internal_set_temperature(self.min_temp) + _LOGGER.warning( + "No previously saved temperature, setting to %s", self._target_temp + ) + + self._saved_target_temp = self._target_temp + + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVACMode.OFF + + self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) + self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) + + _LOGGER.info( + "%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s", + self, + self._target_temp, + self._attr_preset_mode, + self._hvac_mode, + ) + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def is_over_climate(self) -> bool: + """ True if the Thermostat is over_climate""" + return False + + @property + def is_over_switch(self) -> bool: + """ True if the Thermostat is over_switch""" + return False + + @property + def is_over_valve(self) -> bool: + """ True if the Thermostat is over_valve""" + return False + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._unique_id)}, + name=self._name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @property + def unique_id(self): + return self._unique_id + + @property + def should_poll(self): + return False + + @property + def name(self): + return self._name + + @property + def hvac_modes(self): + """List of available operation modes.""" + 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. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).fan_mode + + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).fan_modes + + return [] + + @property + def swing_mode(self) -> str | None: + """Return the swing setting. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).swing_mode + + return None + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).swing_modes + + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).temperature_unit + + return self._unit + + @property + def hvac_mode(self) -> HVACMode | None: + """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. + # if self.is_over_climate: + # if one not OFF -> return it + # else OFF + # for under in self._underlyings: + # if (mode := under.hvac_mode) not in [HVACMode.OFF] + # return mode + # return HVACMode.OFF + + return self._hvac_mode + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation if supported. + Need to be one of CURRENT_HVAC_*. + """ + if self._hvac_mode == HVACMode.OFF: + action = HVACAction.OFF + elif not self.is_device_active: + action = HVACAction.IDLE + elif self._hvac_mode == HVACMode.COOL: + action = HVACAction.COOLING + else: + action = HVACAction.HEATING + return action + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def supported_features(self): + """Return the list of supported features.""" + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).supported_features | self._support_flags + + return self._support_flags + + @property + def is_device_active(self): + """Returns true if one underlying is active""" + for under in self._underlyings: + if under.is_device_active: + return True + return False + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + @property + def target_temperature_step(self) -> float | None: + """Return the supported step of target temperature.""" + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_step + + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the highbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_high + + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_low + + return None + + @property + def is_aux_heat(self) -> bool | None: + """Return true if aux heater. + + Requires ClimateEntityFeature.AUX_HEAT. + """ + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).is_aux_heat + + return None + + @property + def mean_cycle_power(self) -> float | None: + """Returns the mean power consumption during the cycle""" + if not self._device_power or self.is_over_climate: + return None + + return float( + self.nb_underlying_entities + * self._device_power + * self._prop_algorithm.on_percent + ) + + @property + def total_energy(self) -> float | None: + """Returns the total energy calculated for this thermostast""" + return self._total_energy + + @property + def device_power(self) -> float | None: + """Returns the device_power for this thermostast""" + return self._device_power + + @property + def overpowering_state(self) -> bool | None: + """Get the overpowering_state""" + return self._overpowering_state + + @property + def window_state(self) -> bool | None: + """Get the window_state""" + return self._window_state + + @property + def window_auto_state(self) -> bool | None: + """Get the window_auto_state""" + return STATE_ON if self._window_auto_state else STATE_OFF + + @property + def security_state(self) -> bool | None: + """Get the security_state""" + return self._security_state + + @property + def motion_state(self) -> bool | None: + """Get the motion_state""" + return self._motion_state + + @property + def presence_state(self) -> bool | None: + """Get the presence_state""" + return self._presence_state + + @property + def proportional_algorithm(self) -> PropAlgorithm | None: + """Get the eventual ProportionalAlgorithm""" + return self._prop_algorithm + + @property + def last_temperature_mesure(self) -> datetime | None: + """Get the last temperature datetime""" + return self._last_temperature_mesure + + @property + def last_ext_temperature_mesure(self) -> datetime | None: + """Get the last external temperature datetime""" + return self._last_ext_temperature_mesure + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_modes + + @property + def last_temperature_slope(self) -> float | None: + """Return the last temperature slope curve if any""" + if not self._window_auto_algo: + return None + else: + return self._window_auto_algo.last_slope + + @property + def is_window_auto_enabled(self) -> bool: + """True if the Window auto feature is enabled""" + return self._window_auto_on + + @property + def nb_underlying_entities(self) -> int: + """Returns the number of underlying entities""" + return len(self._underlyings) + + def underlying_entity_id(self, index=0) -> str | None: + """The climate_entity_id. Added for retrocompatibility reason""" + if index < self.nb_underlying_entities: + return self.underlying_entity(index).entity_id + else: + return None + + def underlying_entity(self, index=0) -> UnderlyingEntity | None: + """Get the underlying entity at specified index""" + if index < self.nb_underlying_entities: + return self._underlyings[index] + else: + return None + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + if self.is_over_climate and self.underlying_entity(0): + return self.underlying_entity(0).turn_aux_heat_on() + + raise NotImplementedError() + + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + if self.is_over_climate: + for under in self._underlyings: + await under.async_turn_aux_heat_on() + + raise NotImplementedError() + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + if self.is_over_climate: + for under in self._underlyings: + return under.turn_aux_heat_off() + + raise NotImplementedError() + + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + if self.is_over_climate: + for under in self._underlyings: + await under.async_turn_aux_heat_off() + + raise NotImplementedError() + + async def async_set_hvac_mode(self, hvac_mode, need_control_heating=True): + """Set new target hvac mode.""" + _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) + + if hvac_mode is None: + return + + self._hvac_mode = hvac_mode + + # Delegate to all underlying + sub_need_control_heating = False + for under in self._underlyings: + sub_need_control_heating = ( + await under.set_hvac_mode(hvac_mode) or need_control_heating + ) + + # If AC is on maybe we have to change the temperature in force mode + if self._ac_mode: + await self._async_set_preset_mode_internal(self._attr_preset_mode, True) + + if need_control_heating and sub_need_control_heating: + await self.async_control_heating(force=True) + + # 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}) + + async def async_set_preset_mode(self, preset_mode): + """Set new preset mode.""" + await self._async_set_preset_mode_internal(preset_mode) + await self.async_control_heating(force=True) + + async def _async_set_preset_mode_internal(self, preset_mode, force=False): + """Set new preset mode.""" + _LOGGER.info("%s - Set preset_mode: %s force=%s", self, preset_mode, force) + if ( + preset_mode not in (self._attr_preset_modes or []) + and preset_mode not in HIDDEN_PRESETS + ): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" # pylint: disable=line-too-long + ) + + if preset_mode == self._attr_preset_mode and not force: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + + # In security mode don't change preset but memorise the new expected preset when security will be off + if preset_mode != PRESET_SECURITY and self._security_state: + _LOGGER.debug( + "%s - is in security mode. Just memorise the new expected ", self + ) + if preset_mode not in HIDDEN_PRESETS: + self._saved_preset_mode = preset_mode + return + + old_preset_mode = self._attr_preset_mode + if preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE + if self._saved_target_temp: + await self._async_internal_set_temperature(self._saved_target_temp) + elif preset_mode == PRESET_ACTIVITY: + self._attr_preset_mode = PRESET_ACTIVITY + await self._async_update_motion_temp() + else: + if self._attr_preset_mode == PRESET_NONE: + self._saved_target_temp = self._target_temp + self._attr_preset_mode = preset_mode + await self._async_internal_set_temperature( + self.find_preset_temp(preset_mode) + ) + + self.reset_last_temperature_time(old_preset_mode) + + self.save_preset_mode() + 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 ( + self._attr_preset_mode not in HIDDEN_PRESETS + and old_preset_mode not in HIDDEN_PRESETS + ): + self._last_temperature_mesure = ( + self._last_ext_temperature_mesure + ) = datetime.now(tz=self._current_tz) + + def find_preset_temp(self, preset_mode): + """Find the right temperature of a preset considering the presence if configured""" + if preset_mode == PRESET_SECURITY: + return ( + self._target_temp + ) # in security just keep the current target temperature, the thermostat should be off + if preset_mode == PRESET_POWER: + return self._power_temp + else: + # 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 [ + STATE_ON, + STATE_HOME, + ]: + return self._presets[preset_mode] + else: + return self._presets_away[self.get_preset_away_name(preset_mode)] + + def get_preset_away_name(self, preset_mode): + """Get the preset name in away mode (when presence is off)""" + return preset_mode + PRESET_AWAY_SUFFIX + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) + if fan_mode is None or not self.is_over_climate: + return + + for under in self._underlyings: + await under.set_fan_mode(fan_mode) + self._fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if humidity is None or not self.is_over_climate: + return + for under in self._underlyings: + await under.set_humidity(humidity) + self._humidity = humidity + self.async_write_ha_state() + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if swing_mode is None or not self.is_over_climate: + return + for under in self._underlyings: + await under.set_swing_mode(swing_mode) + self._swing_mode = swing_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.info("%s - Set target temp: %s", self, temperature) + if temperature is None: + return + 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): + """Set the target temperature and the target temperature of underlying climate if any""" + self._target_temp = temperature + if not self.is_over_climate: + return + + for under in self._underlyings: + await under.set_temperature( + temperature, self._attr_max_temp, self._attr_min_temp + ) + + def get_state_date_or_now(self, state: State): + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_changed.astimezone(self._current_tz) + if state.last_changed is not None + else datetime.now(tz=self._current_tz) + ) + + def get_last_updated_date_or_now(self, state: State): + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_updated.astimezone(self._current_tz) + if state.last_updated is not None + else datetime.now(tz=self._current_tz) + ) + + @callback + async def entry_update_listener( + self, _, config_entry: ConfigEntry # hass: HomeAssistant, + ) -> None: + """Called when the entry have changed in ConfigFlow""" + _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data) + + @callback + async def _async_temperature_changed(self, event: Event): + """Handle temperature of the temperature sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - Temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + await self._async_update_temp(new_state) + self.recalculate() + await self.async_control_heating(force=False) + + async def _async_ext_temperature_changed(self, event: Event): + """Handle external temperature opf the sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - external Temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + await self._async_update_ext_temp(new_state) + self.recalculate() + await self.async_control_heating(force=False) + + @callback + async def _async_windows_changed(self, event): + """Handle window changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + _LOGGER.info( + "%s - Window changed. Event.new_state is %s, _hvac_mode=%s, _saved_hvac_mode=%s", + self, + new_state, + self._hvac_mode, + self._saved_hvac_mode, + ) + + # Check delay condition + async def try_window_condition(_): + try: + long_enough = condition.state( + self.hass, + self._window_sensor_entity_id, + new_state.state, + timedelta(seconds=self._window_delay_sec), + ) + except ConditionError: + long_enough = False + + if not long_enough: + _LOGGER.debug( + "Window delay condition is not satisfied. Ignore window event" + ) + self._window_state = old_state.state + return + + _LOGGER.debug("%s - Window delay condition is satisfied", self) + # if not self._saved_hvac_mode: + # self._saved_hvac_mode = self._hvac_mode + + if self._window_state == new_state.state: + _LOGGER.debug("%s - no change in window state. Forget the event") + return + + self._window_state = new_state.state + if self._window_state == STATE_OFF: + _LOGGER.info( + "%s - Window is closed. Restoring hvac_mode '%s'", + self, + self._saved_hvac_mode, + ) + await self.restore_hvac_mode(True) + elif self._window_state == STATE_ON: + _LOGGER.info( + "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF + ) + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + self.update_custom_attributes() + + if new_state is None or old_state is None or new_state.state == old_state.state: + return try_window_condition + + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + self._window_call_cancel = async_call_later( + self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition + ) + # For testing purpose we need to access the inner function + return try_window_condition + + @callback + async def _async_motion_changed(self, event): + """Handle motion changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._attr_preset_mode, + PRESET_ACTIVITY, + ) + + if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): + return + + # 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=delay), + ) + except ConditionError: + long_enough = False + + if not long_enough: + _LOGGER.debug( + "Motion delay condition is not satisfied. Ignore motion event" + ) + 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 + + 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 + ) + 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 + + # 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) + # 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) + + @callback + def _async_switch_changed(self, event): + """Handle heater switch state changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None: + return + if old_state is None: + self.hass.create_task(self._check_switch_initial_state()) + self.async_write_ha_state() + + @callback + async def _async_climate_changed(self, event): + """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: + return + + changes = False + new_hvac_mode = new_state.state + + old_state = event.data.get("old_state") + old_hvac_action = ( + old_state.attributes.get("hvac_action") + if old_state and old_state.attributes + else None + ) + new_hvac_action = ( + new_state.attributes.get("hvac_action") + if new_state and new_state.attributes + else None + ) + + 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: + # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") + # new_hvac_mode = HVACMode.OFF + + _LOGGER.info( + "%s - Underlying climate changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", + self, + new_hvac_mode, + self._hvac_mode, + new_hvac_action, + old_hvac_action, + ) + + _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 + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, + ] + if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: + self._underlying_climate_start_hvac_action_date = ( + self.get_last_updated_date_or_now(new_state) + ) + _LOGGER.info( + "%s - underlying just switch ON. Set power and energy start date %s", + self, + self._underlying_climate_start_hvac_action_date.isoformat(), + ) + changes = True + + if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: + stop_power_date = self.get_last_updated_date_or_now(new_state) + if self._underlying_climate_start_hvac_action_date: + delta = ( + stop_power_date - self._underlying_climate_start_hvac_action_date + ) + self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 + + # increment energy at the end of the cycle + self.incremente_energy() + + self._underlying_climate_start_hvac_action_date = None + + _LOGGER.info( + "%s - underlying just switch OFF at %s. delta_h=%.3f h", + self, + stop_power_date.isoformat(), + self._underlying_climate_delta_t, + ) + 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 in underlying have change to %s", + self, + new_target_temp, + ) + await self.async_set_temperature(temperature=new_target_temp) + changes = True + + await end_climate_changed(changes) + + @callback + async def _async_update_temp(self, state: State): + """Update thermostat with latest state from sensor.""" + try: + cur_temp = float(state.state) + if math.isnan(cur_temp) or math.isinf(cur_temp): + raise ValueError(f"Sensor has illegal state {state.state}") + self._cur_temp = cur_temp + + self._last_temperature_mesure = self.get_state_date_or_now(state) + + _LOGGER.debug( + "%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s", + self, + self._last_temperature_mesure, + state.last_changed.astimezone(self._current_tz), + ) + + # try to restart if we were in security mode + if self._security_state: + await self.check_security() + + # check window_auto + await self._async_manage_window_auto() + + except ValueError as ex: + _LOGGER.error("Unable to update temperature from sensor: %s", ex) + + @callback + async def _async_update_ext_temp(self, state: State): + """Update thermostat with latest state from sensor.""" + try: + cur_ext_temp = float(state.state) + if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp): + raise ValueError(f"Sensor has illegal state {state.state}") + self._cur_ext_temp = cur_ext_temp + self._last_ext_temperature_mesure = self.get_state_date_or_now(state) + + _LOGGER.debug( + "%s - After setting _last_ext_temperature_mesure %s , state.last_changed.replace=%s", + self, + self._last_ext_temperature_mesure, + state.last_changed.astimezone(self._current_tz), + ) + + # try to restart if we were in security mode + if self._security_state: + await self.check_security() + except ValueError as ex: + _LOGGER.error("Unable to update external temperature from sensor: %s", ex) + + @callback + async def _async_power_changed(self, event): + """Handle power changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power = float(new_state.state) + if math.isnan(current_power) or math.isinf(current_power): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power = current_power + + if self._attr_preset_mode == PRESET_POWER: + await self.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_max_power_changed(self, event): + """Handle power max changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power_max = float(new_state.state) + if math.isnan(current_power_max) or math.isinf(current_power_max): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power_max = current_power_max + if self._attr_preset_mode == PRESET_POWER: + await self.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_presence_changed(self, event): + """Handle presence changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Presence changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._attr_preset_mode, + PRESET_ACTIVITY, + ) + if new_state is None: + return + + await self._async_update_presence(new_state.state) + await self.async_control_heating(force=True) + + async def _async_update_presence(self, new_state): + _LOGGER.debug("%s - Updating presence. New state is %s", self, new_state) + self._presence_state = new_state + if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: + _LOGGER.info( + "%s - Ignoring presence change cause in Power or Security preset or presence not configured", + self, + ) + return + if new_state is None or new_state not in ( + STATE_OFF, + STATE_ON, + STATE_HOME, + STATE_NOT_HOME, + ): + return + if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: + return + + # 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", + self, + new_temp, + ) + await self._async_internal_set_temperature(new_temp) + self.recalculate() + + async def _async_update_motion_temp(self): + """Update the temperature considering the ACTIVITY preset and current motion state""" + _LOGGER.debug( + "%s - Calling _update_motion_temp preset_mode=%s, motion_state=%s", + self, + self._attr_preset_mode, + self._motion_state, + ) + if ( + self._motion_sensor_entity_id is None + or self._attr_preset_mode != PRESET_ACTIVITY + ): + return + + await self._async_internal_set_temperature( + self._presets[ + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ] + ) + _LOGGER.debug( + "%s - regarding motion, target_temp have been set to %.2f", + self, + self._target_temp, + ) + + async def _async_underlying_entity_turn_off(self): + """Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off""" + + for under in self._underlyings: + await under.turn_off() + + async def _async_manage_window_auto(self): + """The management of the window auto feature""" + + async def dearm_window_auto(_): + """Callback that will be called after end of WINDOW_AUTO_MAX_DURATION""" + _LOGGER.info("Unset window auto because MAX_DURATION is exceeded") + await deactivate_window_auto(auto=True) + + async def deactivate_window_auto(auto=False): + """Deactivation of the Window auto state""" + _LOGGER.warning( + "%s - End auto detection of open window slope=%.3f", self, slope + ) + # Send an event + cause = "max duration expiration" if auto else "end of slope alert" + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "end", "cause": cause, "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = False + await self.restore_hvac_mode(True) + + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + + if not self._window_auto_algo: + return + + slope = self._window_auto_algo.add_temp_measurement( + temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure + ) + _LOGGER.debug( + "%s - Window auto is on, check the alert. last slope is %.3f", + self, + slope if slope is not None else 0.0, + ) + if ( + self._window_auto_algo.is_window_open_detected() + and self._window_auto_state is False + and self.hvac_mode != HVACMode.OFF + ): + if ( + not self.proportional_algorithm + or self.proportional_algorithm.on_percent <= 0.0 + ): + _LOGGER.info( + "%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event", + self, + slope, + ) + return dearm_window_auto + + _LOGGER.warning( + "%s - Start auto detection of open window slope=%.3f", self, slope + ) + + # Send an event + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = True + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + + # Arm the end trigger + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + self._window_call_cancel = async_call_later( + self.hass, + timedelta(minutes=self._window_auto_max_duration), + dearm_window_auto, + ) + + elif ( + self._window_auto_algo.is_window_close_detected() + and self._window_auto_state is True + ): + await deactivate_window_auto(False) + + # For testing purpose we need to return the inner function + return dearm_window_auto + + def save_preset_mode(self): + """Save the current preset mode to be restored later + We never save a hidden preset mode + """ + if ( + self._attr_preset_mode not in HIDDEN_PRESETS + and self._attr_preset_mode is not None + ): + self._saved_preset_mode = self._attr_preset_mode + + async def restore_preset_mode(self): + """Restore a previous preset mode + We never restore a hidden preset mode. Normally that is not possible + """ + if ( + self._saved_preset_mode not in HIDDEN_PRESETS + and self._saved_preset_mode is not None + ): + await self._async_set_preset_mode_internal(self._saved_preset_mode) + + def save_hvac_mode(self): + """Save the current hvac-mode to be restored later""" + self._saved_hvac_mode = self._hvac_mode + _LOGGER.debug( + "%s - Saved hvac mode - saved_hvac_mode is %s, hvac_mode is %s", + self, + self._saved_hvac_mode, + self._hvac_mode, + ) + + 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", + self, + 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 + Turn the preset_mode of the heater to 'power' if power conditions are exceeded + """ + + if not self._pmax_on: + _LOGGER.debug( + "%s - power not configured. check_overpowering not available", self + ) + return False + + if ( + self._current_power is None + or self._device_power is None + or self._current_power_max is None + ): + _LOGGER.warning( + "%s - power not valued. check_overpowering not available", self + ) + return False + + _LOGGER.debug( + "%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f", + self, + self._current_power, + self._current_power_max, + self._device_power, + ) + + ret = self._current_power + self._device_power >= self._current_power_max + if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF: + _LOGGER.warning( + "%s - overpowering is detected. Heater preset will be set to 'power'", + self, + ) + if self.is_over_climate: + self.save_hvac_mode() + self.save_preset_mode() + await self._async_underlying_entity_turn_off() + await self._async_set_preset_mode_internal(PRESET_POWER) + self.send_event( + EventType.POWER_EVENT, + { + "type": "start", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_power_max, + }, + ) + + # Check if we need to remove the POWER preset + if ( + self._overpowering_state + and not ret + and self._attr_preset_mode == PRESET_POWER + ): + _LOGGER.warning( + "%s - end of overpowering is detected. Heater preset will be restored to '%s'", + self, + self._saved_preset_mode, + ) + if self.is_over_climate: + await self.restore_hvac_mode(False) + await self.restore_preset_mode() + self.send_event( + EventType.POWER_EVENT, + { + "type": "end", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_power_max, + }, + ) + + self._overpowering_state = ret + return self._overpowering_state + + async def check_security(self) -> bool: + """Check if last temperature date is too long""" + now = datetime.now(self._current_tz) + delta_temp = ( + now - self._last_temperature_mesure.replace(tzinfo=self._current_tz) + ).total_seconds() / 60.0 + delta_ext_temp = ( + now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz) + ).total_seconds() / 60.0 + + mode_cond = self._hvac_mode != HVACMode.OFF + + temp_cond: bool = ( + delta_temp > self._security_delay_min + or delta_ext_temp > self._security_delay_min + ) + climate_cond: bool = self.is_over_climate and self.hvac_action not in [ + HVACAction.COOLING, + HVACAction.IDLE, + ] + switch_cond: bool = ( + not self.is_over_climate + and self._prop_algorithm is not None + and self._prop_algorithm.calculated_on_percent + >= self._security_min_on_percent + ) + + _LOGGER.debug( + "%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s", + self, + delta_temp, + delta_ext_temp, + mode_cond, + temp_cond, + climate_cond, + switch_cond, + ) + + # 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 + shouldSwitchBeInSecurity = temp_cond and switch_cond + shouldBeInSecurity = shouldClimateBeInSecurity or shouldSwitchBeInSecurity + + 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 + ) + + # Logging and event + if shouldStartSecurity: + if shouldClimateBeInSecurity: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Set it into security mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self.hvac_action, + ) + elif shouldSwitchBeInSecurity: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent (%.2f) is over defined value (%.2f). Set it into security mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self._prop_algorithm.on_percent, + self._security_min_on_percent, + ) + + self.send_event( + EventType.TEMPERATURE_EVENT, + { + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + if shouldStartSecurity: + self._security_state = True + self.save_hvac_mode() + self.save_preset_mode() + await self._async_set_preset_mode_internal(PRESET_SECURITY) + # Turn off the underlying climate or heater if security default on_percent is 0 + if self.is_over_climate or self._security_default_on_percent <= 0.0: + await self.async_set_hvac_mode(HVACMode.OFF, False) + if self._prop_algorithm: + self._prop_algorithm.set_security(self._security_default_on_percent) + + self.send_event( + EventType.SECURITY_EVENT, + { + "type": "start", + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + if shouldStopSecurity: + _LOGGER.warning( + "%s - End of security mode. restoring hvac_mode to %s and preset_mode to %s", + self, + self._saved_hvac_mode, + self._saved_preset_mode, + ) + self._security_state = False + # Restore hvac_mode if previously saved + if self.is_over_climate or self._security_default_on_percent <= 0.0: + await self.restore_hvac_mode(False) + await self.restore_preset_mode() + if self._prop_algorithm: + self._prop_algorithm.unset_security() + self.send_event( + EventType.SECURITY_EVENT, + { + "type": "end", + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + return shouldBeInSecurity + + async def async_control_heating(self, force=False, _=None): + """The main function used to run the calculation at each cycle""" + + _LOGGER.debug( + "%s - Checking new cycle. hvac_mode=%s, security_state=%s, preset_mode=%s", + self, + self._hvac_mode, + self._security_state, + self._attr_preset_mode, + ) + + # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it + for under in self._underlyings: + if not under.is_initialized: + _LOGGER.info( + "%s - Underlying %s is not initialized. Try to initialize it", + self, + under.entity_id, + ) + try: + under.startup() + except UnknownEntity: + # still not found, we an stop here + 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 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 True + + # Stop here if we are off + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", self) + # A security to force stop heater if still active + if self.is_device_active: + await self._async_underlying_entity_turn_off() + return True + + for under in self._underlyings: + await under.start_cycle( + self._hvac_mode, + self._prop_algorithm.on_time_sec if self._prop_algorithm else None, + self._prop_algorithm.off_time_sec if self._prop_algorithm else None, + self._prop_algorithm.on_percent if self._prop_algorithm else None, + force, + ) + + 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 + """ + _LOGGER.debug("%s - recalculate all", self) + 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() + + def incremente_energy(self): + """increment the energy counter if device is active""" + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if self.is_over_climate and self._underlying_climate_delta_t is not None: + added_energy = self._device_power * self._underlying_climate_delta_t + + if not self.is_over_climate and self.mean_cycle_power is not None: + added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + + self._total_energy += added_energy + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) + + def update_custom_attributes(self): + """Update the custom extra attributes for the entity""" + + self._attr_extra_state_attributes: dict(str, str) = { + "hvac_action": self.hvac_action, + "hvac_mode": self.hvac_mode, + "preset_mode": self.preset_mode, + "type": self._thermostat_type, + "eco_temp": self._presets[PRESET_ECO], + "boost_temp": self._presets[PRESET_BOOST], + "comfort_temp": self._presets[PRESET_COMFORT], + "eco_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_ECO) + ), + "boost_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_BOOST) + ), + "comfort_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_COMFORT) + ), + "power_temp": self._power_temp, + "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, + "saved_target_temp": self._saved_target_temp, + "saved_hvac_mode": self._saved_hvac_mode, + "window_state": self._window_state, + "motion_state": self._motion_state, + "overpowering_state": self._overpowering_state, + "presence_state": self._presence_state, + "window_auto_state": self._window_auto_state, + "security_delay_min": self._security_delay_min, + "security_min_on_percent": self._security_min_on_percent, + "security_default_on_percent": self._security_default_on_percent, + "last_temperature_datetime": self._last_temperature_mesure.astimezone( + self._current_tz + ).isoformat(), + "last_ext_temperature_datetime": self._last_ext_temperature_mesure.astimezone( + self._current_tz + ).isoformat(), + "security_state": self._security_state, + "minimal_activation_delay_sec": self._minimal_activation_delay, + "device_power": self._device_power, + ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power, + ATTR_TOTAL_ENERGY: self.total_energy, + "last_update_datetime": datetime.now() + .astimezone(self._current_tz) + .isoformat(), + "timezone": str(self._current_tz), + "window_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, + } + + @callback + def async_registry_entry_updated(self): + """update the entity if the config entry have been updated + Note: this don't work either + """ + _LOGGER.info("%s - The config entry have been updated") + + async def service_set_presence(self, presence): + """Called by a service call: + service: versatile_thermostat.set_presence + data: + presence: "off" + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence) + await self._async_update_presence(presence) + await self.async_control_heating(force=True) + + async def service_set_preset_temperature( + self, preset, temperature=None, temperature_away=None + ): + """Called by a service call: + service: versatile_thermostat.set_preset_temperature + data: + preset: boost + temperature: 17.8 + temperature_away: 15 + target: + entity_id: climate.thermostat_2 + """ + _LOGGER.info( + "%s - Calling service_set_preset_temperature, preset: %s, temperature: %s, temperature_away: %s", + self, + preset, + temperature, + temperature_away, + ) + if preset in self._presets: + if temperature is not None: + self._presets[preset] = temperature + if self._presence_on and temperature_away is not None: + self._presets_away[self.get_preset_away_name(preset)] = temperature_away + else: + _LOGGER.warning( + "%s - No preset %s configured for this thermostat. Ignoring set_preset_temperature call", + self, + preset, + ) + + # If the changed preset is active, change the current temperature + # 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): + """Called by a service call: + service: versatile_thermostat.set_security + data: + delay_min: 15 + min_on_percent: 0.5 + default_on_percent: 0.2 + target: + entity_id: climate.thermostat_2 + """ + _LOGGER.info( + "%s - Calling service_set_security, delay_min: %s, min_on_percent: %s, default_on_percent: %s", + self, + delay_min, + min_on_percent, + default_on_percent, + ) + if delay_min: + self._security_delay_min = delay_min + if min_on_percent: + self._security_min_on_percent = min_on_percent + if default_on_percent: + self._security_default_on_percent = default_on_percent + + if self._prop_algorithm and self._security_state: + self._prop_algorithm.set_security(self._security_default_on_percent) + + await self.async_control_heating() + self.update_custom_attributes() + + def send_event(self, event_type: EventType, data: dict): + """Send an event""" + _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data) + data["entity_id"] = self.entity_id + data["name"] = self.name + data["state_attributes"] = self.state_attributes + self._hass.bus.fire(event_type.value, data) diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index e623d4a4..8652a231 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1,155 +1,40 @@ # pylint: disable=line-too-long -# pylint: disable=too-many-lines # pylint: disable=invalid-name """ Implements the VersatileThermostat climate component """ -import math import logging -from datetime import timedelta, datetime import voluptuous as vol -from homeassistant.util import dt as dt_util -from homeassistant.core import ( - HomeAssistant, - callback, - CoreState, - Event, - State, -) +from homeassistant.core import HomeAssistant -from homeassistant.components.climate import ClimateEntity -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers.event import ( - async_track_state_change_event, - async_call_later, - async_track_time_interval, -) - -from homeassistant.exceptions import ConditionError -from homeassistant.helpers import ( - condition, - entity_platform, -) # , config_validation as cv +from homeassistant.helpers import entity_platform -from homeassistant.components.climate import ( - ATTR_PRESET_MODE, - # ATTR_FAN_MODE, - HVACMode, - HVACAction, - # HVAC_MODE_COOL, - # HVAC_MODE_HEAT, - # HVAC_MODE_OFF, - PRESET_ACTIVITY, - # PRESET_AWAY, - PRESET_BOOST, - PRESET_COMFORT, - PRESET_ECO, - # PRESET_HOME, - PRESET_NONE, - # PRESET_SLEEP, - ClimateEntityFeature, -) - -# from homeassistant.components.climate import ( -# CURRENT_HVAC_HEAT, -# HVACAction.IDLE, -# HVACAction.OFF, -# HVACAction.COOLING, -# ) - -from homeassistant.const import ( - # UnitOfTemperature, - ATTR_TEMPERATURE, - # TEMP_FAHRENHEIT, - CONF_NAME, - # CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - STATE_OFF, - STATE_ON, - EVENT_HOMEASSISTANT_START, - STATE_HOME, - STATE_NOT_HOME, -) +from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME from .const import ( DOMAIN, PLATFORMS, - DEVICE_MANUFACTURER, - CONF_HEATER, - CONF_HEATER_2, - CONF_HEATER_3, - CONF_HEATER_4, - CONF_POWER_SENSOR, - CONF_TEMP_SENSOR, - CONF_EXTERNAL_TEMP_SENSOR, - CONF_MAX_POWER_SENSOR, - CONF_WINDOW_SENSOR, - CONF_WINDOW_DELAY, - CONF_WINDOW_AUTO_CLOSE_THRESHOLD, - CONF_WINDOW_AUTO_OPEN_THRESHOLD, - CONF_WINDOW_AUTO_MAX_DURATION, - CONF_MOTION_SENSOR, - CONF_MOTION_DELAY, - CONF_MOTION_OFF_DELAY, - CONF_MOTION_PRESET, - CONF_NO_MOTION_PRESET, - CONF_DEVICE_POWER, - CONF_PRESETS, - CONF_PRESETS_AWAY, CONF_PRESETS_WITH_AC, - CONF_PRESETS_AWAY_WITH_AC, - CONF_CYCLE_MIN, - CONF_PROP_FUNCTION, - CONF_TPI_COEF_INT, - CONF_TPI_COEF_EXT, - CONF_PRESENCE_SENSOR, - CONF_PRESET_POWER, - SUPPORT_FLAGS, - PRESET_POWER, - PRESET_SECURITY, - PROPORTIONAL_FUNCTION_TPI, SERVICE_SET_PRESENCE, SERVICE_SET_PRESET_TEMPERATURE, SERVICE_SET_SECURITY, #PR - Adding Window ByPass SERVICE_SET_WINDOW_BYPASS, - PRESET_AWAY_SUFFIX, - CONF_SECURITY_DELAY_MIN, - CONF_SECURITY_MIN_ON_PERCENT, - CONF_SECURITY_DEFAULT_ON_PERCENT, - DEFAULT_SECURITY_MIN_ON_PERCENT, - DEFAULT_SECURITY_DEFAULT_ON_PERCENT, - CONF_MINIMAL_ACTIVATION_DELAY, - CONF_TEMP_MAX, - CONF_TEMP_MIN, - HIDDEN_PRESETS, CONF_THERMOSTAT_TYPE, - # CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, - CONF_AC_MODE, - UnknownEntity, - EventType, - ATTR_MEAN_POWER_CYCLE, - ATTR_TOTAL_ENERGY, - PRESET_AC_SUFFIX, + CONF_THERMOSTAT_VALVE, ) -from .underlyings import UnderlyingSwitch, UnderlyingClimate, UnderlyingEntity - -from .prop_algorithm import PropAlgorithm -from .open_window_algorithm import WindowOpenDetectionAlgorithm +from .thermostat_switch import ThermostatOverSwitch +from .thermostat_climate import ThermostatOverClimate +from .thermostat_valve import ThermostatOverValve _LOGGER = logging.getLogger(__name__) @@ -170,8 +55,15 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) - entity = VersatileThermostat(hass, unique_id, name, entry.data) + # Instantiate the right base class + if vt_type == CONF_THERMOSTAT_SWITCH: + entity = ThermostatOverSwitch(hass, unique_id, name, entry.data) + elif vt_type == CONF_THERMOSTAT_CLIMATE: + entity = ThermostatOverClimate(hass, unique_id, name, entry.data) + elif vt_type == CONF_THERMOSTAT_VALVE: + entity = ThermostatOverValve(hass, unique_id, name, entry.data) async_add_entities([entity], True) # No more needed @@ -208,7 +100,6 @@ async def async_setup_entry( }, "service_set_security", ) - #PR - Adding Window ByPass platform.async_register_entity_service( SERVICE_SET_WINDOW_BYPASS, @@ -218,2532 +109,3 @@ async def async_setup_entry( }, "service_set_window_bypass_state", ) - -class VersatileThermostat(ClimateEntity, RestoreEntity): - """Representation of a Versatile Thermostat device.""" - - # The list of VersatileThermostat entities - # No more needed - # _registry: dict[str, object] = {} - _hass: HomeAssistant - _last_temperature_mesure: datetime - _last_ext_temperature_mesure: datetime - _total_energy: float - _overpowering_state: bool - _window_state: bool - _motion_state: bool - _presence_state: bool - _window_auto_state: bool - #PR - Adding Window ByPass - _window_bypass_state: bool - _underlyings: list[UnderlyingEntity] - _last_change_time: datetime - - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: - """Initialize the thermostat.""" - - super().__init__() - - self._hass = hass - self._attr_extra_state_attributes = {} - - self._unique_id = unique_id - self._name = name - self._prop_algorithm = None - self._async_cancel_cycle = None - self._hvac_mode = None - self._target_temp = None - self._saved_target_temp = None - self._saved_preset_mode = None - self._fan_mode = None - self._humidity = None - self._swing_mode = None - self._current_power = None - self._current_power_max = None - self._window_state = None - self._motion_state = None - self._saved_hvac_mode = None - self._window_call_cancel = None - self._motion_call_cancel = None - self._cur_ext_temp = None - self._cur_temp = None - self._ac_mode = None - self._last_ext_temperature_mesure = None - self._last_temperature_mesure = None - self._cur_ext_temp = None - self._presence_state = None - self._overpowering_state = None - self._should_relaunch_control_heating = None - - self._security_delay_min = None - self._security_min_on_percent = None - self._security_default_on_percent = None - self._security_state = None - - self._thermostat_type = None - self._is_over_climate = False - - self._attr_translation_key = "versatile_thermostat" - - self._total_energy = None - - # because energy of climate is calculated in the thermostat we have to keep that here and not in underlying entity - self._underlying_climate_start_hvac_action_date = None - self._underlying_climate_delta_t = 0 - - self._window_sensor_entity_id = None - self._window_delay_sec = None - self._window_auto_open_threshold = 0 - self._window_auto_close_threshold = 0 - self._window_auto_max_duration = 0 - self._window_auto_state = False - self._window_auto_on = False - self._window_auto_algo = None - - # PR - Adding Window ByPass - self._window_bypass_state = False - - 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) - - def post_init(self, entry_infos): - """Finish the initialization of the thermostast""" - - _LOGGER.info( - "%s - Updating VersatileThermostat with infos %s", - self, - entry_infos, - ) - - 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() - for key, value in items: - _LOGGER.debug("looking for key=%s, value=%s", key, value) - if value in entry_infos: - presets[key] = entry_infos.get(value) - else: - _LOGGER.debug("value %s not found in Entry", value) - - presets_away = {} - items = ( - CONF_PRESETS_AWAY_WITH_AC.items() - if self._ac_mode - else CONF_PRESETS_AWAY.items() - ) - for key, value in items: - _LOGGER.debug("looking for key=%s, value=%s", key, value) - if value in entry_infos: - presets_away[key] = entry_infos.get(value) - else: - _LOGGER.debug("value %s not found in Entry", value) - - if self._window_call_cancel is not None: - self._window_call_cancel() - self._window_call_cancel = None - if self._motion_call_cancel is not None: - self._motion_call_cancel() - self._motion_call_cancel = None - - self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) - - # Initialize underlying entities - self._underlyings = [] - 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, - ]: - if entry_infos.get(climate): - self._underlyings.append( - UnderlyingClimate( - hass=self._hass, - thermostat=self, - climate_entity_id=entry_infos.get(climate), - ) - ) - else: - lst_switches = [entry_infos.get(CONF_HEATER)] - if entry_infos.get(CONF_HEATER_2): - lst_switches.append(entry_infos.get(CONF_HEATER_2)) - if entry_infos.get(CONF_HEATER_3): - lst_switches.append(entry_infos.get(CONF_HEATER_3)) - if entry_infos.get(CONF_HEATER_4): - lst_switches.append(entry_infos.get(CONF_HEATER_4)) - - delta_cycle = self._cycle_min * 60 / len(lst_switches) - for idx, switch in enumerate(lst_switches): - self._underlyings.append( - UnderlyingSwitch( - hass=self._hass, - thermostat=self, - switch_entity_id=switch, - initial_delay_sec=idx * delta_cycle, - ) - ) - - self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) - self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) - self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) - self._attr_max_temp = entry_infos.get(CONF_TEMP_MAX) - self._attr_min_temp = entry_infos.get(CONF_TEMP_MIN) - self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) - self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) - self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) - self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY) - - self._window_auto_open_threshold = entry_infos.get( - CONF_WINDOW_AUTO_OPEN_THRESHOLD - ) - self._window_auto_close_threshold = entry_infos.get( - CONF_WINDOW_AUTO_CLOSE_THRESHOLD - ) - self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION) - self._window_auto_on = ( - self._window_auto_open_threshold is not None - and self._window_auto_open_threshold > 0.0 - and self._window_auto_close_threshold is not None - and self._window_auto_max_duration is not None - and self._window_auto_max_duration > 0 - ) - self._window_auto_state = False - self._window_auto_algo = WindowOpenDetectionAlgorithm( - alert_threshold=self._window_auto_open_threshold, - end_alert_threshold=self._window_auto_close_threshold, - ) - - self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR) - self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY) - self._motion_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 = ( - self._motion_sensor_entity_id is not None - and self._motion_preset is not None - and self._no_motion_preset is not None - ) - - self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) - self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) - self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR) - self._power_temp = entry_infos.get(CONF_PRESET_POWER) - - self._presence_on = self._presence_sensor_entity_id is not None - - if self._ac_mode: - self._hvac_list = [HVACMode.COOL, HVACMode.OFF] - else: - self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] - - self._unit = self._hass.config.units.temperature_unit - # Will be restored if possible - self._hvac_mode = None # HVAC_MODE_OFF - self._saved_hvac_mode = self._hvac_mode - - self._support_flags = SUPPORT_FLAGS - - self._presets = presets - self._presets_away = presets_away - - _LOGGER.debug( - "%s - presets are set to: %s, away: %s", - self, - self._presets, - self._presets_away, - ) - # Will be restored if possible - self._attr_preset_mode = PRESET_NONE - self._saved_preset_mode = PRESET_NONE - - # Power management - self._device_power = entry_infos.get(CONF_DEVICE_POWER) - self._pmax_on = False - self._current_power = None - self._current_power_max = None - if ( - self._max_power_sensor_entity_id - and self._power_sensor_entity_id - and self._device_power - ): - self._pmax_on = True - else: - _LOGGER.info("%s - Power management is not fully configured", self) - - # will be restored if possible - self._target_temp = None - self._saved_target_temp = PRESET_NONE - self._humidity = None - self._fan_mode = None - self._swing_mode = None - self._cur_temp = None - self._cur_ext_temp = None - - # Fix parameters for TPI - if ( - self._proportional_function == PROPORTIONAL_FUNCTION_TPI - and self._ext_temp_sensor_entity_id is None - ): - _LOGGER.warning( - "Using TPI function but not external temperature sensor is set. Removing the delta temp ext factor. Thermostat will not be fully operationnal" # pylint: disable=line-too-long - ) - self._tpi_coef_ext = 0 - - self._security_delay_min = entry_infos.get(CONF_SECURITY_DELAY_MIN) - self._security_min_on_percent = ( - entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) - if entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) is not None - else DEFAULT_SECURITY_MIN_ON_PERCENT - ) - self._security_default_on_percent = ( - entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) - if entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) is not None - else DEFAULT_SECURITY_DEFAULT_ON_PERCENT - ) - self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY) - self._last_temperature_mesure = datetime.now(tz=self._current_tz) - self._last_ext_temperature_mesure = datetime.now(tz=self._current_tz) - self._security_state = False - self._saved_hvac_mode = None - - # Initiate the ProportionalAlgorithm - if self._prop_algorithm is not None: - del self._prop_algorithm - if not self._is_over_climate: - self._prop_algorithm = PropAlgorithm( - self._proportional_function, - self._tpi_coef_int, - self._tpi_coef_ext, - self._cycle_min, - self._minimal_activation_delay, - ) - self._should_relaunch_control_heating = False - - # Memory synthesis state - self._motion_state = None - self._window_state = None - self._overpowering_state = None - self._presence_state = None - - # Calculate all possible presets - self._attr_preset_modes = [PRESET_NONE] - if len(presets): - self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE - - for key, val in CONF_PRESETS.items(): - if val != 0.0: - self._attr_preset_modes.append(key) - - _LOGGER.debug( - "After adding presets, preset_modes to %s", self._attr_preset_modes - ) - else: - _LOGGER.debug("No preset_modes") - - if self._motion_on: - self._attr_preset_modes.append(PRESET_ACTIVITY) - - self._total_energy = 0 - - _LOGGER.debug( - "%s - Creation of a new VersatileThermostat entity: unique_id=%s", - self, - self.unique_id, - ) - - async def async_added_to_hass(self): - """Run when entity about to be added.""" - _LOGGER.debug("Calling async_added_to_hass") - - await super().async_added_to_hass() - - # Add listener to all underlying entities - if self.is_over_climate: - for climate in self._underlyings: - self.async_on_remove( - async_track_state_change_event( - self.hass, [climate.entity_id], self._async_climate_changed - ) - ) - else: - for switch in self._underlyings: - self.async_on_remove( - async_track_state_change_event( - self.hass, [switch.entity_id], self._async_switch_changed - ) - ) - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._temp_sensor_entity_id], - self._async_temperature_changed, - ) - ) - - if self._ext_temp_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._ext_temp_sensor_entity_id], - self._async_ext_temperature_changed, - ) - ) - - if self._window_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._window_sensor_entity_id], - self._async_windows_changed, - ) - ) - if self._motion_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._motion_sensor_entity_id], - self._async_motion_changed, - ) - ) - - if self._power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._power_sensor_entity_id], - self._async_power_changed, - ) - ) - - if self._max_power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._max_power_sensor_entity_id], - self._async_max_power_changed, - ) - ) - - if self._presence_on: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._presence_sensor_entity_id], - self._async_presence_changed, - ) - ) - - self.async_on_remove(self.remove_thermostat) - - try: - await self.async_startup() - except UnknownEntity: - # Ingore this error which is possible if underlying climate is not found temporary - pass - - def remove_thermostat(self): - """Called when the thermostat will be removed""" - _LOGGER.info("%s - Removing thermostat", self) - for under in self._underlyings: - under.remove_entity() - - async def async_startup(self): - """Triggered on startup, used to get old state and set internal states accordingly""" - _LOGGER.debug("%s - Calling async_startup", self) - - @callback - async def _async_startup_internal(*_): - _LOGGER.debug("%s - Calling async_startup_internal", self) - need_write_state = False - - # Initialize all UnderlyingEntities - for under in self._underlyings: - 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 ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - _LOGGER.debug( - "%s - temperature sensor have been retrieved: %.1f", - self, - float(temperature_state.state), - ) - await self._async_update_temp(temperature_state) - need_write_state = True - - if self._ext_temp_sensor_entity_id: - ext_temperature_state = self.hass.states.get( - self._ext_temp_sensor_entity_id - ) - if ext_temperature_state and ext_temperature_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - _LOGGER.debug( - "%s - external temperature sensor have been retrieved: %.1f", - self, - float(ext_temperature_state.state), - ) - await self._async_update_ext_temp(ext_temperature_state) - else: - _LOGGER.debug( - "%s - external temperature sensor have NOT been retrieved cause unknown or unavailable", - self, - ) - else: - _LOGGER.debug( - "%s - external temperature sensor have NOT been retrieved cause no external sensor", - self, - ) - - if self._pmax_on: - # try to acquire current power and power max - current_power_state = self.hass.states.get(self._power_sensor_entity_id) - if current_power_state and current_power_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power = float(current_power_state.state) - _LOGGER.debug( - "%s - Current power have been retrieved: %.3f", - self, - self._current_power, - ) - need_write_state = True - - # Try to acquire power max - current_power_max_state = self.hass.states.get( - self._max_power_sensor_entity_id - ) - if current_power_max_state and current_power_max_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power_max = float(current_power_max_state.state) - _LOGGER.debug( - "%s - Current power max have been retrieved: %.3f", - self, - self._current_power_max, - ) - need_write_state = True - - # try to acquire window entity state - if self._window_sensor_entity_id: - window_state = self.hass.states.get(self._window_sensor_entity_id) - if window_state and window_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._window_state = window_state.state - _LOGGER.debug( - "%s - Window state have been retrieved: %s", - self, - self._window_state, - ) - need_write_state = True - - # try to acquire motion entity state - if self._motion_sensor_entity_id: - motion_state = self.hass.states.get(self._motion_sensor_entity_id) - if motion_state and motion_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._motion_state = motion_state.state - _LOGGER.debug( - "%s - Motion state have been retrieved: %s", - self, - self._motion_state, - ) - # recalculate the right target_temp in activity mode - await self._async_update_motion_temp() - need_write_state = True - - if self._presence_on: - # try to acquire presence entity state - presence_state = self.hass.states.get(self._presence_sensor_entity_id) - if presence_state and presence_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - await self._async_update_presence(presence_state.state) - _LOGGER.debug( - "%s - Presence have been retrieved: %s", - self, - presence_state.state, - ) - need_write_state = True - - if need_write_state: - self.async_write_ha_state() - if self._prop_algorithm: - self._prop_algorithm.calculate( - self._target_temp, - self._cur_temp, - self._cur_ext_temp, - self._hvac_mode == HVACMode.COOL, - ) - - self.hass.create_task(self._check_switch_initial_state()) - # Start the control_heating - # starts a cycle if we are in over_climate type - if self._is_over_climate: - self.async_on_remove( - async_track_time_interval( - self.hass, - self._async_control_heating, - interval=timedelta(minutes=self._cycle_min), - ) - ) - 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: - await _async_startup_internal() - else: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_startup_internal - ) - - async def get_my_previous_state(self): - """Try to get my previou state""" - # Check If we have an old state - old_state = await self.async_get_last_state() - _LOGGER.debug( - "%s - Calling get_my_previous_state old_state is %s", self, old_state - ) - if old_state is not None: - # If we have no initial temperature, restore - if self._target_temp is None: - # If we have a previously saved temperature - if old_state.attributes.get(ATTR_TEMPERATURE) is None: - if self._ac_mode: - await self._async_internal_set_temperature(self.max_temp) - else: - await self._async_internal_set_temperature(self.min_temp) - _LOGGER.warning( - "%s - Undefined target temperature, falling back to %s", - self, - self._target_temp, - ) - else: - await self._async_internal_set_temperature( - float(old_state.attributes[ATTR_TEMPERATURE]) - ) - - old_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) - # Never restore a Power or Security preset - if ( - old_preset_mode in self._attr_preset_modes - and old_preset_mode not in HIDDEN_PRESETS - ): - self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) - self.save_preset_mode() - else: - self._attr_preset_mode = PRESET_NONE - - if not self._hvac_mode and old_state.state: - self._hvac_mode = old_state.state - else: - self._hvac_mode = HVACMode.OFF - - old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY) - if old_total_energy: - self._total_energy = old_total_energy - else: - # No previous state, try and restore defaults - if self._target_temp is None: - if self._ac_mode: - await self._async_internal_set_temperature(self.max_temp) - else: - await self._async_internal_set_temperature(self.min_temp) - _LOGGER.warning( - "No previously saved temperature, setting to %s", self._target_temp - ) - - self._saved_target_temp = self._target_temp - - # Set default state to off - if not self._hvac_mode: - self._hvac_mode = HVACMode.OFF - - self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) - self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) - - _LOGGER.info( - "%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s", - self, - self._target_temp, - self._attr_preset_mode, - self._hvac_mode, - ) - - def __str__(self): - return f"VersatileThermostat-{self.name}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._unique_id)}, - name=self._name, - manufacturer=DEVICE_MANUFACTURER, - model=DOMAIN, - ) - - @property - def unique_id(self): - return self._unique_id - - @property - def should_poll(self): - return False - - @property - def name(self): - return self._name - - @property - def hvac_modes(self): - """List of available operation modes.""" - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).hvac_modes - - 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. - - Requires ClimateEntityFeature.FAN_MODE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).fan_mode - - return None - - @property - def fan_modes(self) -> list[str] | None: - """Return the list of available fan modes. - - Requires ClimateEntityFeature.FAN_MODE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).fan_modes - - return [] - - @property - def swing_mode(self) -> str | None: - """Return the swing setting. - - Requires ClimateEntityFeature.SWING_MODE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).swing_mode - - return None - - @property - def swing_modes(self) -> list[str] | None: - """Return the list of available swing modes. - - Requires ClimateEntityFeature.SWING_MODE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).swing_modes - - return None - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).temperature_unit - - return self._unit - - @property - def hvac_mode(self) -> HVACMode | None: - """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. - # if self._is_over_climate: - # if one not OFF -> return it - # else OFF - # for under in self._underlyings: - # if (mode := under.hvac_mode) not in [HVACMode.OFF] - # return mode - # return HVACMode.OFF - - return self._hvac_mode - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current running hvac operation if supported. - - Need to be one of CURRENT_HVAC_*. - """ - if self._is_over_climate: - # if one not IDLE or OFF -> return it - # else if one IDLE -> IDLE - # else OFF - one_idle = False - for under in self._underlyings: - if (action := under.hvac_action) not in [ - HVACAction.IDLE, - HVACAction.OFF, - ]: - return action - if under.hvac_action == HVACAction.IDLE: - one_idle = True - if one_idle: - return HVACAction.IDLE - return HVACAction.OFF - - if self._hvac_mode == HVACMode.OFF: - 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 - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temp - - @property - def supported_features(self): - """Return the list of supported features.""" - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).supported_features | self._support_flags - - return self._support_flags - - @property - def _is_device_active(self): - """Returns true if one underlying is active""" - for under in self._underlyings: - if under.is_device_active: - return True - return False - - @property - def current_temperature(self): - """Return the sensor temperature.""" - return self._cur_temp - - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_step - - return None - - @property - def target_temperature_high(self) -> float | None: - """Return the highbound target temperature we try to reach. - - Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_high - - return None - - @property - def target_temperature_low(self) -> float | None: - """Return the lowbound target temperature we try to reach. - - Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_low - - return None - - @property - def is_aux_heat(self) -> bool | None: - """Return true if aux heater. - - Requires ClimateEntityFeature.AUX_HEAT. - """ - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).is_aux_heat - - return None - - @property - def mean_cycle_power(self) -> float | None: - """Returns the mean power consumption during the cycle""" - if not self._device_power or self._is_over_climate: - return None - - return float( - self.nb_underlying_entities - * self._device_power - * self._prop_algorithm.on_percent - ) - - @property - def total_energy(self) -> float | None: - """Returns the total energy calculated for this thermostast""" - return self._total_energy - - @property - def device_power(self) -> float | None: - """Returns the device_power for this thermostast""" - return self._device_power - - @property - def overpowering_state(self) -> bool | None: - """Get the overpowering_state""" - return self._overpowering_state - - @property - def window_state(self) -> bool | None: - """Get the window_state""" - return self._window_state - - @property - def window_auto_state(self) -> bool | None: - """Get the window_auto_state""" - return STATE_ON if self._window_auto_state else STATE_OFF - - @property - def security_state(self) -> bool | None: - """Get the security_state""" - return self._security_state - - @property - def motion_state(self) -> bool | None: - """Get the motion_state""" - return self._motion_state - - @property - def presence_state(self) -> bool | None: - """Get the presence_state""" - return self._presence_state - - @property - def proportional_algorithm(self) -> PropAlgorithm | None: - """Get the eventual ProportionalAlgorithm""" - return self._prop_algorithm - - @property - def last_temperature_mesure(self) -> datetime | None: - """Get the last temperature datetime""" - return self._last_temperature_mesure - - @property - def last_ext_temperature_mesure(self) -> datetime | None: - """Get the last external temperature datetime""" - return self._last_ext_temperature_mesure - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return self._attr_preset_mode - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return self._attr_preset_modes - - @property - def is_over_climate(self) -> bool | None: - """return True is the thermostat is over a climate - or False is over switch""" - return self._is_over_climate - - @property - def last_temperature_slope(self) -> float | None: - """Return the last temperature slope curve if any""" - if not self._window_auto_algo: - return None - else: - return self._window_auto_algo.last_slope - - @property - def is_window_auto_enabled(self) -> bool: - """True if the Window auto feature is enabled""" - return self._window_auto_on - - @property - def nb_underlying_entities(self) -> int: - """Returns the number of underlying entities""" - return len(self._underlyings) - - #PR - Adding Window ByPass - @property - def window_bypass_state(self) -> bool | None: - """Get the Window Bypass""" - return self._window_bypass_state - - def underlying_entity_id(self, index=0) -> str | None: - """The climate_entity_id. Added for retrocompatibility reason""" - if index < self.nb_underlying_entities: - return self.underlying_entity(index).entity_id - else: - return None - - def underlying_entity(self, index=0) -> UnderlyingEntity | None: - """Get the underlying entity at specified index""" - if index < self.nb_underlying_entities: - return self._underlyings[index] - else: - return None - - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - if self._is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).turn_aux_heat_on() - - raise NotImplementedError() - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - if self._is_over_climate: - for under in self._underlyings: - await under.async_turn_aux_heat_on() - - raise NotImplementedError() - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - if self._is_over_climate: - for under in self._underlyings: - return under.turn_aux_heat_off() - - raise NotImplementedError() - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - if self._is_over_climate: - for under in self._underlyings: - await under.async_turn_aux_heat_off() - - raise NotImplementedError() - - async def async_set_hvac_mode(self, hvac_mode, need_control_heating=True): - """Set new target hvac mode.""" - _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) - - if hvac_mode is None: - return - - self._hvac_mode = hvac_mode - - # Delegate to all underlying - sub_need_control_heating = False - for under in self._underlyings: - sub_need_control_heating = ( - await under.set_hvac_mode(hvac_mode) or need_control_heating - ) - - # If AC is on maybe we have to change the temperature in force mode - if self._ac_mode: - await self._async_set_preset_mode_internal(self._attr_preset_mode, True) - - if need_control_heating and sub_need_control_heating: - await self._async_control_heating(force=True) - - # 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}) - - async def async_set_preset_mode(self, preset_mode): - """Set new preset mode.""" - await self._async_set_preset_mode_internal(preset_mode) - await self._async_control_heating(force=True) - - async def _async_set_preset_mode_internal(self, preset_mode, force=False): - """Set new preset mode.""" - _LOGGER.info("%s - Set preset_mode: %s force=%s", self, preset_mode, force) - if ( - preset_mode not in (self._attr_preset_modes or []) - and preset_mode not in HIDDEN_PRESETS - ): - raise ValueError( - f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" # pylint: disable=line-too-long - ) - - if preset_mode == self._attr_preset_mode and not force: - # I don't think we need to call async_write_ha_state if we didn't change the state - return - - # In security mode don't change preset but memorise the new expected preset when security will be off - if preset_mode != PRESET_SECURITY and self._security_state: - _LOGGER.debug( - "%s - is in security mode. Just memorise the new expected ", self - ) - if preset_mode not in HIDDEN_PRESETS: - self._saved_preset_mode = preset_mode - return - - old_preset_mode = self._attr_preset_mode - if preset_mode == PRESET_NONE: - self._attr_preset_mode = PRESET_NONE - if self._saved_target_temp: - await self._async_internal_set_temperature(self._saved_target_temp) - elif preset_mode == PRESET_ACTIVITY: - self._attr_preset_mode = PRESET_ACTIVITY - await self._async_update_motion_temp() - else: - if self._attr_preset_mode == PRESET_NONE: - self._saved_target_temp = self._target_temp - self._attr_preset_mode = preset_mode - await self._async_internal_set_temperature( - self.find_preset_temp(preset_mode) - ) - - self.reset_last_temperature_time(old_preset_mode) - - self.save_preset_mode() - 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 ( - self._attr_preset_mode not in HIDDEN_PRESETS - and old_preset_mode not in HIDDEN_PRESETS - ): - self._last_temperature_mesure = ( - self._last_ext_temperature_mesure - ) = datetime.now(tz=self._current_tz) - - def find_preset_temp(self, preset_mode): - """Find the right temperature of a preset considering the presence if configured""" - if preset_mode == PRESET_SECURITY: - return ( - self._target_temp - ) # in security just keep the current target temperature, the thermostat should be off - if preset_mode == PRESET_POWER: - return self._power_temp - else: - # 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 [ - STATE_ON, - STATE_HOME, - ]: - return self._presets[preset_mode] - else: - return self._presets_away[self.get_preset_away_name(preset_mode)] - - def get_preset_away_name(self, preset_mode): - """Get the preset name in away mode (when presence is off)""" - return preset_mode + PRESET_AWAY_SUFFIX - - async def async_set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) - if fan_mode is None or not self._is_over_climate: - return - - for under in self._underlyings: - await under.set_fan_mode(fan_mode) - self._fan_mode = fan_mode - self.async_write_ha_state() - - async def async_set_humidity(self, humidity: int): - """Set new target humidity.""" - _LOGGER.info("%s - Set fan mode: %s", self, humidity) - if humidity is None or not self._is_over_climate: - return - for under in self._underlyings: - await under.set_humidity(humidity) - self._humidity = humidity - self.async_write_ha_state() - - async def async_set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) - if swing_mode is None or not self._is_over_climate: - return - for under in self._underlyings: - await under.set_swing_mode(swing_mode) - self._swing_mode = swing_mode - self.async_write_ha_state() - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.info("%s - Set target temp: %s", self, temperature) - if temperature is None: - return - 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): - """Set the target temperature and the target temperature of underlying climate if any""" - self._target_temp = temperature - if not self._is_over_climate: - return - - for under in self._underlyings: - await under.set_temperature( - temperature, self._attr_max_temp, self._attr_min_temp - ) - - def get_state_date_or_now(self, state: State): - """Extract the last_changed state from State or return now if not available""" - return ( - state.last_changed.astimezone(self._current_tz) - if state.last_changed is not None - else datetime.now(tz=self._current_tz) - ) - - def get_last_updated_date_or_now(self, state: State): - """Extract the last_changed state from State or return now if not available""" - return ( - state.last_updated.astimezone(self._current_tz) - if state.last_updated is not None - else datetime.now(tz=self._current_tz) - ) - - @callback - async def entry_update_listener( - self, _, config_entry: ConfigEntry # hass: HomeAssistant, - ) -> None: - """Called when the entry have changed in ConfigFlow""" - _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data) - - @callback - async def _async_temperature_changed(self, event: Event): - """Handle temperature of the temperature sensor changes.""" - new_state: State = event.data.get("new_state") - _LOGGER.debug( - "%s - Temperature changed. Event.new_state is %s", - self, - new_state, - ) - if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return - - await self._async_update_temp(new_state) - self.recalculate() - await self._async_control_heating(force=False) - - async def _async_ext_temperature_changed(self, event: Event): - """Handle external temperature opf the sensor changes.""" - new_state: State = event.data.get("new_state") - _LOGGER.debug( - "%s - external Temperature changed. Event.new_state is %s", - self, - new_state, - ) - if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return - - await self._async_update_ext_temp(new_state) - self.recalculate() - await self._async_control_heating(force=False) - - @callback - async def _async_windows_changed(self, event): - """Handle window changes.""" - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - _LOGGER.info( - "%s - Window changed. Event.new_state is %s, _hvac_mode=%s, _saved_hvac_mode=%s", - self, - new_state, - self._hvac_mode, - self._saved_hvac_mode, - ) - - # Check delay condition - async def try_window_condition(_): - try: - long_enough = condition.state( - self.hass, - self._window_sensor_entity_id, - new_state.state, - timedelta(seconds=self._window_delay_sec), - ) - except ConditionError: - long_enough = False - - if not long_enough: - _LOGGER.debug( - "Window delay condition is not satisfied. Ignore window event" - ) - self._window_state = old_state.state - return - - _LOGGER.debug("%s - Window delay condition is satisfied", self) - # if not self._saved_hvac_mode: - # self._saved_hvac_mode = self._hvac_mode - - if self._window_state == new_state.state: - _LOGGER.debug("%s - no change in window state. Forget the event") - return - - self._window_state = new_state.state - - #PR - Adding Window ByPass - _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) - if self._window_bypass_state: - _LOGGER.debug("Window ByPass is activated. Ignore window event") - self.update_custom_attributes() - return - - if self._window_state == STATE_OFF: - _LOGGER.info( - "%s - Window is closed. Restoring hvac_mode '%s'", - self, - self._saved_hvac_mode, - ) - await self.restore_hvac_mode(True) - elif self._window_state == STATE_ON: - _LOGGER.info( - "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF - ) - self.save_hvac_mode() - await self.async_set_hvac_mode(HVACMode.OFF) - self.update_custom_attributes() - - if new_state is None or old_state is None or new_state.state == old_state.state: - return try_window_condition - - if self._window_call_cancel: - self._window_call_cancel() - self._window_call_cancel = None - self._window_call_cancel = async_call_later( - self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition - ) - # For testing purpose we need to access the inner function - return try_window_condition - - @callback - async def _async_motion_changed(self, event): - """Handle motion changes.""" - new_state = event.data.get("new_state") - _LOGGER.info( - "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", - self, - new_state, - self._attr_preset_mode, - PRESET_ACTIVITY, - ) - - if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): - return - - # 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=delay), - ) - except ConditionError: - long_enough = False - - if not long_enough: - _LOGGER.debug( - "Motion delay condition is not satisfied. Ignore motion event" - ) - 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 - - 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 - ) - 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 - - # 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) - # 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) - - @callback - def _async_switch_changed(self, event): - """Handle heater switch state changes.""" - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if new_state is None: - return - if old_state is None: - self.hass.create_task(self._check_switch_initial_state()) - self.async_write_ha_state() - - @callback - async def _async_climate_changed(self, event): - """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: - return - - changes = False - new_hvac_mode = new_state.state - - old_state = event.data.get("old_state") - old_hvac_action = ( - old_state.attributes.get("hvac_action") - if old_state and old_state.attributes - else None - ) - new_hvac_action = ( - new_state.attributes.get("hvac_action") - if new_state and new_state.attributes - else None - ) - - 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: - # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") - # new_hvac_mode = HVACMode.OFF - - _LOGGER.info( - "%s - Underlying climate changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", - self, - new_hvac_mode, - self._hvac_mode, - new_hvac_action, - old_hvac_action, - ) - - _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 - HVACAction.COOLING, - HVACAction.DRYING, - HVACAction.FAN, - HVACAction.HEATING, - ] - if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: - self._underlying_climate_start_hvac_action_date = ( - self.get_last_updated_date_or_now(new_state) - ) - _LOGGER.info( - "%s - underlying just switch ON. Set power and energy start date %s", - self, - self._underlying_climate_start_hvac_action_date.isoformat(), - ) - changes = True - - if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: - stop_power_date = self.get_last_updated_date_or_now(new_state) - if self._underlying_climate_start_hvac_action_date: - delta = ( - stop_power_date - self._underlying_climate_start_hvac_action_date - ) - self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 - - # increment energy at the end of the cycle - self.incremente_energy() - - self._underlying_climate_start_hvac_action_date = None - - _LOGGER.info( - "%s - underlying just switch OFF at %s. delta_h=%.3f h", - self, - stop_power_date.isoformat(), - self._underlying_climate_delta_t, - ) - 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 in underlying have change to %s", - self, - new_target_temp, - ) - await self.async_set_temperature(temperature=new_target_temp) - changes = True - - await end_climate_changed(changes) - - @callback - async def _async_update_temp(self, state: State): - """Update thermostat with latest state from sensor.""" - try: - cur_temp = float(state.state) - if math.isnan(cur_temp) or math.isinf(cur_temp): - raise ValueError(f"Sensor has illegal state {state.state}") - self._cur_temp = cur_temp - - self._last_temperature_mesure = self.get_state_date_or_now(state) - - _LOGGER.debug( - "%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s", - self, - self._last_temperature_mesure, - state.last_changed.astimezone(self._current_tz), - ) - - # try to restart if we were in security mode - if self._security_state: - await self.check_security() - - # check window_auto - await self._async_manage_window_auto() - - except ValueError as ex: - _LOGGER.error("Unable to update temperature from sensor: %s", ex) - - @callback - async def _async_update_ext_temp(self, state: State): - """Update thermostat with latest state from sensor.""" - try: - cur_ext_temp = float(state.state) - if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp): - raise ValueError(f"Sensor has illegal state {state.state}") - self._cur_ext_temp = cur_ext_temp - self._last_ext_temperature_mesure = self.get_state_date_or_now(state) - - _LOGGER.debug( - "%s - After setting _last_ext_temperature_mesure %s , state.last_changed.replace=%s", - self, - self._last_ext_temperature_mesure, - state.last_changed.astimezone(self._current_tz), - ) - - # try to restart if we were in security mode - if self._security_state: - await self.check_security() - except ValueError as ex: - _LOGGER.error("Unable to update external temperature from sensor: %s", ex) - - @callback - async def _async_power_changed(self, event): - """Handle power changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power event", self.name) - _LOGGER.debug(event) - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if ( - new_state is None - or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or (old_state is not None and new_state.state == old_state.state) - ): - return - - try: - current_power = float(new_state.state) - if math.isnan(current_power) or math.isinf(current_power): - raise ValueError(f"Sensor has illegal state {new_state.state}") - self._current_power = current_power - - if self._attr_preset_mode == PRESET_POWER: - await self._async_control_heating() - - except ValueError as ex: - _LOGGER.error("Unable to update current_power from sensor: %s", ex) - - @callback - async def _async_max_power_changed(self, event): - """Handle power max changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) - _LOGGER.debug(event) - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if ( - new_state is None - or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or (old_state is not None and new_state.state == old_state.state) - ): - return - - try: - current_power_max = float(new_state.state) - if math.isnan(current_power_max) or math.isinf(current_power_max): - raise ValueError(f"Sensor has illegal state {new_state.state}") - self._current_power_max = current_power_max - if self._attr_preset_mode == PRESET_POWER: - await self._async_control_heating() - - except ValueError as ex: - _LOGGER.error("Unable to update current_power from sensor: %s", ex) - - @callback - async def _async_presence_changed(self, event): - """Handle presence changes.""" - new_state = event.data.get("new_state") - _LOGGER.info( - "%s - Presence changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", - self, - new_state, - self._attr_preset_mode, - PRESET_ACTIVITY, - ) - if new_state is None: - return - - await self._async_update_presence(new_state.state) - await self._async_control_heating(force=True) - - async def _async_update_presence(self, new_state): - _LOGGER.debug("%s - Updating presence. New state is %s", self, new_state) - self._presence_state = new_state - if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: - _LOGGER.info( - "%s - Ignoring presence change cause in Power or Security preset or presence not configured", - self, - ) - return - if new_state is None or new_state not in ( - STATE_OFF, - STATE_ON, - STATE_HOME, - STATE_NOT_HOME, - ): - return - if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: - return - - # 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", - self, - new_temp, - ) - await self._async_internal_set_temperature(new_temp) - self.recalculate() - - async def _async_update_motion_temp(self): - """Update the temperature considering the ACTIVITY preset and current motion state""" - _LOGGER.debug( - "%s - Calling _update_motion_temp preset_mode=%s, motion_state=%s", - self, - self._attr_preset_mode, - self._motion_state, - ) - if ( - self._motion_sensor_entity_id is None - or self._attr_preset_mode != PRESET_ACTIVITY - ): - return - - await self._async_internal_set_temperature( - self._presets[ - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ] - ) - _LOGGER.debug( - "%s - regarding motion, target_temp have been set to %.2f", - self, - self._target_temp, - ) - - async def _async_underlying_entity_turn_off(self): - """Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off""" - - for under in self._underlyings: - await under.turn_off() - - async def _async_manage_window_auto(self): - """The management of the window auto feature""" - - async def dearm_window_auto(_): - """Callback that will be called after end of WINDOW_AUTO_MAX_DURATION""" - _LOGGER.info("Unset window auto because MAX_DURATION is exceeded") - await deactivate_window_auto(auto=True) - - async def deactivate_window_auto(auto=False): - """Deactivation of the Window auto state""" - _LOGGER.warning( - "%s - End auto detection of open window slope=%.3f", self, slope - ) - # Send an event - cause = "max duration expiration" if auto else "end of slope alert" - self.send_event( - EventType.WINDOW_AUTO_EVENT, - {"type": "end", "cause": cause, "curve_slope": slope}, - ) - # Set attributes - self._window_auto_state = False - await self.restore_hvac_mode(True) - - if self._window_call_cancel: - self._window_call_cancel() - self._window_call_cancel = None - - if not self._window_auto_algo: - return - - slope = self._window_auto_algo.add_temp_measurement( - temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure - ) - _LOGGER.debug( - "%s - Window auto is on, check the alert. last slope is %.3f", - self, - slope if slope is not None else 0.0, - ) - if ( - self._window_auto_algo.is_window_open_detected() - and self._window_auto_state is False - and self.hvac_mode != HVACMode.OFF - ): - if ( - not self.proportional_algorithm - or self.proportional_algorithm.on_percent <= 0.0 - ): - _LOGGER.info( - "%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event", - self, - slope, - ) - return dearm_window_auto - - _LOGGER.warning( - "%s - Start auto detection of open window slope=%.3f", self, slope - ) - - # Send an event - self.send_event( - EventType.WINDOW_AUTO_EVENT, - {"type": "start", "cause": "slope alert", "curve_slope": slope}, - ) - # Set attributes - self._window_auto_state = True - self.save_hvac_mode() - await self.async_set_hvac_mode(HVACMode.OFF) - - # Arm the end trigger - if self._window_call_cancel: - self._window_call_cancel() - self._window_call_cancel = None - self._window_call_cancel = async_call_later( - self.hass, - timedelta(minutes=self._window_auto_max_duration), - dearm_window_auto, - ) - - elif ( - self._window_auto_algo.is_window_close_detected() - and self._window_auto_state is True - ): - await deactivate_window_auto(False) - - # For testing purpose we need to return the inner function - return dearm_window_auto - - def save_preset_mode(self): - """Save the current preset mode to be restored later - We never save a hidden preset mode - """ - if ( - self._attr_preset_mode not in HIDDEN_PRESETS - and self._attr_preset_mode is not None - ): - self._saved_preset_mode = self._attr_preset_mode - - async def restore_preset_mode(self): - """Restore a previous preset mode - We never restore a hidden preset mode. Normally that is not possible - """ - if ( - self._saved_preset_mode not in HIDDEN_PRESETS - and self._saved_preset_mode is not None - ): - await self._async_set_preset_mode_internal(self._saved_preset_mode) - - def save_hvac_mode(self): - """Save the current hvac-mode to be restored later""" - self._saved_hvac_mode = self._hvac_mode - _LOGGER.debug( - "%s - Saved hvac mode - saved_hvac_mode is %s, hvac_mode is %s", - self, - self._saved_hvac_mode, - self._hvac_mode, - ) - - 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", - self, - 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 - Turn the preset_mode of the heater to 'power' if power conditions are exceeded - """ - - if not self._pmax_on: - _LOGGER.debug( - "%s - power not configured. check_overpowering not available", self - ) - return False - - if ( - self._current_power is None - or self._device_power is None - or self._current_power_max is None - ): - _LOGGER.warning( - "%s - power not valued. check_overpowering not available", self - ) - return False - - _LOGGER.debug( - "%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f", - self, - self._current_power, - self._current_power_max, - self._device_power, - ) - - ret = self._current_power + self._device_power >= self._current_power_max - if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF: - _LOGGER.warning( - "%s - overpowering is detected. Heater preset will be set to 'power'", - self, - ) - if self._is_over_climate: - self.save_hvac_mode() - self.save_preset_mode() - await self._async_underlying_entity_turn_off() - await self._async_set_preset_mode_internal(PRESET_POWER) - self.send_event( - EventType.POWER_EVENT, - { - "type": "start", - "current_power": self._current_power, - "device_power": self._device_power, - "current_power_max": self._current_power_max, - }, - ) - - # Check if we need to remove the POWER preset - if ( - self._overpowering_state - and not ret - and self._attr_preset_mode == PRESET_POWER - ): - _LOGGER.warning( - "%s - end of overpowering is detected. Heater preset will be restored to '%s'", - self, - self._saved_preset_mode, - ) - if self._is_over_climate: - await self.restore_hvac_mode(False) - await self.restore_preset_mode() - self.send_event( - EventType.POWER_EVENT, - { - "type": "end", - "current_power": self._current_power, - "device_power": self._device_power, - "current_power_max": self._current_power_max, - }, - ) - - self._overpowering_state = ret - return self._overpowering_state - - async def check_security(self) -> bool: - """Check if last temperature date is too long""" - now = datetime.now(self._current_tz) - delta_temp = ( - now - self._last_temperature_mesure.replace(tzinfo=self._current_tz) - ).total_seconds() / 60.0 - delta_ext_temp = ( - now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz) - ).total_seconds() / 60.0 - - mode_cond = self._hvac_mode != HVACMode.OFF - - temp_cond: bool = ( - delta_temp > self._security_delay_min - or delta_ext_temp > self._security_delay_min - ) - climate_cond: bool = self._is_over_climate and self.hvac_action not in [ - HVACAction.COOLING, - HVACAction.IDLE, - ] - switch_cond: bool = ( - not self._is_over_climate - and self._prop_algorithm is not None - and self._prop_algorithm.calculated_on_percent - >= self._security_min_on_percent - ) - - _LOGGER.debug( - "%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s", - self, - delta_temp, - delta_ext_temp, - mode_cond, - temp_cond, - climate_cond, - switch_cond, - ) - - # 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 - shouldSwitchBeInSecurity = temp_cond and switch_cond - shouldBeInSecurity = shouldClimateBeInSecurity or shouldSwitchBeInSecurity - - 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 - ) - - # Logging and event - if shouldStartSecurity: - if shouldClimateBeInSecurity: - _LOGGER.warning( - "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Set it into security mode", - self, - self._security_delay_min, - delta_temp, - delta_ext_temp, - self.hvac_action, - ) - elif shouldSwitchBeInSecurity: - _LOGGER.warning( - "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent (%.2f) is over defined value (%.2f). Set it into security mode", - self, - self._security_delay_min, - delta_temp, - delta_ext_temp, - self._prop_algorithm.on_percent, - self._security_min_on_percent, - ) - - self.send_event( - EventType.TEMPERATURE_EVENT, - { - "last_temperature_mesure": self._last_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "current_temp": self._cur_temp, - "current_ext_temp": self._cur_ext_temp, - "target_temp": self.target_temperature, - }, - ) - - if shouldStartSecurity: - self._security_state = True - self.save_hvac_mode() - self.save_preset_mode() - await self._async_set_preset_mode_internal(PRESET_SECURITY) - # Turn off the underlying climate or heater if security default on_percent is 0 - if self._is_over_climate or self._security_default_on_percent <= 0.0: - await self.async_set_hvac_mode(HVACMode.OFF, False) - if self._prop_algorithm: - self._prop_algorithm.set_security(self._security_default_on_percent) - - self.send_event( - EventType.SECURITY_EVENT, - { - "type": "start", - "last_temperature_mesure": self._last_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "current_temp": self._cur_temp, - "current_ext_temp": self._cur_ext_temp, - "target_temp": self.target_temperature, - }, - ) - - if shouldStopSecurity: - _LOGGER.warning( - "%s - End of security mode. restoring hvac_mode to %s and preset_mode to %s", - self, - self._saved_hvac_mode, - self._saved_preset_mode, - ) - self._security_state = False - # Restore hvac_mode if previously saved - if self._is_over_climate or self._security_default_on_percent <= 0.0: - await self.restore_hvac_mode(False) - await self.restore_preset_mode() - if self._prop_algorithm: - self._prop_algorithm.unset_security() - self.send_event( - EventType.SECURITY_EVENT, - { - "type": "end", - "last_temperature_mesure": self._last_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( - tzinfo=self._current_tz - ).isoformat(), - "current_temp": self._cur_temp, - "current_ext_temp": self._cur_ext_temp, - "target_temp": self.target_temperature, - }, - ) - - return shouldBeInSecurity - - async def _async_control_heating(self, force=False, _=None): - """The main function used to run the calculation at each cycle""" - - _LOGGER.debug( - "%s - Checking new cycle. hvac_mode=%s, security_state=%s, preset_mode=%s", - self, - self._hvac_mode, - self._security_state, - self._attr_preset_mode, - ) - - # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it - for under in self._underlyings: - if not under.is_initialized: - _LOGGER.info( - "%s - Underlying %s is not initialized. Try to initialize it", - self, - under.entity_id, - ) - try: - under.startup() - except UnknownEntity: - # still not found, we an stop here - 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 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 True - - # Stop here if we are off - if self._hvac_mode == HVACMode.OFF: - _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", self) - # A security to force stop heater if still active - if self._is_device_active: - await self._async_underlying_entity_turn_off() - return True - - if not self._is_over_climate: - for under in self._underlyings: - await under.start_cycle( - self._hvac_mode, - self._prop_algorithm.on_time_sec, - self._prop_algorithm.off_time_sec, - force, - ) - - 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 - """ - _LOGGER.debug("%s - recalculate all", self) - 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() - - def incremente_energy(self): - """increment the energy counter if device is active""" - if self.hvac_mode == HVACMode.OFF: - return - - added_energy = 0 - if self._is_over_climate and self._underlying_climate_delta_t is not None: - added_energy = self._device_power * self._underlying_climate_delta_t - - if not self._is_over_climate and self.mean_cycle_power is not None: - added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 - - self._total_energy += added_energy - _LOGGER.debug( - "%s - added energy is %.3f . Total energy is now: %.3f", - self, - added_energy, - self._total_energy, - ) - - def update_custom_attributes(self): - """Update the custom extra attributes for the entity""" - - self._attr_extra_state_attributes: dict(str, str) = { - "hvac_mode": self.hvac_mode, - "preset_mode": self.preset_mode, - "type": self._thermostat_type, - "eco_temp": self._presets[PRESET_ECO], - "boost_temp": self._presets[PRESET_BOOST], - "comfort_temp": self._presets[PRESET_COMFORT], - "eco_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_ECO) - ), - "boost_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_BOOST) - ), - "comfort_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_COMFORT) - ), - "power_temp": self._power_temp, - "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, - "saved_target_temp": self._saved_target_temp, - "saved_hvac_mode": self._saved_hvac_mode, - "window_state": self._window_state, - "motion_state": self._motion_state, - "overpowering_state": self._overpowering_state, - "presence_state": self._presence_state, - "window_auto_state": self._window_auto_state, - #PR - Adding Window ByPass - "window_bypass_state": self._window_bypass_state, - "security_delay_min": self._security_delay_min, - "security_min_on_percent": self._security_min_on_percent, - "security_default_on_percent": self._security_default_on_percent, - "last_temperature_datetime": self._last_temperature_mesure.astimezone( - self._current_tz - ).isoformat(), - "last_ext_temperature_datetime": self._last_ext_temperature_mesure.astimezone( - self._current_tz - ).isoformat(), - "security_state": self._security_state, - "minimal_activation_delay_sec": self._minimal_activation_delay, - "device_power": self._device_power, - ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power, - ATTR_TOTAL_ENERGY: self.total_energy, - "last_update_datetime": datetime.now() - .astimezone(self._current_tz) - .isoformat(), - "timezone": str(self._current_tz), - "window_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_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" - ] = self._underlying_climate_start_hvac_action_date - else: - self._attr_extra_state_attributes[ - "underlying_switch_1" - ] = self._underlyings[0].entity_id - self._attr_extra_state_attributes["underlying_switch_2"] = ( - self._underlyings[1].entity_id if len(self._underlyings) > 1 else None - ) - self._attr_extra_state_attributes["underlying_switch_3"] = ( - self._underlyings[2].entity_id if len(self._underlyings) > 2 else None - ) - self._attr_extra_state_attributes["underlying_switch_4"] = ( - self._underlyings[3].entity_id if len(self._underlyings) > 3 else None - ) - self._attr_extra_state_attributes[ - "on_percent" - ] = self._prop_algorithm.on_percent - self._attr_extra_state_attributes[ - "on_time_sec" - ] = self._prop_algorithm.on_time_sec - self._attr_extra_state_attributes[ - "off_time_sec" - ] = self._prop_algorithm.off_time_sec - self._attr_extra_state_attributes["cycle_min"] = self._cycle_min - self._attr_extra_state_attributes["function"] = self._proportional_function - self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int - self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext - - self.async_write_ha_state() - _LOGGER.debug( - "%s - Calling update_custom_attributes: %s", - self, - self._attr_extra_state_attributes, - ) - - @callback - def async_registry_entry_updated(self): - """update the entity if the config entry have been updated - Note: this don't work either - """ - _LOGGER.info("%s - The config entry have been updated") - - async def service_set_presence(self, presence): - """Called by a service call: - service: versatile_thermostat.set_presence - data: - presence: "off" - target: - entity_id: climate.thermostat_1 - """ - _LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence) - await self._async_update_presence(presence) - await self._async_control_heating(force=True) - - async def service_set_preset_temperature( - self, preset, temperature=None, temperature_away=None - ): - """Called by a service call: - service: versatile_thermostat.set_preset_temperature - data: - preset: boost - temperature: 17.8 - temperature_away: 15 - target: - entity_id: climate.thermostat_2 - """ - _LOGGER.info( - "%s - Calling service_set_preset_temperature, preset: %s, temperature: %s, temperature_away: %s", - self, - preset, - temperature, - temperature_away, - ) - if preset in self._presets: - if temperature is not None: - self._presets[preset] = temperature - if self._presence_on and temperature_away is not None: - self._presets_away[self.get_preset_away_name(preset)] = temperature_away - else: - _LOGGER.warning( - "%s - No preset %s configured for this thermostat. Ignoring set_preset_temperature call", - self, - preset, - ) - - # If the changed preset is active, change the current temperature - # 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): - """Called by a service call: - service: versatile_thermostat.set_security - data: - delay_min: 15 - min_on_percent: 0.5 - default_on_percent: 0.2 - target: - entity_id: climate.thermostat_2 - """ - _LOGGER.info( - "%s - Calling service_set_security, delay_min: %s, min_on_percent: %s, default_on_percent: %s", - self, - delay_min, - min_on_percent, - default_on_percent, - ) - if delay_min: - self._security_delay_min = delay_min - if min_on_percent: - self._security_min_on_percent = min_on_percent - if default_on_percent: - self._security_default_on_percent = default_on_percent - - if self._prop_algorithm and self._security_state: - self._prop_algorithm.set_security(self._security_default_on_percent) - - await self._async_control_heating() - self.update_custom_attributes() - - #PR - Adding Window ByPass - async def service_set_window_bypass_state(self, window_bypass): - """Called by a service call: - service: versatile_thermostat.set_window_bypass - data: - window_bypass: True - target: - entity_id: climate.thermostat_1 - """ - _LOGGER.info("%s - Calling service_set_window_bypass, window_bypass: %s", self, window_bypass) - self._window_bypass_state = window_bypass - if not self._window_bypass_state and self._window_state == STATE_ON: - _LOGGER.info("%s - Last window state was open & ByPass is now off. Set hvac_mode to '%s'", self, HVACMode.OFF) - self.save_hvac_mode() - await self.async_set_hvac_mode(HVACMode.OFF) - self.update_custom_attributes() - - def send_event(self, event_type: EventType, data: dict): - """Send an event""" - _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data) - data["entity_id"] = self.entity_id - data["name"] = self.name - data["state_attributes"] = self.state_attributes - self._hass.bus.fire(event_type.value, data) diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py index 5f95c7bc..5032e553 100644 --- a/custom_components/versatile_thermostat/commons.py +++ b/custom_components/versatile_thermostat/commons.py @@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.event import async_track_state_change_event, async_call_later -from .climate import VersatileThermostat +from .base_thermostat import BaseThermostat from .const import DOMAIN, DEVICE_MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -17,7 +17,7 @@ class VersatileThermostatBaseEntity(Entity): """A base class for all entities""" - _my_climate: VersatileThermostat + _my_climate: BaseThermostat hass: HomeAssistant _config_id: str _device_name: str @@ -37,7 +37,7 @@ def should_poll(self) -> bool: return False @property - def my_climate(self) -> VersatileThermostat | None: + def my_climate(self) -> BaseThermostat | None: """Returns my climate if found""" if not self._my_climate: self._my_climate = self.find_my_versatile_thermostat() @@ -54,7 +54,7 @@ def device_info(self) -> DeviceInfo: model=DOMAIN, ) - def find_my_versatile_thermostat(self) -> VersatileThermostat: + def find_my_versatile_thermostat(self) -> BaseThermostat: """Find the underlying climate entity""" try: component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index a198771f..1987242d 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -1,3 +1,7 @@ +# pylint: disable=line-too-long +# pylint: disable=too-many-lines +# pylint: disable=invalid-name + """Config flow for Versatile Thermostat integration.""" from __future__ import annotations @@ -24,6 +28,7 @@ from homeassistant.helpers import selector from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.input_boolean import ( DOMAIN as INPUT_BOOLEAN_DOMAIN, ) @@ -91,6 +96,11 @@ CONF_USE_POWER_FEATURE, CONF_AC_MODE, CONF_THERMOSTAT_TYPES, + CONF_THERMOSTAT_VALVE, + CONF_VALVE, + CONF_VALVE_2, + CONF_VALVE_3, + CONF_VALVE_4, UnknownEntity, WindowOpenDetectionMethod, ) @@ -249,6 +259,39 @@ def __init__(self, infos) -> None: } ) + self.STEP_THERMOSTAT_VALVE = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_VALVE): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] + ), + ), + vol.Optional(CONF_VALVE_2): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] + ), + ), + vol.Optional(CONF_VALVE_3): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] + ), + ), + vol.Optional(CONF_VALVE_4): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] + ), + ), + vol.Required( + CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI + ): vol.In( + [ + PROPORTIONAL_FUNCTION_TPI, + ] + ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, + } + ) + self.STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_TPI_COEF_INT, default=0.6): vol.Coerce(float), @@ -479,6 +522,10 @@ async def async_step_type(self, user_input: dict | None = None) -> FlowResult: return await self.generic_step( "type", self.STEP_THERMOSTAT_SWITCH, user_input, self.async_step_tpi ) + elif self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_VALVE: + return await self.generic_step( + "type", self.STEP_THERMOSTAT_VALVE, user_input, self.async_step_tpi + ) else: return await self.generic_step( "type", @@ -509,7 +556,7 @@ async def async_step_presets(self, user_input: dict | None = None) -> FlowResult elif self._infos[CONF_USE_PRESENCE_FEATURE]: next_step = self.async_step_presence - if self._infos.get(CONF_AC_MODE) == True: + if self._infos.get(CONF_AC_MODE) is True: schema = self.STEP_PRESETS_WITH_AC_DATA_SCHEMA else: schema = self.STEP_PRESETS_DATA_SCHEMA @@ -565,7 +612,7 @@ async def async_step_presence(self, user_input: dict | None = None) -> FlowResul """Handle the presence management flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_presence user_input=%s", user_input) - if self._infos.get(CONF_AC_MODE) == True: + if self._infos.get(CONF_AC_MODE) is True: schema = self.STEP_PRESENCE_WITH_AC_DATA_SCHEMA else: schema = self.STEP_PRESENCE_DATA_SCHEMA @@ -670,6 +717,10 @@ async def async_step_type(self, user_input: dict | None = None) -> FlowResult: return await self.generic_step( "type", self.STEP_THERMOSTAT_SWITCH, user_input, self.async_step_tpi ) + elif self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_VALVE: + return await self.generic_step( + "type", self.STEP_THERMOSTAT_VALVE, user_input, self.async_step_tpi + ) else: return await self.generic_step( "type", @@ -704,7 +755,7 @@ async def async_step_presets(self, user_input: dict | None = None) -> FlowResult elif self._infos[CONF_USE_PRESENCE_FEATURE]: next_step = self.async_step_presence - if self._infos.get(CONF_AC_MODE) == True: + if self._infos.get(CONF_AC_MODE) is True: schema = self.STEP_PRESETS_WITH_AC_DATA_SCHEMA else: schema = self.STEP_PRESETS_DATA_SCHEMA @@ -767,7 +818,7 @@ async def async_step_presence(self, user_input: dict | None = None) -> FlowResul "Into OptionsFlowHandler.async_step_presence user_input=%s", user_input ) - if self._infos.get(CONF_AC_MODE) == True: + if self._infos.get(CONF_AC_MODE) is True: schema = self.STEP_PRESENCE_WITH_AC_DATA_SCHEMA else: schema = self.STEP_PRESENCE_DATA_SCHEMA diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index df280466..202aaf8a 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -11,16 +11,16 @@ ClimateEntityFeature, ) -PRESET_AC_SUFFIX = "_ac" -PRESET_ECO_AC = PRESET_ECO + PRESET_AC_SUFFIX -PRESET_COMFORT_AC = PRESET_COMFORT + PRESET_AC_SUFFIX -PRESET_BOOST_AC = PRESET_BOOST + PRESET_AC_SUFFIX - from homeassistant.exceptions import HomeAssistantError from .prop_algorithm import ( PROPORTIONAL_FUNCTION_TPI, ) +PRESET_AC_SUFFIX = "_ac" +PRESET_ECO_AC = PRESET_ECO + PRESET_AC_SUFFIX +PRESET_COMFORT_AC = PRESET_COMFORT + PRESET_AC_SUFFIX +PRESET_BOOST_AC = PRESET_BOOST + PRESET_AC_SUFFIX + DEVICE_MANUFACTURER = "JMCOLLIN" DEVICE_MODEL = "Versatile Thermostat" @@ -65,6 +65,7 @@ CONF_THERMOSTAT_TYPE = "thermostat_type" CONF_THERMOSTAT_SWITCH = "thermostat_over_switch" CONF_THERMOSTAT_CLIMATE = "thermostat_over_climate" +CONF_THERMOSTAT_VALVE = "thermostat_over_valve" CONF_CLIMATE = "climate_entity_id" CONF_CLIMATE_2 = "climate_entity2_id" CONF_CLIMATE_3 = "climate_entity3_id" @@ -77,6 +78,10 @@ CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold" CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold" CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration" +CONF_VALVE = "valve_entity_id" +CONF_VALVE_2 = "valve_entity2_id" +CONF_VALVE_3 = "valve_entity3_id" +CONF_VALVE_4 = "valve_entity4_id" CONF_PRESETS = { p: f"{p}_temp" @@ -174,6 +179,11 @@ CONF_USE_PRESENCE_FEATURE, CONF_USE_POWER_FEATURE, CONF_AC_MODE, + CONF_VALVE, + CONF_VALVE_2, + CONF_VALVE_3, + CONF_VALVE_4, + ] + CONF_PRESETS_VALUES + CONF_PRESETS_AWAY_VALUES @@ -185,7 +195,7 @@ PROPORTIONAL_FUNCTION_TPI, ] -CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE] +CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE] SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE @@ -219,3 +229,14 @@ class UnknownEntity(HomeAssistantError): class WindowOpenDetectionMethod(HomeAssistantError): """Error to indicate there is an error in the window open detection method given.""" + +class overrides: # pylint: disable=invalid-name + """ An annotation to inform overrides """ + def __init__(self, func): + self.func = func + + def __get__(self, instance, owner): + return self.func.__get__(instance, owner) + + def __call__(self, *args, **kwargs): + raise RuntimeError(f"Method {self.func.__name__} should have been overridden") diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index f3226ded..13cc18bd 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-argument """ Implements the VersatileThermostat sensors component """ import logging import math @@ -22,6 +23,7 @@ CONF_PROP_FUNCTION, PROPORTIONAL_FUNCTION_TPI, CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_TYPE, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ] if entry.data.get(CONF_DEVICE_POWER): entities.append(EnergySensor(hass, unique_id, name, entry.data)) - if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_SWITCH: + if entry.data.get(CONF_THERMOSTAT_TYPE) in [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_VALVE]: entities.append(MeanPowerSensor(hass, unique_id, name, entry.data)) if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI: @@ -58,6 +60,9 @@ async def async_setup_entry( entities.append(OnTimeSensor(hass, unique_id, name, entry.data)) entities.append(OffTimeSensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: + entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) + async_add_entities(entities, True) @@ -224,6 +229,47 @@ def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" return 1 +class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on percent sensor which exposes the on_percent in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Vave open percent" + self._attr_unique_id = f"{self._device_name}_valve_open_percent" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_native_value + self._attr_native_value = self.my_climate.valve_open_percent + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:pipe-valve" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.POWER_FACTOR + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return PERCENTAGE + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 0 + class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity): """Representation of a on time sensor which exposes the on_time_sec in a cycle""" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 263e00c1..0db2ed6e 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -34,7 +34,11 @@ "climate_entity2_id": "2nd underlying climate", "climate_entity3_id": "3rd underlying climate", "climate_entity4_id": "4th underlying climate", - "ac_mode": "AC mode" + "ac_mode": "AC mode", + "valve_entity_id": "1rst valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -46,7 +50,11 @@ "climate_entity2_id": "2nd underlying climate entity id", "climate_entity3_id": "3rd underlying climate entity id", "climate_entity4_id": "4th underlying climate entity id", - "ac_mode": "Use the Air Conditioning (AC) mode" + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1rst valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id" } }, "tpi": { @@ -178,16 +186,20 @@ "title": "Linked entities", "description": "Linked entities attributes", "data": { - "heater_entity_id": "Heater switch", - "heater_entity2_id": "2nd Heater switch", - "heater_entity3_id": "3rd Heater switch", - "heater_entity4_id": "4th 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 thermostat", + "climate_entity_id": "1rst underlying climate", "climate_entity2_id": "2nd underlying climate", "climate_entity3_id": "3rd underlying climate", "climate_entity4_id": "4th underlying climate", - "ac_mode": "AC mode" + "ac_mode": "AC mode", + "valve_entity_id": "1rst valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -199,7 +211,11 @@ "climate_entity2_id": "2nd underlying climate entity id", "climate_entity3_id": "3rd underlying climate entity id", "climate_entity4_id": "4th underlying climate entity id", - "ac_mode": "Use the Air Conditioning (AC) mode" + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1rst valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id" } }, "tpi": { @@ -310,7 +326,8 @@ "thermostat_type": { "options": { "thermostat_over_switch": "Thermostat over a switch", - "thermostat_over_climate": "Thermostat over another thermostat" + "thermostat_over_climate": "Thermostat over a climate", + "thermostat_over_valve": "Thermostat over a valve" } } }, diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py new file mode 100644 index 00000000..74eb6fe9 --- /dev/null +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -0,0 +1,140 @@ +# pylint: disable=line-too-long +""" A climate over switch classe """ +import logging +from datetime import timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval + +from homeassistant.components.climate import HVACAction + +from .base_thermostat import BaseThermostat + +from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4 + +from .underlyings import UnderlyingClimate + +_LOGGER = logging.getLogger(__name__) + +class ThermostatOverClimate(BaseThermostat): + """Representation of a base class for a Versatile Thermostat over a climate""" + + _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( + { + "is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1", + "underlying_climate_2", "underlying_climate_3" + })) + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat over switch.""" + super().__init__(hass, unique_id, name, entry_infos) + + @property + def is_over_climate(self) -> bool: + """ True if the Thermostat is over_climate""" + return True + + @property + def hvac_action(self) -> HVACAction | None: + """ Returns the current hvac_action by checking all hvac_action of the underlyings """ + + # if one not IDLE or OFF -> return it + # else if one IDLE -> IDLE + # else OFF + one_idle = False + for under in self._underlyings: + if (action := under.hvac_action) not in [ + HVACAction.IDLE, + HVACAction.OFF, + ]: + return action + if under.hvac_action == HVACAction.IDLE: + one_idle = True + if one_idle: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def hvac_modes(self): + """List of available operation modes.""" + if self.underlying_entity(0): + return self.underlying_entity(0).hvac_modes + else: + return super.hvac_modes + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + for climate in [ + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + ]: + if entry_infos.get(climate): + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat=self, + climate_entity_id=entry_infos.get(climate), + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for climate in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [climate.entity_id], self._async_climate_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self.async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + + self._attr_extra_state_attributes["is_over_climate"] = self.is_over_climate + self._attr_extra_state_attributes["start_hvac_action_date"] = ( + self._underlying_climate_start_hvac_action_date) + 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.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self.update_custom_attributes() + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py new file mode 100644 index 00000000..db4b3057 --- /dev/null +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -0,0 +1,130 @@ +# pylint: disable=line-too-long + +""" A climate over switch classe """ +import logging +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.components.climate import HVACMode + +from .const import ( + CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4 +) + +from .base_thermostat import BaseThermostat + +from .underlyings import UnderlyingSwitch + +_LOGGER = logging.getLogger(__name__) + +class ThermostatOverSwitch(BaseThermostat): + """Representation of a base class for a Versatile Thermostat over a switch.""" + + _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( + { + "is_over_switch", "underlying_switch_0", "underlying_switch_1", + "underlying_switch_2", "underlying_switch_3", "on_time_sec", "off_time_sec", + "cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" + })) + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat over switch.""" + super().__init__(hass, unique_id, name, entry_infos) + + @property + def is_over_switch(self) -> bool: + """ True if the Thermostat is over_switch""" + return True + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + lst_switches = [entry_infos.get(CONF_HEATER)] + if entry_infos.get(CONF_HEATER_2): + lst_switches.append(entry_infos.get(CONF_HEATER_2)) + if entry_infos.get(CONF_HEATER_3): + lst_switches.append(entry_infos.get(CONF_HEATER_3)) + if entry_infos.get(CONF_HEATER_4): + lst_switches.append(entry_infos.get(CONF_HEATER_4)) + + delta_cycle = self._cycle_min * 60 / len(lst_switches) + for idx, switch in enumerate(lst_switches): + self._underlyings.append( + UnderlyingSwitch( + hass=self._hass, + thermostat=self, + switch_entity_id=switch, + initial_delay_sec=idx * delta_cycle, + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for switch in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [switch.entity_id], self._async_switch_changed + ) + ) + + self.hass.create_task(self.async_control_heating()) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + + self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch + self._attr_extra_state_attributes["underlying_switch_0"] = ( + self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_switch_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_switch_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_switch_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + 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() diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py new file mode 100644 index 00000000..5c1981b6 --- /dev/null +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -0,0 +1,318 @@ +# pylint: disable=line-too-long +""" A climate over switch classe """ +import logging +from datetime import timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval +from homeassistant.core import callback +from homeassistant.components.climate import HVACMode, HVACAction + +from .base_thermostat import BaseThermostat + +from .const import CONF_VALVE, CONF_VALVE_2, CONF_VALVE_3, CONF_VALVE_4 + +from .underlyings import UnderlyingValve + +_LOGGER = logging.getLogger(__name__) + +class ThermostatOverValve(BaseThermostat): + """Representation of a class for a Versatile Thermostat over a Valve""" + + _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( + { + "is_over_valve", "underlying_valve_0", "underlying_valve_1", + "underlying_valve_2", "underlying_valve_3", "on_time_sec", "off_time_sec", + "cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" + })) + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat over switch.""" + super().__init__(hass, unique_id, name, entry_infos) + + @property + def is_over_valve(self) -> bool: + """ True if the Thermostat is over_valve""" + return True + + @property + def valve_open_percent(self) -> int: + """ Gives the percentage of valve needed""" + if self._hvac_mode == HVACMode.OFF: + return 0 + else: + return round(max(0, min(self.proportional_algorithm.on_percent, 1)) * 100) + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + lst_valves = [entry_infos.get(CONF_VALVE)] + if entry_infos.get(CONF_VALVE_2): + lst_valves.append(entry_infos.get(CONF_VALVE_2)) + if entry_infos.get(CONF_VALVE_3): + lst_valves.append(entry_infos.get(CONF_VALVE_3)) + if entry_infos.get(CONF_VALVE_4): + lst_valves.append(entry_infos.get(CONF_VALVE_4)) + + for _, valve in enumerate(lst_valves): + self._underlyings.append( + UnderlyingValve( + hass=self._hass, + thermostat=self, + valve_entity_id=valve + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for valve in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [valve.entity_id], self._async_valve_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self.async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + + @callback + async def _async_valve_changed(self, event): + """Handle unerdlying valve 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: + return + + changes = False + new_hvac_mode = new_state.state + + old_state = event.data.get("old_state") + old_hvac_action = ( + old_state.attributes.get("hvac_action") + if old_state and old_state.attributes + else None + ) + new_hvac_action = ( + new_state.attributes.get("hvac_action") + if new_state and new_state.attributes + else None + ) + + 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: + # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") + # new_hvac_mode = HVACMode.OFF + + _LOGGER.info( + "%s - Underlying climate changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", + self, + new_hvac_mode, + self._hvac_mode, + new_hvac_action, + old_hvac_action, + ) + + _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 + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, + ] + if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: + self._underlying_climate_start_hvac_action_date = ( + self.get_last_updated_date_or_now(new_state) + ) + _LOGGER.info( + "%s - underlying just switch ON. Set power and energy start date %s", + self, + self._underlying_climate_start_hvac_action_date.isoformat(), + ) + changes = True + + if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: + stop_power_date = self.get_last_updated_date_or_now(new_state) + if self._underlying_climate_start_hvac_action_date: + delta = ( + stop_power_date - self._underlying_climate_start_hvac_action_date + ) + self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 + + # increment energy at the end of the cycle + self.incremente_energy() + + self._underlying_climate_start_hvac_action_date = None + + _LOGGER.info( + "%s - underlying just switch OFF at %s. delta_h=%.3f h", + self, + stop_power_date.isoformat(), + self._underlying_climate_delta_t, + ) + 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 in underlying have change to %s", + self, + new_target_temp, + ) + await self.async_set_temperature(temperature=new_target_temp) + changes = True + + await end_climate_changed(changes) + + def update_custom_attributes(self): + """ Custom attributes """ + super().update_custom_attributes() + self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent + self._attr_extra_state_attributes["is_over_valve"] = self.is_over_valve + self._attr_extra_state_attributes["underlying_valve_0"] = ( + self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_valve_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_valve_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_valve_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode == HVACMode.COOL, + ) + + for under in self._underlyings: + under.set_valve_open_percent( + self._prop_algorithm.on_percent + ) + + self.update_custom_attributes() + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 263e00c1..93dae608 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -310,7 +310,8 @@ "thermostat_type": { "options": { "thermostat_over_switch": "Thermostat over a switch", - "thermostat_over_climate": "Thermostat over another thermostat" + "thermostat_over_climate": "Thermostat over another thermostat", + "thermostat_over_valve": "Thermostat over a valve" } } }, diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 2334f2d4..b2b23f7f 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -34,7 +34,11 @@ "climate_entity2_id": "2ème thermostat sous-jacent", "climate_entity3_id": "3ème thermostat sous-jacent", "climate_entity4_id": "4ème thermostat sous-jacent", - "ac_mode": "AC mode ?" + "ac_mode": "AC mode ?", + "valve_entity_id": "1ère valve number", + "valve_entity2_id": "2ème valve number", + "valve_entity3_id": "3ème valve number", + "valve_entity4_id": "4ème valve number" }, "data_description": { "heater_entity_id": "Entity id du 1er radiateur obligatoire", @@ -46,7 +50,11 @@ "climate_entity2_id": "Entity id du 2ème thermostat sous-jacent", "climate_entity3_id": "Entity id du 3ème thermostat sous-jacent", "climate_entity4_id": "Entity id du 4ème thermostat sous-jacent", - "ac_mode": "Utilisation du mode Air Conditionné (AC)" + "ac_mode": "Utilisation du mode Air Conditionné (AC)", + "valve_entity_id": "Entity id de la 1ère valve", + "valve_entity2_id": "Entity id de la 2ème valve", + "valve_entity3_id": "Entity id de la 3ème valve", + "valve_entity4_id": "Entity id de la 4ème valve" } }, "tpi": { @@ -188,7 +196,11 @@ "climate_entity2_id": "2ème thermostat sous-jacent", "climate_entity3_id": "3ème thermostat sous-jacent", "climate_entity4_id": "4ème thermostat sous-jacent", - "ac_mode": "AC mode ?" + "ac_mode": "AC mode ?", + "valve_entity_id": "1ère valve number", + "valve_entity2_id": "2ème valve number", + "valve_entity3_id": "3ème valve number", + "valve_entity4_id": "4ème valve number" }, "data_description": { "heater_entity_id": "Entity id du 1er radiateur obligatoire", @@ -200,7 +212,11 @@ "climate_entity2_id": "Entity id du 2ème thermostat sous-jacent", "climate_entity3_id": "Entity id du 3ème thermostat sous-jacent", "climate_entity4_id": "Entity id du 4ème thermostat sous-jacent", - "ac_mode": "Utilisation du mode Air Conditionné (AC)" + "ac_mode": "Utilisation du mode Air Conditionné (AC)", + "valve_entity_id": "Entity id de la 1ère valve", + "valve_entity2_id": "Entity id de la 2ème valve", + "valve_entity3_id": "Entity id de la 3ème valve", + "valve_entity4_id": "Entity id de la 4ème valve" } }, "tpi": { @@ -311,7 +327,8 @@ "thermostat_type": { "options": { "thermostat_over_switch": "Thermostat sur un switch", - "thermostat_over_climate": "Thermostat sur un autre thermostat" + "thermostat_over_climate": "Thermostat sur un autre thermostat", + "thermostat_over_valve": "Thermostat sur une valve" } } }, diff --git a/custom_components/versatile_thermostat/translations/it.json b/custom_components/versatile_thermostat/translations/it.json index f8a19de5..e7f52301 100644 --- a/custom_components/versatile_thermostat/translations/it.json +++ b/custom_components/versatile_thermostat/translations/it.json @@ -34,7 +34,11 @@ "climate_entity2_id": "Secundo termostato sottostante", "climate_entity3_id": "Terzo termostato sottostante", "climate_entity4_id": "Quarto termostato sottostante", - "ac_mode": "AC mode ?" + "ac_mode": "AC mode ?", + "valve_entity_id": "Primo valvola numero", + "valve_entity2_id": "Secondo valvola numero", + "valve_entity3_id": "Terzo valvola numero", + "valve_entity4_id": "Quarto valvola numero" }, "data_description": { "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", @@ -46,7 +50,11 @@ "climate_entity2_id": "Entity id del secundo termostato sottostante", "climate_entity3_id": "Entity id del terzo termostato sottostante", "climate_entity4_id": "Entity id del quarto termostato sottostante", - "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?" + "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?", + "valve_entity_id": "Entity id del primo valvola numero", + "valve_entity2_id": "Entity id del secondo valvola numero", + "valve_entity3_id": "Entity id del terzo valvola numero", + "valve_entity4_id": "Entity id del quarto valvola numero" } }, "tpi": { @@ -169,18 +177,22 @@ }, "type": { "title": "Entità collegate", - "description": "Attributi delle entità collegate", + "description": "Parametri entità collegate", "data": { - "heater_entity_id": "Interruttore riscaldatore", - "heater_entity2_id": "Secondo interruttore riscaldatore", - "heater_entity3_id": "Terzo interruttore riscaldatore", - "heater_entity4_id": "Quarto interruttore riscaldatore", + "heater_entity_id": "Primo riscaldatore", + "heater_entity2_id": "Secondo riscaldatore", + "heater_entity3_id": "Terzo riscaldatore", + "heater_entity4_id": "Quarto riscaldatore", "proportional_function": "Algoritmo", "climate_entity_id": "Termostato sottostante", "climate_entity2_id": "Secundo termostato sottostante", "climate_entity3_id": "Terzo termostato sottostante", "climate_entity4_id": "Quarto termostato sottostante", - "ac_mode": "AC mode ?" + "ac_mode": "AC mode ?", + "valve_entity_id": "Primo valvola numero", + "valve_entity2_id": "Secondo valvola numero", + "valve_entity3_id": "Terzo valvola numero", + "valve_entity4_id": "Quarto valvola numero" }, "data_description": { "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", @@ -192,7 +204,11 @@ "climate_entity2_id": "Entity id del secundo termostato sottostante", "climate_entity3_id": "Entity id del terzo termostato sottostante", "climate_entity4_id": "Entity id del quarto termostato sottostante", - "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?" + "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?", + "valve_entity_id": "Entity id del primo valvola numero", + "valve_entity2_id": "Entity id del secondo valvola numero", + "valve_entity3_id": "Entity id del terzo valvola numero", + "valve_entity4_id": "Entity id del quarto valvola numero" } }, "tpi": { @@ -296,7 +312,8 @@ "thermostat_type": { "options": { "thermostat_over_switch": "Termostato su un interruttore", - "thermostat_over_climate": "Termostato sopra un altro termostato" + "thermostat_over_climate": "Termostato sopra un altro termostato", + "thermostat_over_valve": "Thermostato su una valvola" } } }, diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index b8580a2e..0526529a 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -1,3 +1,5 @@ +# pylint: disable=unused-argument, line-too-long + """ Underlying entities classes """ import logging from typing import Any @@ -22,10 +24,13 @@ SERVICE_TURN_ON, SERVICE_SET_TEMPERATURE, ) + +from homeassistant.components.number import SERVICE_SET_VALUE + from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later -from .const import UnknownEntity +from .const import UnknownEntity, overrides _LOGGER = logging.getLogger(__name__) @@ -42,6 +47,9 @@ class UnderlyingEntityType(StrEnum): # a climate CLIMATE = "climate" + # a valve + VALVE = "valve" + class UnderlyingEntity: """Represent a underlying device which could be a switch or a climate""" @@ -140,7 +148,7 @@ async def check_initial_state(self, hvac_mode: HVACMode): self._entity_id, ) await self.set_hvac_mode(hvac_mode) - elif hvac_mode != HVACMode.OFF and self.is_device_active: + elif hvac_mode != HVACMode.OFF and not self.is_device_active: _LOGGER.warning( "%s - The hvac mode is ON, but the underlying device is not ON. Turning on device %s", self, @@ -155,6 +163,19 @@ def call_later( """Call the method after a delay""" return async_call_later(hass, delay_sec, called_method) + async def start_cycle( + self, + hvac_mode: HVACMode, + on_time_sec: int, + off_time_sec: int, + on_percent: int, + force=False, + ): + """Starting cycle for switch""" + + def _cancel_cycle(self): + """ Stops an eventual cycle """ + class UnderlyingSwitch(UnderlyingEntity): """Represent a underlying switch""" @@ -210,11 +231,13 @@ def is_device_active(self): """If the toggleable device is currently active.""" return self._hass.states.is_state(self._entity_id, STATE_ON) + @overrides async def start_cycle( self, hvac_mode: HVACMode, on_time_sec: int, off_time_sec: int, + on_percent: int, force=False, ): """Starting cycle for switch""" @@ -265,6 +288,7 @@ async def start_cycle( else: _LOGGER.debug("%s - nothing to do", self) + @overrides def _cancel_cycle(self): """Cancel the cycle""" if self._async_cancel_cycle: @@ -298,15 +322,6 @@ async def _turn_on_later(self, _): time = self._on_time_sec action_label = "start" - # if self._should_relaunch_control_heating: - # _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label) - # self._should_relaunch_control_heating = False - # # self.hass.create_task(self._async_control_heating()) - # await self.start_cycle( - # self._hvac_mode, self._on_time_sec, self._off_time_sec - # ) - # _LOGGER.debug("%s - End of cycle (3)", self) - # return if time > 0: _LOGGER.info( @@ -343,16 +358,6 @@ async def _turn_off_later(self, _): return action_label = "stop" - # if self._should_relaunch_control_heating: - # _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label) - # self._should_relaunch_control_heating = False - # # self.hass.create_task(self._async_control_heating()) - # await self.start_cycle( - # self._hvac_mode, self._on_time_sec, self._off_time_sec - # ) - # _LOGGER.debug("%s - End of cycle (3)", self) - # return - time = self._off_time_sec if time > 0: @@ -626,3 +631,107 @@ def turn_aux_heat_off(self) -> None: if not self.is_initialized: return None return self._underlying_climate.turn_aux_heat_off() + +class UnderlyingValve(UnderlyingEntity): + """Represent a underlying switch""" + + _hvac_mode: HVACMode + # This is the percentage of opening int integer (from 0 to 100) + _percent_open: int + + def __init__( + self, + hass: HomeAssistant, + thermostat: Any, + valve_entity_id: str + ) -> None: + """Initialize the underlying switch""" + + super().__init__( + hass=hass, + thermostat=thermostat, + entity_type=UnderlyingEntityType.VALVE, + entity_id=valve_entity_id, + ) + self._async_cancel_cycle = None + self._should_relaunch_control_heating = False + self._hvac_mode = None + self._percent_open = self._thermostat.valve_open_percent + + async def send_percent_open(self): + """ Send the percent open to the underlying valve """ + # This may fails if called after shutdown + try: + data = { ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open } + domain = self._entity_id.split('.')[0] + await self._hass.services.async_call( + domain, + SERVICE_SET_VALUE, + data, + ) + except ServiceNotFound as err: + _LOGGER.error(err) + + async def turn_off(self): + """Turn heater toggleable device off.""" + _LOGGER.debug("%s - Stopping underlying valve entity %s", self, self._entity_id) + self._percent_open = 0 + if self.is_device_active: + await self.send_percent_open() + + async def turn_on(self): + """Nothing to do for Valve because it cannot be turned off""" + + async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: + """Set the HVACmode. Returns true if something have change""" + + if hvac_mode == HVACMode.OFF: + await self.turn_off() + + if self._hvac_mode != hvac_mode: + self._hvac_mode = hvac_mode + return True + else: + return False + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + try: + return self._percent_open > 0 + # To test if real device is open but this is causing some side effect + # because the activation can be deferred - + # or float(self._hass.states.get(self._entity_id).state) > 0 + except Exception: # pylint: disable=broad-exception-caught + return False + + @overrides + async def start_cycle( + self, + hvac_mode: HVACMode, + _1, + _2, + _3, + force=False, + ): + """We use this function to change the on_percent""" + if force: + await self.send_percent_open() + + def set_valve_open_percent(self, percent): + """ Update the valve open percent """ + caped_val = self._thermostat.valve_open_percent + if self._percent_open == caped_val: + # No changes + return + + self._percent_open = caped_val + # Send the new command to valve via a service call + + _LOGGER.info("%s - Setting valve ouverture percent to %s", self, self._percent_open) + # Send the change to the valve, in background + self._hass.create_task(self.send_percent_open()) + + def remove_entity(self): + """Remove the entity after stopping its cycle""" + self._cancel_cycle() diff --git a/images/config-linked-entity3.png b/images/config-linked-entity3.png new file mode 100644 index 0000000000000000000000000000000000000000..fe6707b6418afdbc8d5e2485a1bb9d45ac086712 GIT binary patch literal 26303 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 literal 0 HcmV?d00001 diff --git a/images/config-main.png b/images/config-main.png index 88b94a612b54618a5efd300c1f755f935c66b528..87e7ad1df255d07e4ac2a50cc1bd217a2cbba9fe 100644 GIT binary patch literal 40961 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�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 XAfv^H_1=ytfx{iQZ)b_ip~OD{yL4@o diff --git a/tests/commons.py b/tests/commons.py index c7d721cb..adc20470 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -1,3 +1,5 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + """ Some common resources """ import asyncio import logging @@ -20,7 +22,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.versatile_thermostat.climate import VersatileThermostat +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat 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 @@ -219,10 +221,10 @@ def supported_features(self): # pylint: disable=missing-function-docstring async def create_thermostat( hass: HomeAssistant, entry: MockConfigEntry, entity_id: str -) -> VersatileThermostat: +) -> BaseThermostat: """Creates and return a TPI Thermostat""" with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -248,7 +250,7 @@ def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity: async def send_temperature_change_event( - entity: VersatileThermostat, new_temp, date, sleep=True + entity: BaseThermostat, new_temp, date, sleep=True ): """Sending a new temperature event simulating a change on temperature sensor""" _LOGGER.info( @@ -274,7 +276,7 @@ async def send_temperature_change_event( async def send_ext_temperature_change_event( - entity: VersatileThermostat, new_temp, date, sleep=True + entity: BaseThermostat, new_temp, date, sleep=True ): """Sending a new external temperature event simulating a change on temperature sensor""" _LOGGER.info( @@ -300,7 +302,7 @@ async def send_ext_temperature_change_event( async def send_power_change_event( - entity: VersatileThermostat, new_power, date, sleep=True + entity: BaseThermostat, new_power, date, sleep=True ): """Sending a new power event simulating a change on power sensor""" _LOGGER.info( @@ -326,7 +328,7 @@ async def send_power_change_event( async def send_max_power_change_event( - entity: VersatileThermostat, new_power_max, date, sleep=True + entity: BaseThermostat, new_power_max, date, sleep=True ): """Sending a new power max event simulating a change on power max sensor""" _LOGGER.info( @@ -352,7 +354,7 @@ async def send_max_power_change_event( async def send_window_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True + entity: BaseThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new window event simulating a change on the window state""" _LOGGER.info( @@ -386,7 +388,7 @@ async def send_window_change_event( async def send_motion_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True + entity: BaseThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new motion event simulating a change on the window state""" _LOGGER.info( @@ -420,7 +422,7 @@ async def send_motion_change_event( async def send_presence_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True + entity: BaseThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new presence event simulating a change on the window state""" _LOGGER.info( @@ -460,7 +462,7 @@ def get_tz(hass: HomeAssistant): async def send_climate_change_event( - entity: VersatileThermostat, + entity: BaseThermostat, new_hvac_mode: HVACMode, old_hvac_mode: HVACMode, new_hvac_action: HVACAction, @@ -503,7 +505,7 @@ async def send_climate_change_event( return ret async def send_climate_change_event_with_temperature( - entity: VersatileThermostat, + entity: BaseThermostat, new_hvac_mode: HVACMode, old_hvac_mode: HVACMode, new_hvac_action: HVACAction, @@ -548,9 +550,9 @@ async def send_climate_change_event_with_temperature( return ret -def cancel_switchs_cycles(entity: VersatileThermostat): +def cancel_switchs_cycles(entity: BaseThermostat): """This method will cancel all running cycle on all underlying switch entity""" - if entity._is_over_climate: + if entity.is_over_climate: return for under in entity._underlyings: under._cancel_cycle() diff --git a/tests/conftest.py b/tests/conftest.py index 2260dfd9..4e5aa59c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,9 +26,7 @@ VersatileThermostatBaseConfigFlow, ) -from custom_components.versatile_thermostat.climate import ( - VersatileThermostat, -) +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat pytest_plugins = "pytest_homeassistant_custom_component" # pylint: disable=invalid-name @@ -84,7 +82,7 @@ def skip_hass_states_get_fixture(): def skip_control_heating_fixture(): """Skip the control_heating of VersatileThermostat""" with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): yield @@ -107,6 +105,6 @@ def skip_hass_states_is_state_fixture(): @pytest.fixture(name="skip_send_event") def skip_send_event_fixture(): - """Skip the send_event in VersatileThermostat""" - with patch.object(VersatileThermostat, "send_event"): + """Skip the send_event in BaseThermostat""" + with patch.object(BaseThermostat, "send_event"): yield diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index c9f8d53c..7a5d834e 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -1,3 +1,5 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, unused-argument, line-too-long + """ Test the normal start of a Thermostat """ from unittest.mock import patch from datetime import timedelta, datetime @@ -9,7 +11,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.versatile_thermostat.climate import VersatileThermostat +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat from custom_components.versatile_thermostat.binary_sensor import ( SecurityBinarySensor, OverpoweringBinarySensor, @@ -18,7 +20,7 @@ PresenceBinarySensor, ) -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import * @pytest.mark.parametrize("expected_lingering_tasks", [True]) @@ -60,7 +62,7 @@ async def test_security_binary_sensors( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat ( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -141,7 +143,7 @@ async def test_overpowering_binary_sensors( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -223,7 +225,7 @@ async def test_window_binary_sensors( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -311,7 +313,7 @@ async def test_motion_binary_sensors( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -401,7 +403,7 @@ async def test_presence_binary_sensors( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -483,7 +485,7 @@ async def test_binary_sensors_over_climate_minimal( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverclimatemockname" ) assert entity diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 09fcb0a1..630f391b 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -1,10 +1,12 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + """ Test the Window management """ from unittest.mock import patch, call -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime, timedelta import logging +from .commons import * logging.getLogger().setLevel(logging.DEBUG) @@ -49,7 +51,7 @@ async def test_bug_56( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverclimatemockname" ) assert entity @@ -60,9 +62,9 @@ async def test_bug_56( # Should not failed entity.update_custom_attributes() - # try to call _async_control_heating + # try to call async_control_heating try: - ret = await entity._async_control_heating() + ret = await entity.async_control_heating() # an exception should be send assert ret is False except Exception: # pylint: disable=broad-exception-caught @@ -73,9 +75,9 @@ async def test_bug_56( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, # dont find the underlying climate ): - # try to call _async_control_heating + # try to call async_control_heating try: - await entity._async_control_heating() + await entity.async_control_heating() except UnknownEntity: assert False except Exception: # pylint: disable=broad-exception-caught @@ -126,7 +128,7 @@ async def test_bug_63( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -178,7 +180,7 @@ async def test_bug_64( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -230,7 +232,7 @@ async def test_bug_66( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -245,7 +247,7 @@ async def test_bug_66( # Open the window and let the thermostat shut down with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -273,7 +275,7 @@ async def test_bug_66( # Close the window but too shortly with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -296,7 +298,7 @@ async def test_bug_66( # Reopen immediatly with sufficient time with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -319,7 +321,7 @@ async def test_bug_66( # Close the window but with sufficient time this time with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -366,7 +368,7 @@ async def test_bug_82( fake_underlying_climate = MockUnavailableClimate(hass, "mockUniqueId", "MockClimateName", {}) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, @@ -387,7 +389,7 @@ def find_my_entity(entity_id) -> ClimateEntity: assert entity assert entity.name == "TheOverClimateMockName" - assert entity._is_over_climate is True + 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 @@ -431,10 +433,10 @@ def find_my_entity(entity_id) -> ClimateEntity: # 2. activate security feature when date is expired with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on: + ): event_timestamp = now - timedelta(minutes=6) # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) @@ -468,7 +470,7 @@ async def test_bug_101( fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, @@ -491,7 +493,7 @@ def find_my_entity(entity_id) -> ClimateEntity: assert entity assert entity.name == "TheOverClimateMockName" - assert entity._is_over_climate is True + assert entity.is_over_climate is True 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 @@ -540,6 +542,3 @@ def find_my_entity(entity_id) -> ClimateEntity: 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 - - - diff --git a/tests/test_movement.py b/tests/test_movement.py index d95ae93f..658e6989 100644 --- a/tests/test_movement.py +++ b/tests/test_movement.py @@ -64,7 +64,7 @@ async def test_movement_management_time_not_enough( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_ACTIVITY) @@ -85,7 +85,7 @@ async def test_movement_management_time_not_enough( # starts detecting motion with time not enough with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -118,7 +118,7 @@ async def test_movement_management_time_not_enough( # starts detecting motion with time enough this time with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -144,7 +144,7 @@ async def test_movement_management_time_not_enough( # stop detecting motion with off delay too low with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -176,7 +176,7 @@ async def test_movement_management_time_not_enough( # stop detecting motion with off delay enough long with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -261,7 +261,7 @@ async def test_movement_management_time_enough_and_presence( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_ACTIVITY) @@ -282,7 +282,7 @@ async def test_movement_management_time_enough_and_presence( # starts detecting motion with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -311,7 +311,7 @@ async def test_movement_management_time_enough_and_presence( # stop detecting motion with confirmation of stop with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -393,7 +393,7 @@ async def test_movement_management_time_enoughand_not_presence( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_ACTIVITY) @@ -414,7 +414,7 @@ async def test_movement_management_time_enoughand_not_presence( # starts detecting motion with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -443,7 +443,7 @@ async def test_movement_management_time_enoughand_not_presence( # stop detecting motion with confirmation of stop with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -527,7 +527,7 @@ async def test_movement_management_with_stop_during_condition( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_ACTIVITY) @@ -548,7 +548,7 @@ async def test_movement_management_with_stop_during_condition( # starts detecting motion with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index 09408d21..89668f9b 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -58,7 +58,7 @@ async def test_one_switch_cycle( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_BOOST) @@ -75,14 +75,14 @@ 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 # pylint: disable=protected-access + assert entity.is_device_active is False # pylint: disable=protected-access # Should be call for the Switch assert mock_is_state.call_count == 1 # Set temperature to a low level with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -117,7 +117,7 @@ async def test_one_switch_cycle( # Set a temperature at middle level event_timestamp = now - timedelta(minutes=4) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -139,7 +139,7 @@ async def test_one_switch_cycle( # Set another temperature at middle level event_timestamp = now - timedelta(minutes=3) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -172,7 +172,7 @@ async def test_one_switch_cycle( # Simulate the end of heater on cycle event_timestamp = now - timedelta(minutes=3) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -195,7 +195,7 @@ async def test_one_switch_cycle( # Simulate the start of heater on cycle event_timestamp = now - timedelta(minutes=3) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -269,7 +269,7 @@ async def test_multiple_switchs( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.set_hvac_mode" ) as mock_underlying_set_hvac_mode: @@ -285,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 # pylint: disable=protected-access + 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 @@ -297,7 +297,7 @@ async def test_multiple_switchs( # Set temperature to a low level with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -335,7 +335,7 @@ async def test_multiple_switchs( # Set a temperature at middle level event_timestamp = now - timedelta(minutes=4) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -405,7 +405,7 @@ async def test_multiple_climates( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode: @@ -427,11 +427,11 @@ async def test_multiple_climates( call.set_hvac_mode(HVACMode.HEAT), ] ) - assert entity._is_device_active is False # pylint: disable=protected-access + 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode: @@ -452,7 +452,7 @@ async def test_multiple_climates( call.set_hvac_mode(HVACMode.OFF), ] ) - assert entity._is_device_active is False # pylint: disable=protected-access + assert entity.is_device_active is False # pylint: disable=protected-access @pytest.mark.parametrize("expected_lingering_tasks", [True]) @@ -505,7 +505,7 @@ async def test_multiple_climates_underlying_changes( # 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode: @@ -527,11 +527,11 @@ async def test_multiple_climates_underlying_changes( call.set_hvac_mode(HVACMode.HEAT), ] ) - assert entity._is_device_active is False # pylint: disable=protected-access + 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode: @@ -554,11 +554,11 @@ async def test_multiple_climates_underlying_changes( ] ) assert entity.hvac_mode == HVACMode.OFF - assert entity._is_device_active is False # pylint: disable=protected-access + 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" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ), patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" ) as mock_underlying_set_hvac_mode, patch( @@ -587,4 +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 # pylint: disable=protected-access + assert entity.is_device_active is False # pylint: disable=protected-access diff --git a/tests/test_power.py b/tests/test_power.py index a59a612f..ace86038 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -81,7 +81,7 @@ async def test_power_management_hvac_off( # Send power max mesurement too low but HVACMode is off with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -162,7 +162,7 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is # Send power max mesurement too low and HVACMode is on with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -196,7 +196,7 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is # Send power mesurement low to unseet power preset with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -282,7 +282,7 @@ async def test_power_management_energy_over_switch( # set temperature to 15 so that on_percent will be set with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -311,7 +311,7 @@ async def test_power_management_energy_over_switch( # change temperature to a higher value with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -333,7 +333,7 @@ async def test_power_management_energy_over_switch( # change temperature to a much higher value so that heater will be shut down with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( diff --git a/tests/test_security.py b/tests/test_security.py index 1b6e1262..a13bc122 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -87,7 +87,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # 2. activate security feature when date is expired with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: @@ -134,7 +134,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # 3. Change the preset to Boost (we should stay in SECURITY) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: @@ -149,7 +149,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # 5. resolve the datetime issue with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: @@ -214,7 +214,7 @@ async def test_security_over_climate( fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, @@ -235,7 +235,7 @@ def find_my_entity(entity_id) -> ClimateEntity: assert entity assert entity.name == "TheOverClimateMockName" - assert entity._is_over_climate is True + assert entity.is_over_climate is True # Because the underlying is HEATING. In real life the underlying will be shut-off assert entity.hvac_action is HVACAction.HEATING @@ -292,7 +292,7 @@ def find_my_entity(entity_id) -> ClimateEntity: # 2. activate security feature when date is expired with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 41b58b73..c6467efd 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -1,3 +1,5 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + """ Test the normal start of a Thermostat """ from datetime import timedelta, datetime @@ -12,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.versatile_thermostat.climate import VersatileThermostat +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat from custom_components.versatile_thermostat.sensor import ( EnergySensor, MeanPowerSensor, @@ -66,7 +68,7 @@ async def test_sensors_over_switch( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -229,7 +231,7 @@ async def test_sensors_over_climate( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverclimatemockname" ) assert entity @@ -361,7 +363,7 @@ async def test_sensors_over_climate_minimal( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverclimatemockname" ) assert entity diff --git a/tests/test_start.py b/tests/test_start.py index 450dac76..819ad309 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -1,3 +1,5 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + """ Test the normal start of a Thermostat """ from unittest.mock import patch, call @@ -10,7 +12,9 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.versatile_thermostat.climate import VersatileThermostat +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate +from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -28,7 +32,7 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s ) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -41,12 +45,13 @@ def find_my_entity(entity_id) -> ClimateEntity: if entity.entity_id == entity_id: return entity - entity: VersatileThermostat = find_my_entity("climate.theoverswitchmockname") + entity: BaseThermostat = find_my_entity("climate.theoverswitchmockname") assert entity + assert isinstance(entity, ThermostatOverSwitch) assert entity.name == "TheOverSwitchMockName" - assert entity._is_over_climate is False + assert entity.is_over_climate is False assert entity.hvac_action is HVACAction.OFF assert entity.hvac_mode is HVACMode.OFF assert entity.target_temperature == entity.min_temp @@ -93,7 +98,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, @@ -112,9 +117,10 @@ def find_my_entity(entity_id) -> ClimateEntity: entity = find_my_entity("climate.theoverclimatemockname") assert entity + assert isinstance(entity, ThermostatOverClimate) assert entity.name == "TheOverClimateMockName" - assert entity._is_over_climate is True + assert entity.is_over_climate is True assert entity.hvac_action is HVACAction.OFF assert entity.hvac_mode is HVACMode.OFF assert entity.target_temperature == entity.min_temp @@ -160,7 +166,7 @@ async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_ ) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -173,12 +179,12 @@ def find_my_entity(entity_id) -> ClimateEntity: if entity.entity_id == entity_id: return entity - entity: VersatileThermostat = find_my_entity("climate.theover4switchmockname") + entity: BaseThermostat = find_my_entity("climate.theover4switchmockname") assert entity assert entity.name == "TheOver4SwitchMockName" - assert entity._is_over_climate is False + assert entity.is_over_climate is False assert entity.hvac_action is HVACAction.OFF assert entity.hvac_mode is HVACMode.OFF assert entity.target_temperature == entity.min_temp diff --git a/tests/test_switch_ac.py b/tests/test_switch_ac.py index e2633527..86082055 100644 --- a/tests/test_switch_ac.py +++ b/tests/test_switch_ac.py @@ -11,7 +11,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.versatile_thermostat.climate import VersatileThermostat +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -31,7 +32,7 @@ async def test_over_switch_ac_full_start(hass: HomeAssistant, skip_hass_states_i now: datetime = datetime.now(tz=tz) with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -45,12 +46,13 @@ def find_my_entity(entity_id) -> ClimateEntity: return entity # The name is in the CONF and not the title of the entry - entity: VersatileThermostat = find_my_entity("climate.theoverswitchmockname") + entity: BaseThermostat = find_my_entity("climate.theoverswitchmockname") assert entity + assert isinstance(entity, ThermostatOverSwitch) assert entity.name == "TheOverSwitchMockName" - assert entity._is_over_climate is False # pylint: disable=protected-access + 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 @@ -136,5 +138,3 @@ def find_my_entity(entity_id) -> ClimateEntity: 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/tests/test_valve.py b/tests/test_valve.py new file mode 100644 index 00000000..e3a0a7b7 --- /dev/null +++ b/tests/test_valve.py @@ -0,0 +1,259 @@ +# pylint: disable=line-too-long + +""" 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.thermostat_valve import ThermostatOverValve + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_valve_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="TheOverValveMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverValveMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_VALVE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_VALVE: "number.mock_valve", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + PRESET_ECO + "_temp": 17, + PRESET_COMFORT + "_temp": 19, + PRESET_BOOST + "_temp": 21, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: True, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: True, + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MOTION_SENSOR: "input_boolean.motion_sensor", + CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", + CONF_WINDOW_DELAY: 10, + CONF_MOTION_DELAY: 10, + CONF_MOTION_OFF_DELAY: 30, + CONF_MOTION_PRESET: PRESET_COMFORT, + CONF_NO_MOTION_PRESET: PRESET_ECO, + CONF_POWER_SENSOR: "sensor.power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor", + CONF_PRESENCE_SENSOR: "person.presence_sensor", + PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 17.1, + PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17.2, + PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 17.3, + CONF_PRESET_POWER: 10, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_DEVICE_POWER: 100, + CONF_AC_MODE: False + }, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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: ThermostatOverValve = find_my_entity("climate.theovervalvemockname") + + assert entity + assert isinstance(entity, ThermostatOverValve) + + assert entity.name == "TheOverValveMockName" + assert entity.is_over_climate is False + assert entity.is_over_switch is False + assert entity.is_over_valve is True + assert entity.ac_mode is False + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_modes == [HVACMode.HEAT, HVACMode.OFF] + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + PRESET_ACTIVITY, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False # 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}, + ), + ] + ) + + # Set the HVACMode to HEAT, with manual preset and target_temp to 18 before receiving temperature + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.HEAT) + # + assert entity.hvac_mode is HVACMode.HEAT + # No heating now + assert entity.valve_open_percent == 0 + assert entity.hvac_action == HVACAction.IDLE + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.HEAT}, + ), + ] + ) + + # set manual target temp + await entity.async_set_temperature(temperature=18) + assert entity.preset_mode == PRESET_NONE # Manual mode + assert entity.target_temperature == 18 + # Nothing have changed cause we don't have room and external temperature + assert mock_send_event.call_count == 1 + + + # Set temperature and external temperature + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.core.ServiceRegistry.async_call" + ) as mock_service_call, patch( + "homeassistant.core.StateMachine.get", return_value=State(entity_id="number.mock_valve", state="90") + ): + # Change temperature + event_timestamp = now - timedelta(minutes=10) + await send_temperature_change_event(entity, 15, datetime.now()) + assert entity.valve_open_percent == 90 + await send_ext_temperature_change_event(entity, 10, datetime.now()) + # Should heating strongly now + assert entity.valve_open_percent == 98 + assert entity.is_device_active is True + assert entity.hvac_action == HVACAction.HEATING + + assert mock_service_call.call_count == 2 + mock_service_call.assert_has_calls([ + call.async_call('number', 'set_value', {'entity_id': 'number.mock_valve', 'value': 90}), + call.async_call('number', 'set_value', {'entity_id': 'number.mock_valve', 'value': 98}) + ]) + + assert mock_send_event.call_count == 0 + + # Change to preset Comfort + await entity.async_set_preset_mode(preset_mode=PRESET_COMFORT) + assert entity.preset_mode == PRESET_COMFORT + assert entity.target_temperature == 17.2 + assert entity.valve_open_percent == 73 + assert entity.is_device_active is True + assert entity.hvac_action == HVACAction.HEATING + + # Change presence to on + 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 + assert entity.preset_mode is PRESET_COMFORT + assert entity.target_temperature == 19 + assert entity.valve_open_percent == 100 # Full heating + assert entity.is_device_active is True + assert entity.hvac_action == HVACAction.HEATING + + # Change internal temperature + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.core.ServiceRegistry.async_call" + ) as mock_service_call, patch( + "homeassistant.core.StateMachine.get", return_value=0 + ): + event_timestamp = now - timedelta(minutes=3) + await send_temperature_change_event(entity, 20, datetime.now()) + assert entity.valve_open_percent == 0 + assert entity.is_device_active is False + assert entity.hvac_action == HVACAction.IDLE + + + await send_temperature_change_event(entity, 17, datetime.now()) + # switch to Eco + await entity.async_set_preset_mode(PRESET_ECO) + assert entity.preset_mode is PRESET_ECO + assert entity.target_temperature == 17 + assert entity.valve_open_percent == 7 + + # Unset the presence + event_timestamp = now - timedelta(minutes=2) + await send_presence_change_event(entity, False, True, event_timestamp) + assert entity.presence_state == STATE_OFF # pylint: disable=protected-access + assert entity.valve_open_percent == 10 + assert entity.target_temperature == 17.1 # eco_away + assert entity.is_device_active is True + assert entity.hvac_action == HVACAction.HEATING + + # Open a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=1) + 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 == 17.1 # eco + assert entity.valve_open_percent == 0 + + # Close a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=0) + 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.HEAT + assert (entity.hvac_action is HVACAction.OFF or entity.hvac_action is HVACAction.IDLE) + assert entity.target_temperature == 17.1 # eco + assert entity.valve_open_percent == 10 diff --git a/tests/test_window.py b/tests/test_window.py index b59d4b48..6b513b0e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -66,7 +66,7 @@ async def test_window_management_time_not_enough( # Open the window, but condition of time is not satisfied and check the thermostat don't turns off with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -154,7 +154,7 @@ async def test_window_management_time_enough( # change temperature to force turning on the heater with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -172,7 +172,7 @@ async def test_window_management_time_enough( # Open the window, condition of time is satisfied, check the thermostat and heater turns off with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -200,7 +200,7 @@ async def test_window_management_time_enough( # Close the window with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -296,7 +296,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): # Make the temperature down with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -318,7 +318,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): # send one degre down in one minute with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -353,7 +353,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): # send another 0.1 degre in one minute -> no change with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -378,7 +378,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): # send another plus 1.1 degre in one minute -> restore state with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -480,7 +480,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st # Make the temperature down with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -501,7 +501,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st # send one degre down in one minute with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -539,7 +539,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st # Waits for automatic disable with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -625,7 +625,7 @@ async def test_window_auto_no_on_percent( # Make the temperature down with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( @@ -647,7 +647,7 @@ async def test_window_auto_no_on_percent( # send one degre down in one minute with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( From 5bb6c8bf51f5955ac053c0a302da3aa1952f9068 Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sat, 28 Oct 2023 21:49:26 +0200 Subject: [PATCH 5/9] Add case bypass is enable after hvac already off --- custom_components/versatile_thermostat/base_thermostat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 5c7468f4..eea78db1 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -2554,6 +2554,9 @@ async def service_set_window_bypass_state(self, window_bypass): _LOGGER.info("%s - Last window state was open & ByPass is now off. Set hvac_mode to '%s'", self, HVACMode.OFF) self.save_hvac_mode() await self.async_set_hvac_mode(HVACMode.OFF) + if self._window_bypass_state and self._window_state == STATE_ON: + _LOGGER.info("%s - Last window state was open & ByPass is now on. Set hvac_mode to last available mode", self) + await self.restore_hvac_mode(True) self.update_custom_attributes() def send_event(self, event_type: EventType, data: dict): From 6f9a089cd5c087716439e6ff9ce824e0a2fd6c08 Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sun, 29 Oct 2023 00:07:42 +0200 Subject: [PATCH 6/9] Modif Orthographe et icon --- README-fr.md | 12 ++++++------ .../versatile_thermostat/binary_sensor.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README-fr.md b/README-fr.md index 5ec9d774..cbd9a210 100644 --- a/README-fr.md +++ b/README-fr.md @@ -532,7 +532,7 @@ Ce service permet de forcer l'état de présence indépendamment du capteur de p Le code pour appeler ce service est le suivant : ``` -service : thermostat_polyvalent.set_presence +service : versatile_thermostat.set_presence Les données: présence : "off" cible: @@ -547,7 +547,7 @@ Vous pouvez modifier l'une ou les deux températures (lorsqu'elles sont présent Utilisez le code suivant pour régler la température du préréglage : ``` -service : thermostat_polyvalent.set_preset_temperature +service : versatile_thermostat.set_preset_temperature date: preset : boost temperature : 17,8 @@ -576,7 +576,7 @@ Si le thermostat est en mode ``security`` les nouveaux paramètres sont appliqu Pour changer les paramètres de sécurité utilisez le code suivant : ``` -service : thermostat_polyvalent.set_security +service : versatile_thermostat.set_security data: min_on_percent: "0.5" default_on_percent: "0.1" @@ -587,12 +587,12 @@ target: ## ByPass Window Check Ce service permet d'activer ou non un bypass de la vérification des fenetres. -Il permet de continuer à chauffer même si la fenetre est detecté ouverte. +Il permet de continuer à chauffer même si la fenetre est detectée ouverte. Mis à ``true`` les modifications de status de la fenetre n'auront plus d'effet sur le thermostat, remis à ``false`` cela s'assurera de désactiver le thermostat si la fenetre est toujours ouverte. -Pour changer le paramétre de bypass utilisez le code suivant : +Pour changer le paramètre de bypass utilisez le code suivant : ``` -service : thermostat_polyvalent.set_window_bypass +service : versatile_thermostat.set_window_bypass data: window_bypass: true target: diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index e14392ae..19c2e7b2 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -270,6 +270,6 @@ def device_class(self) -> BinarySensorDeviceClass | None: @property def icon(self) -> str | None: if self._attr_is_on: - return "mdi:check-circle-outline" + return "mdi:window-shutter-cog" else: - return "mdi:alpha-b-circle-outline" \ No newline at end of file + return "mdi:window-shutter-auto" \ No newline at end of file From d978a686edd80e610d8eed70a0cb08204d4621e7 Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sun, 29 Oct 2023 15:02:39 +0100 Subject: [PATCH 7/9] Modif debug=>info --- custom_components/versatile_thermostat/base_thermostat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index eea78db1..8dadb5cd 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1400,7 +1400,7 @@ async def try_window_condition(_): #PR - Adding Window ByPass _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) if self._window_bypass_state: - _LOGGER.debug("Window ByPass is activated. Ignore window event") + _LOGGER.info("Window ByPass is activated. Ignore window event") self.update_custom_attributes() return From c98475dcd1bee1feba108e689718f8bdf5f13674 Mon Sep 17 00:00:00 2001 From: AdrienM Date: Sun, 29 Oct 2023 23:52:28 +0100 Subject: [PATCH 8/9] Add test for window bypass --- tests/test_window.py | 136 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/tests/test_window.py b/tests/test_window.py index 6b513b0e..54c6f058 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -671,3 +671,139 @@ async def test_window_auto_no_on_percent( # Clean the entity entity.remove_thermostat() + +#PR - Adding Window Bypass +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_bypass( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Window management when bypass enabled""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tpi_algo = entity._prop_algorithm + assert tpi_algo + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.overpowering_state is None + assert entity.target_temperature == 19 + + assert entity.window_state is None + + # change temperature to force turning on the heater + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + await send_temperature_change_event(entity, 15, datetime.now()) + + # Heater shoud turn-on + assert mock_heater_on.call_count >= 1 + assert mock_heater_off.call_count == 0 + assert mock_send_event.call_count == 0 + + #Set Window ByPass to true + entity._window_bypass_state = True + + # Open the window, condition of time is satisfied, check the thermostat and heater turns off + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + await send_window_change_event(entity, True, False, datetime.now()) + + #assert mock_send_event.call_count == 1 + #mock_send_event.assert_has_calls( + # [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] + #) + + # Heater should not be on + assert mock_heater_on.call_count == 0 + # One call in set_hvac_mode turn_off and one call in the control_heating for security + assert mock_heater_off.call_count == 0 + assert mock_condition.call_count == 1 + assert entity.hvac_mode is HVACMode.HEAT + assert entity.window_state == STATE_ON + + # Close the window + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + try_function = await send_window_change_event( + entity, False, True, datetime.now(), sleep=False + ) + + await try_function(None) + + # Wait for initial delay of heater + await asyncio.sleep(0.3) + + assert entity.window_state == STATE_OFF + assert mock_heater_on.call_count == 0 + assert mock_send_event.call_count == 0 + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + + # Clean the entity + entity.remove_thermostat() \ No newline at end of file From 06da6508b07365895518dc8083f9389dca45ee1a Mon Sep 17 00:00:00 2001 From: AdrienM Date: Mon, 30 Oct 2023 00:06:14 +0100 Subject: [PATCH 9/9] Correct test --- tests/test_window.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_window.py b/tests/test_window.py index 54c6f058..64de8a6b 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -764,10 +764,7 @@ async def test_window_bypass( ): await send_window_change_event(entity, True, False, datetime.now()) - #assert mock_send_event.call_count == 1 - #mock_send_event.assert_has_calls( - # [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] - #) + assert mock_send_event.call_count == 0 # Heater should not be on assert mock_heater_on.call_count == 0