diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5422c25..c3c0b24 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,7 +29,8 @@ "donjayamanne.githistory", "waderyan.gitblame", "keesschollaart.vscode-home-assistant", - "vscode.markdown-math" + "vscode.markdown-math", + "yzhang.markdown-all-in-one" ], // "mounts": [ // "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=${localWorkspaceFolder}/config/www/community/,type=bind,consistency=cached", diff --git a/.gitignore b/.gitignore index 67d03c4..e7e27b9 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,4 @@ __pycache__ config/** custom_components/hacs +custom_components/localtuya diff --git a/README-fr.md b/README-fr.md index a31154f..92c55e6 100644 --- a/README-fr.md +++ b/README-fr.md @@ -23,6 +23,7 @@ - [Pour un thermostat de type ```thermostat_over_climate```:](#pour-un-thermostat-de-type-thermostat_over_climate) - [L'auto-régulation](#lauto-régulation) - [L'auto-régulation en mode Expert](#lauto-régulation-en-mode-expert) + - [Le mode auto-fan](#le-mode-auto-fan) - [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) @@ -55,7 +56,7 @@ - [Bien mieux avec le Versatile Thermostat UI Card](#bien-mieux-avec-le-versatile-thermostat-ui-card) - [Encore mieux avec le composant Scheduler !](#encore-mieux-avec-le-composant-scheduler-) - [Encore bien mieux avec la custom:simple-thermostat front integration](#encore-bien-mieux-avec-la-customsimple-thermostat-front-integration) - - [Toujours mieux avec Apex-chart pour régler votre thermostat](#toujours-mieux-avec-apex-chart-pour-régler-votre-thermostat) + - [Toujours mieux avec Plotly pour régler votre thermostat](#toujours-mieux-avec-plotly-pour-régler-votre-thermostat) - [Et toujours de mieux en mieux avec l'AappDaemon NOTIFIER pour notifier les évènements](#et-toujours-de-mieux-en-mieux-avec-laappdaemon-notifier-pour-notifier-les-évènements) - [Les contributions sont les bienvenues !](#les-contributions-sont-les-bienvenues) - [Dépannages](#dépannages) @@ -68,6 +69,7 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une > ![Nouveau](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_ +> * **Release 4.3** : Ajout d'un mode auto-fan pour le type `over_climate` permettant d'activer la ventilation si l'écart de température est important [#223](https://github.com/jmcollin78/versatile_thermostat/issues/223). > * **Release 4.2** : Le calcul de la pente de la courbe de température se fait maintenant en °/heure et non plus en °/min [#242](https://github.com/jmcollin78/versatile_thermostat/issues/242). Correction de la détection automatique des ouvertures par l'ajout d'un lissage de la courbe de température . > * **Release 4.1** : Ajout d'un mode de régulation **Expert** dans lequel l'utilisateur peut spécifier ses propres paramètres d'auto-régulation au lieu d'utiliser les pre-programmés [#194](https://github.com/jmcollin78/versatile_thermostat/issues/194). > * **Release 4.0** : Ajout de la prise en charge de la **Versatile Thermostat UI Card**. Voir [Versatile Thermostat UI Card](https://github.com/jmcollin78/versatile-thermostat-ui-card). Ajout d'un mode de régulation **Slow** pour les appareils de chauffage à latence lente [#168](https://github.com/jmcollin78/versatile_thermostat/issues/168). Changement de la façon dont **la puissance est calculée** dans le cas de VTherm avec des équipements multi-sous-jacents [#146](https://github.com/jmcollin78/versatile_thermostat/issues/146). Ajout de la prise en charge de AC et Heat pour VTherm via un interrupteur également [#144](https://github.com/jmcollin78/versatile_thermostat/pull/144) @@ -315,6 +317,15 @@ et bien sur, configurer le mode auto-régulation du VTherm en mode Expert. Tous Pour que les modifications soient prises en compte, il faut soit **relancer totalement Home Assistant** soit juste l'intégration Versatile Thermostat (Outils de dev / Yaml / rechargement de la configuration / Versatile Thermostat). + +#### Le mode auto-fan +Ce mode introduit en 4.3 permet de forcer l'usage de la ventilation si l'écart de température est important. En effet, en activant la ventilation, la répartition se fait plus rapidement ce qui permet de gagner du temps dans l'atteinte de la température cible. +Vous pouvez choisir quelle ventilation vous voulez activer entre les paramètres suivants : Faible, Moyenne, Forte, Turbo. + +Il faut évidemment que votre équipement sous-jacent soit équipée d'une ventilation et quelle soit pilotable pour que cela fonctionne. +Si votre équipement ne comprend pas le mode Turbo, le mode Forte` sera utilisé en remplacement. +Une fois l'écart de température redevenu faible, la ventilation se mettra dans un mode "normal" qui dépend de votre équipement à savoir (dans l'ordre) : `Silence (mute)`, `Auto (auto)`, `Faible (low)`. La première valeur qui est possible pour votre équipement sera choisie. + ### 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. @@ -902,53 +913,73 @@ Vous pouvez personnaliser ce composant à l'aide du composant HACS card-mod pour ``` ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/custom-css-thermostat.png?raw=true) -## Toujours mieux avec Apex-chart pour régler votre thermostat -Vous pouvez obtenir une courbe comme celle présentée dans [some results](#some-results) avec une sorte de configuration de graphique Apex uniquement en utilisant les attributs personnalisés du thermostat décrits [ici](#custom-attributes) : +## Toujours mieux avec Plotly pour régler votre thermostat +Vous pouvez obtenir une courbe comme celle présentée dans [some results](#some-results) avec une sorte de configuration de graphique Plotly uniquement en utilisant les attributs personnalisés du thermostat décrits [ici](#custom-attributes) : +Remplacez les valeurs entre [[ ]] par les votres. ``` -type: custom:apexcharts-card -header: - show: true - title: Tuning chauffage - show_states: true - colorize_states: true -update_interval: 60sec -graph_span: 4h -yaxis: - - id: left - show: true - decimals: 2 - - id: right - decimals: 2 - show: true - opposite: true -series: - - entity: climate.thermostat_mythermostat - attribute: temperature - type: line - name: Target temp - curve: smooth - yaxis_id: left - - entity: climate.thermostat_mythermostat - attribute: current_temperature - name: Current temp - curve: smooth - yaxis_id: left - - entity: climate.thermostat_mythermostat <--- for over_switch - attribute: on_percent - name: Power percent - curve: stepline - yaxis_id: right - - entity: climate.thermostat_mythermostat <--- for over_thermostast - attribute: regulated_target_temperature - name: Regulated temperature - curve: stepline - yaxis_id: left - - entity: climate.thermostat_mythermostat <--- for over_valve - attribute: valve_open_percent - name: Valve open percent - curve: stepline - yaxis_id: right +- type: custom:plotly-graph + entities: + - entity: '[[climate]]' + attribute: temperature + yaxis: y1 + name: Consigne + - entity: '[[climate]]' + attribute: current_temperature + yaxis: y1 + name: T° + - entity: '[[climate]]' + attribute: ema_temp + yaxis: y1 + name: Ema + - entity: '[[climate]]' + attribute: regulated_target_temperature + yaxis: y1 + name: Regulated T° + - entity: '[[slope]]' + name: Slope + fill: tozeroy + yaxis: y9 + fillcolor: rgba(100, 100, 100, 0.3) + line: + color: rgba(100, 100, 100, 0.9) + hours_to_show: 4 + refresh_interval: 10 + height: 800 + config: + scrollZoom: true + layout: + margin: + r: 50 + legend: + x: 0 + 'y': 1.2 + groupclick: togglegroup + title: + side: top right + yaxis: + visible: true + position: 0 + yaxis9: + visible: true + fixedrange: false + range: + - -0.5 + - 0.5 + position: 1 + xaxis: + rangeselector: + 'y': 1.1 + x: 0.7 + buttons: + - count: 1 + step: hour + - count: 12 + step: hour + - count: 1 + step: day + - count: 7 + step: day ``` ## Et toujours de mieux en mieux avec l'AappDaemon NOTIFIER pour notifier les évènements diff --git a/README.md b/README.md index 0bf53c7..a0e3db9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [For a thermostat of type ```thermostat_over_climate```:](#for-a-thermostat-of-type-thermostat_over_climate) - [Self-regulation](#self-regulation) - [Self-regulation in Expert mode](#self-regulation-in-expert-mode) + - [Auto-fan mode](#auto-fan-mode) - [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) @@ -54,7 +55,7 @@ - [Much better with the Veersatile Thermostat UI Card](#much-better-with-the-veersatile-thermostat-ui-card) - [Even Better with Scheduler Component !](#even-better-with-scheduler-component-) - [Even-even better with custom:simple-thermostat front integration](#even-even-better-with-customsimple-thermostat-front-integration) - - [Even better with Apex-chart to tune your Thermostat](#even-better-with-apex-chart-to-tune-your-thermostat) + - [Even better with Plotly to tune your Thermostat](#even-better-with-plotly-to-tune-your-thermostat) - [And always better and better with the NOTIFIER daemon app to notify events](#and-always-better-and-better-with-the-notifier-daemon-app-to-notify-events) - [Contributions are welcome!](#contributions-are-welcome) - [Troubleshooting](#troubleshooting) @@ -67,7 +68,8 @@ 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 4.2**: The calculation of the slope of the temperature curve is now done in °/hour and no longer in °/min [#242](https://github.com/jmcollin78/versatile_thermostat/ issues/242). Correction of automatic detection of openings by adding smoothing of the temperature curve. +> * **Release 4.3**: Added an auto-fan mode for the `over_climate` type allowing ventilation to be activated if the temperature difference is significant [#223](https://github.com/jmcollin78/versatile_thermostat/issues/223). +> * **Release 4.2**: The calculation of the slope of the temperature curve is now done in °/hour and no longer in °/min [#242](https://github.com/jmcollin78/versatile_thermostat/issues/242). Correction of automatic detection of openings by adding smoothing of the temperature curve. > * **Release 4.1**: Added an **Expert** regulation mode in which the user can specify their own auto-regulation parameters instead of using the pre-programmed ones [#194]( https://github.com/jmcollin78/versatile_thermostat/issues/194). > * **Release 4.0**: Added the support of **Versatile Thermostat UI Card**. See [Versatile Thermostat UI Card](https://github.com/jmcollin78/versatile-thermostat-ui-card). Added a **Slow** regulation mode for slow latency heating devices [#168](https://github.com/jmcollin78/versatile_thermostat/issues/168). Change the way **the power is calculated** in case of VTherm with multi-underlying equipements [#146](https://github.com/jmcollin78/versatile_thermostat/issues/146). Added the support of AC and Heat for VTherm over switch alse [#144](https://github.com/jmcollin78/versatile_thermostat/pull/144) > * **Release 3.8**: Added a **self-regulation function** for `over climate` thermostats whose regulation is done by the underlying climate. See [Self-regulation](#self-regulation) and [#129](https://github.com/jmcollin78/versatile_thermostat/issues/129). Added the possibility of **inverting the command** for an `over switch` thermostat to address installations with pilot wire and diode [#124](https://github.com/jmcollin78/versatile_thermostat/issues/124). @@ -311,6 +313,14 @@ and of course, configure the VTherm's self-regulation mode in **Expert** mode. A For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat). +#### Auto-fan mode +This mode introduced in 4.3 makes it possible to force the use of ventilation if the temperature difference is significant. In fact, by activating ventilation, distribution occurs more quickly, which saves time in reaching the target temperature. +You can choose which ventilation you want to activate between the following settings: Low, Medium, High, Turbo. + +Obviously your underlying equipment must be equipped with ventilation and be controllable for this to work. +If your equipment does not include Turbo mode, Forte` mode will be used as a replacement. +Once the temperature difference becomes low again, the ventilation will go into a "normal" mode which depends on your equipment, namely (in order): `Silence (mute)`, `Auto (auto)`, `Low (low)`. The first value that is possible for your equipment will be chosen. + ### 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. @@ -884,53 +894,73 @@ You can customize this component using the HACS card-mod component to adjust the ``` ![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/custom-css-thermostat.png?raw=true) -## Even better with Apex-chart to tune your Thermostat -You can get curve like presented in [some results](#some-results) with kind of Apex-chart configuration only using the custom attributes of the thermostat described [here](#custom-attributes): +## Even better with Plotly to tune your Thermostat +You can get curve like presented in [some results](#some-results) with kind of Plotly configuration only using the custom attributes of the thermostat described [here](#custom-attributes): +Replace values in [[ ]] by yours. ``` -type: custom:apexcharts-card -header: - show: true - title: Tuning chauffage - show_states: true - colorize_states: true -update_interval: 60sec -graph_span: 4h -yaxis: - - id: left - show: true - decimals: 2 - - id: right - decimals: 2 - show: true - opposite: true -series: - - entity: climate.thermostat_mythermostat - attribute: temperature - type: line - name: Target temp - curve: smooth - yaxis_id: left - - entity: climate.thermostat_mythermostat - attribute: current_temperature - name: Current temp - curve: smooth - yaxis_id: left - - entity: climate.thermostat_mythermostat <--- for over_switch - attribute: on_percent - name: Power percent - curve: stepline - yaxis_id: right - - entity: climate.thermostat_mythermostat <--- for over_thermostast - attribute: regulated_target_temperature - name: Regulated temperature - curve: stepline - yaxis_id: left - - entity: climate.thermostat_mythermostat <--- for over_valve - attribute: valve_open_percent - name: Valve open percent - curve: stepline - yaxis_id: right +- type: custom:plotly-graph + entities: + - entity: '[[climate]]' + attribute: temperature + yaxis: y1 + name: Consigne + - entity: '[[climate]]' + attribute: current_temperature + yaxis: y1 + name: T° + - entity: '[[climate]]' + attribute: ema_temp + yaxis: y1 + name: Ema + - entity: '[[climate]]' + attribute: regulated_target_temperature + yaxis: y1 + name: Regulated T° + - entity: '[[slope]]' + name: Slope + fill: tozeroy + yaxis: y9 + fillcolor: rgba(100, 100, 100, 0.3) + line: + color: rgba(100, 100, 100, 0.9) + hours_to_show: 4 + refresh_interval: 10 + height: 800 + config: + scrollZoom: true + layout: + margin: + r: 50 + legend: + x: 0 + 'y': 1.2 + groupclick: togglegroup + title: + side: top right + yaxis: + visible: true + position: 0 + yaxis9: + visible: true + fixedrange: false + range: + - -0.5 + - 0.5 + position: 1 + xaxis: + rangeselector: + 'y': 1.1 + x: 0.7 + buttons: + - count: 1 + step: hour + - count: 12 + step: hour + - count: 1 + step: day + - count: 7 + step: day ``` ## And always better and better with the NOTIFIER daemon app to notify events diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 915d499..3617145 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -102,7 +102,6 @@ from .const import ( CONF_TEMP_MIN, HIDDEN_PRESETS, CONF_AC_MODE, - UnknownEntity, EventType, ATTR_MEAN_POWER_CYCLE, ATTR_TOTAL_ENERGY, @@ -259,6 +258,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._ema_temp = None self._ema_algo = None self._now = None + + self._attr_fan_mode = None self.post_init(entry_infos) def post_init(self, entry_infos): @@ -555,11 +556,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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 + await self.async_startup() def remove_thermostat(self): """Called when the thermostat will be removed""" @@ -577,12 +574,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): need_write_state = False # Initialize all UnderlyingEntities - for under in self._underlyings: - try: - under.startup() - except UnknownEntity: - # Not found, we will try later - pass + self.init_underlyings() temperature_state = self.hass.states.get(self._temp_sensor_entity_id) if temperature_state and temperature_state.state not in ( @@ -723,6 +715,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): EVENT_HOMEASSISTANT_START, _async_startup_internal ) + def init_underlyings(self): + """Initialize all underlyings. Should be overriden if necessary""" + def restore_specific_previous_state(self, old_state): """Should be overriden in each specific thermostat if a specific previous state or attribute should be @@ -2089,6 +2084,13 @@ class BaseThermostat(ClimateEntity, RestoreEntity): return shouldBeInSecurity + @property + def is_initialized(self) -> bool: + """Check if all underlyings are initialized + This is usefull only for over_climate in which we + should have found the underlying climate to be operational""" + return True + async def async_control_heating(self, force=False, _=None): """The main function used to run the calculation at each cycle""" @@ -2104,18 +2106,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity): await self._async_manage_window_auto(in_cycle=True) # 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 + if not self.is_initialized: + if not self.init_underlyings(): + # still not found, we an stop here + return False # Check overpowering condition # Not necessary for switch because each switch is checking at startup diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index ea2dc1b..6ccd558 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -15,7 +15,13 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers import entity_platform -from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME +from homeassistant.const import ( + CONF_NAME, + STATE_ON, + STATE_OFF, + STATE_HOME, + STATE_NOT_HOME, +) from .const import ( DOMAIN, @@ -26,10 +32,11 @@ from .const import ( SERVICE_SET_SECURITY, SERVICE_SET_WINDOW_BYPASS, SERVICE_SET_AUTO_REGULATION_MODE, + SERVICE_SET_AUTO_FAN_MODE, CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, - CONF_THERMOSTAT_VALVE + CONF_THERMOSTAT_VALVE, ) from .thermostat_switch import ThermostatOverSwitch @@ -102,8 +109,7 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_WINDOW_BYPASS, { - vol.Required("window_bypass"): vol.In([True, False] - ), + vol.Required("window_bypass"): vol.In([True, False]), }, "service_set_window_bypass_state", ) @@ -111,7 +117,19 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_AUTO_REGULATION_MODE, { - vol.Required("auto_regulation_mode"): vol.In(["None", "Light", "Medium", "Strong", "Slow"]), + vol.Required("auto_regulation_mode"): vol.In( + ["None", "Light", "Medium", "Strong", "Slow"] + ), }, "service_set_auto_regulation_mode", ) + + platform.async_register_entity_service( + SERVICE_SET_AUTO_FAN_MODE, + { + vol.Required("auto_fan_mode"): vol.In( + ["None", "Low", "Medium", "High", "Turbo"] + ), + }, + "service_set_auto_fan_mode", + ) diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index 3b6b9cd..5a6a088 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -107,6 +107,9 @@ from .const import ( CONF_INVERSE_SWITCH, UnknownEntity, WindowOpenDetectionMethod, + CONF_AUTO_FAN_MODES, + CONF_AUTO_FAN_MODE, + CONF_AUTO_FAN_HIGH, ) _LOGGER = logging.getLogger(__name__) @@ -275,6 +278,14 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): vol.Optional( CONF_AUTO_REGULATION_PERIOD_MIN, default=5 ): cv.positive_int, + vol.Optional( + CONF_AUTO_FAN_MODE, default=CONF_AUTO_FAN_HIGH + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_AUTO_FAN_MODES, + translation_key="auto_fan_mode", + ) + ), } ) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 1b5770c..9e58a6f 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -95,6 +95,12 @@ CONF_AUTO_REGULATION_DTEMP = "auto_regulation_dtemp" CONF_AUTO_REGULATION_PERIOD_MIN = "auto_regulation_periode_min" CONF_INVERSE_SWITCH = "inverse_switch_command" CONF_SHORT_EMA_PARAMS = "short_ema_params" +CONF_AUTO_FAN_MODE = "auto_fan_mode" +CONF_AUTO_FAN_NONE = "auto_fan_none" +CONF_AUTO_FAN_LOW = "auto_fan_low" +CONF_AUTO_FAN_MEDIUM = "auto_fan_medium" +CONF_AUTO_FAN_HIGH = "auto_fan_high" +CONF_AUTO_FAN_TURBO = "auto_fan_turbo" DEFAULT_SHORT_EMA_PARAMS = { "max_alpha": 0.5, @@ -233,6 +239,14 @@ CONF_THERMOSTAT_TYPES = [ CONF_THERMOSTAT_VALVE, ] +CONF_AUTO_FAN_MODES = [ + CONF_AUTO_FAN_NONE, + CONF_AUTO_FAN_LOW, + CONF_AUTO_FAN_MEDIUM, + CONF_AUTO_FAN_HIGH, + CONF_AUTO_FAN_TURBO, +] + SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE SERVICE_SET_PRESENCE = "set_presence" @@ -240,6 +254,7 @@ SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" SERVICE_SET_SECURITY = "set_security" SERVICE_SET_WINDOW_BYPASS = "set_window_bypass" SERVICE_SET_AUTO_REGULATION_MODE = "set_auto_regulation_mode" +SERVICE_SET_AUTO_FAN_MODE = "set_auto_fan_mode" DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5 DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 @@ -247,6 +262,9 @@ DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 ATTR_TOTAL_ENERGY = "total_energy" ATTR_MEAN_POWER_CYCLE = "mean_cycle_power" +AUTO_FAN_DTEMP_THRESHOLD = 2 +AUTO_FAN_DEACTIVATED_MODES = ["mute", "auto", "low"] + # A special regulation parameter suggested by @Maia here: https://github.com/jmcollin78/versatile_thermostat/discussions/154 class RegulationParamSlow: diff --git a/custom_components/versatile_thermostat/services.yaml b/custom_components/versatile_thermostat/services.yaml index 18f0916..5436779 100644 --- a/custom_components/versatile_thermostat/services.yaml +++ b/custom_components/versatile_thermostat/services.yaml @@ -161,3 +161,25 @@ set_auto_regulation_mode: - "Strong" - "Slow" - "Expert" + +set_auto_fan_mode: + name: Set Auto Fan mode + description: Change the mode of auto-fan (only for VTherm over climate) + target: + entity: + integration: versatile_thermostat + fields: + auto_fan_mode: + name: Auto fan mode + description: Possible values + required: true + advanced: false + default: true + selector: + select: + options: + - "None" + - "Low" + - "Medium" + - "High" + - "Turbo" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 2e0391f..26294a5 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -25,24 +25,25 @@ "title": "Linked entities", "description": "Linked entities attributes", "data": { - "heater_entity_id": "1rst heater switch", + "heater_entity_id": "1st 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": "1rst underlying climate", + "climate_entity_id": "1st underlying climate", "climate_entity2_id": "2nd underlying climate", "climate_entity3_id": "3rd underlying climate", "climate_entity4_id": "4th underlying climate", "ac_mode": "AC mode", - "valve_entity_id": "1rst valve number", + "valve_entity_id": "1st valve number", "valve_entity2_id": "2nd valve number", "valve_entity3_id": "3rd valve number", "valve_entity4_id": "4th valve number", "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -55,14 +56,15 @@ "climate_entity3_id": "3rd underlying climate entity id", "climate_entity4_id": "4th underlying climate entity id", "ac_mode": "Use the Air Conditioning (AC) mode", - "valve_entity_id": "1rst valve number entity id", + "valve_entity_id": "1st 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", "auto_regulation_mode": "Auto adjustment of the target temperature", - "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send", + "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -75,7 +77,7 @@ }, "presets": { "title": "Presets", - "description": "For each presets, give the target temperature (0 to ignore preset)", + "description": "For each preset set the target temperature (0 to ignore preset)", "data": { "eco_temp": "Temperature in Eco preset", "comfort_temp": "Temperature in Comfort preset", @@ -96,16 +98,16 @@ "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { - "window_sensor_entity_id": "Leave empty if no window sensor should be use", + "window_sensor_entity_id": "Leave empty if no window sensor should be used", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not use", - "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", - "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used" } }, "motion": { "title": "Motion management", - "description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name", + "description": "Motion sensor management. Preset can switch automatically depending on motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name", "data": { "motion_sensor_entity_id": "Motion sensor entity id", "motion_delay": "Activation delay", @@ -115,7 +117,7 @@ }, "data_description": { "motion_sensor_entity_id": "The entity id of the motion sensor", - "motion_delay": "Motion activation activation delay (seconds)", + "motion_delay": "Motion activation delay (seconds)", "motion_off_delay": "Motion deactivation delay (seconds)", "motion_preset": "Preset to use when motion is detected", "no_motion_preset": "Preset to use when no motion is detected" @@ -145,7 +147,7 @@ }, "advanced": { "title": "Advanced parameters", - "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.", + "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", "data": { "minimal_activation_delay": "Minimal activation delay", "security_delay_min": "Security delay (in minutes)", @@ -154,16 +156,16 @@ }, "data_description": { "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", - "security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a security off state", "security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset", - "security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present" + "security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security preset" } } }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id", - "window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both" + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both" }, "abort": { "already_configured": "Device is already configured" @@ -194,24 +196,25 @@ "title": "Linked entities", "description": "Linked entities attributes", "data": { - "heater_entity_id": "1rst heater switch", + "heater_entity_id": "1st 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": "1rst underlying climate", + "climate_entity_id": "1st underlying climate", "climate_entity2_id": "2nd underlying climate", "climate_entity3_id": "3rd underlying climate", "climate_entity4_id": "4th underlying climate", "ac_mode": "AC mode", - "valve_entity_id": "1rst valve number", + "valve_entity_id": "1st valve number", "valve_entity2_id": "2nd valve number", "valve_entity3_id": "3rd valve number", "valve_entity4_id": "4th valve number", "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -224,14 +227,15 @@ "climate_entity3_id": "3rd underlying climate entity id", "climate_entity4_id": "4th underlying climate entity id", "ac_mode": "Use the Air Conditioning (AC) mode", - "valve_entity_id": "1rst valve number entity id", + "valve_entity_id": "1st 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", "auto_regulation_mode": "Auto adjustment of the target temperature", - "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send", + "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -244,7 +248,7 @@ }, "presets": { "title": "Presets", - "description": "For each presets, give the target temperature (0 to ignore preset)", + "description": "For each preset set the target temperature (0 to ignore preset)", "data": { "eco_temp": "Temperature in Eco preset", "comfort_temp": "Temperature in Comfort preset", @@ -265,11 +269,11 @@ "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { - "window_sensor_entity_id": "Leave empty if no window sensor should be use", + "window_sensor_entity_id": "Leave empty if no window sensor should be used", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not use", - "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", - "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used" } }, "motion": { @@ -284,7 +288,7 @@ }, "data_description": { "motion_sensor_entity_id": "The entity id of the motion sensor", - "motion_delay": "Motion activation activation delay (seconds)", + "motion_delay": "Motion activation delay (seconds)", "motion_off_delay": "Motion deactivation delay (seconds)", "motion_preset": "Preset to use when motion is detected", "no_motion_preset": "Preset to use when no motion is detected" @@ -303,7 +307,7 @@ "title": "Presence management", "description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.", "data": { - "presence_sensor_entity_id": "Presence sensor entity id (true is present)", + "presence_sensor_entity_id": "Presence sensor entity id", "eco_away_temp": "Temperature in Eco preset when no presence", "comfort_away_temp": "Temperature in Comfort preset when no presence", "boost_away_temp": "Temperature in Boost preset when no presence", @@ -314,25 +318,25 @@ }, "advanced": { "title": "Advanced parameters", - "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.", + "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", "data": { "minimal_activation_delay": "Minimal activation delay", "security_delay_min": "Security delay (in minutes)", - "security_min_on_percent": "Minimal power percent for security mode", + "security_min_on_percent": "Minimal power percent to enable security mode", "security_default_on_percent": "Power percent to use in security mode" }, "data_description": { "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", - "security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a security off state", "security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of power percent the thermostat won't go into security preset", - "security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security present" + "security_default_on_percent": "The default heating power percent value in security preset. Set to 0 to switch off heater in security preset" } } }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id", - "window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both" + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both" }, "abort": { "already_configured": "Device is already configured" @@ -355,6 +359,15 @@ "auto_regulation_expert": "Expert", "auto_regulation_none": "No auto-regulation" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 5a008fe..cb48956 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -9,7 +9,7 @@ from homeassistant.helpers.event import ( async_track_time_interval, ) -from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.components.climate import HVACAction, HVACMode, ClimateEntityFeature from .commons import NowClass, round_to_nearest from .base_thermostat import BaseThermostat @@ -31,10 +31,19 @@ from .const import ( CONF_AUTO_REGULATION_EXPERT, CONF_AUTO_REGULATION_DTEMP, CONF_AUTO_REGULATION_PERIOD_MIN, + CONF_AUTO_FAN_MODE, + CONF_AUTO_FAN_NONE, + CONF_AUTO_FAN_LOW, + CONF_AUTO_FAN_MEDIUM, + CONF_AUTO_FAN_HIGH, + CONF_AUTO_FAN_TURBO, RegulationParamSlow, RegulationParamLight, RegulationParamMedium, RegulationParamStrong, + AUTO_FAN_DTEMP_THRESHOLD, + AUTO_FAN_DEACTIVATED_MODES, + UnknownEntity, ) from .vtherm_api import VersatileThermostatAPI @@ -52,6 +61,9 @@ class ThermostatOverClimate(BaseThermostat): _auto_regulation_dtemp: float = None _auto_regulation_period_min: int = None _last_regulation_change: datetime = None + _auto_fan_mode: str = None + _auto_activated_fan_mode: str = None + _auto_deactivated_fan_mode: str = None _entity_component_unrecorded_attributes = ( BaseThermostat._entity_component_unrecorded_attributes.union( @@ -65,6 +77,9 @@ class ThermostatOverClimate(BaseThermostat): "underlying_climate_3", "regulation_accumulated_error", "auto_regulation_mode", + "auto_fan_mode", + "auto_activated_fan_mode", + "auto_deactivated_fan_mode", } ) ) @@ -164,6 +179,41 @@ class ThermostatOverClimate(BaseThermostat): self.regulated_target_temp, self._attr_max_temp, self._attr_min_temp ) + async def _send_auto_fan_mode(self): + """Send the fan mode if auto_fan_mode and temperature gap is > threshold""" + if not self._auto_fan_mode or not self._auto_activated_fan_mode: + return + + dtemp = ( + self.regulated_target_temp if self.is_regulated else self.target_temperature + ) + if dtemp is None or self.current_temperature is None: + return + + dtemp = dtemp - self.current_temperature + should_activate_auto_fan = ( + dtemp >= AUTO_FAN_DTEMP_THRESHOLD or dtemp <= -AUTO_FAN_DTEMP_THRESHOLD + ) + if should_activate_auto_fan and self.fan_mode != self._auto_activated_fan_mode: + _LOGGER.info( + "%s - Activate the auto fan mode with %s because delta temp is %.2f", + self, + self._auto_fan_mode, + dtemp, + ) + await self.async_set_fan_mode(self._auto_activated_fan_mode) + if ( + not should_activate_auto_fan + and self.fan_mode not in AUTO_FAN_DEACTIVATED_MODES + ): + _LOGGER.info( + "%s - DeActivate the auto fan mode with %s because delta temp is %.2f", + self, + self._auto_deactivated_fan_mode, + dtemp, + ) + await self.async_set_fan_mode(self._auto_deactivated_fan_mode) + @overrides def post_init(self, entry_infos): """Initialize the Thermostat""" @@ -201,6 +251,12 @@ class ThermostatOverClimate(BaseThermostat): else 5 ) + self._auto_fan_mode = ( + entry_infos.get(CONF_AUTO_FAN_MODE) + if entry_infos.get(CONF_AUTO_FAN_MODE) is not None + else CONF_AUTO_FAN_NONE + ) + def choose_auto_regulation_mode(self, auto_regulation_mode): """Choose or change the regulation mode""" self._auto_regulation_mode = auto_regulation_mode @@ -277,6 +333,47 @@ class ThermostatOverClimate(BaseThermostat): self.target_temperature, 0, 0, 0, 0, 0.1, 0 ) + def choose_auto_fan_mode(self, auto_fan_mode): + """Choose the correct fan mode depending of the underlying capacities and the configuration""" + + # Get the supported feature of the first underlying. We suppose each underlying have the same fan attributes + fan_supported = self.supported_features & ClimateEntityFeature.FAN_MODE > 0 + + if auto_fan_mode == CONF_AUTO_FAN_NONE or not fan_supported: + self._auto_activated_fan_mode = self._auto_deactivated_fan_mode = None + return + + def find_fan_mode(fan_modes, fan_mode) -> str: + """Return the fan_mode if it exist of None if not""" + try: + return fan_mode if fan_modes.index(fan_mode) >= 0 else None + except ValueError: + return None + + fan_modes = self.fan_modes + if auto_fan_mode == CONF_AUTO_FAN_LOW: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "low") + elif auto_fan_mode == CONF_AUTO_FAN_MEDIUM: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "mid") + elif auto_fan_mode == CONF_AUTO_FAN_HIGH: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "high") + elif auto_fan_mode == CONF_AUTO_FAN_TURBO: + self._auto_activated_fan_mode = find_fan_mode( + fan_modes, "turbo" + ) or find_fan_mode(fan_modes, "high") + + for val in AUTO_FAN_DEACTIVATED_MODES: + if find_fan_mode(fan_modes, val): + self._auto_deactivated_fan_mode = val + break + + _LOGGER.info( + "%s - choose_auto_fan_mode founds auto_activated_fan_mode=%s and auto_deactivated_fan_mode=%s", + self, + self._auto_activated_fan_mode, + self._auto_deactivated_fan_mode, + ) + @overrides async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -302,6 +399,9 @@ class ThermostatOverClimate(BaseThermostat): ) ) + # init auto_regulation_mode + self.choose_auto_regulation_mode(self._auto_regulation_mode) + @overrides def restore_specific_previous_state(self, old_state): """Restore my specific attributes from previous state""" @@ -348,6 +448,14 @@ class ThermostatOverClimate(BaseThermostat): "regulation_accumulated_error" ] = self._regulation_algo.accumulated_error + self._attr_extra_state_attributes["auto_fan_mode"] = self.auto_fan_mode + self._attr_extra_state_attributes[ + "auto_activated_fan_mode" + ] = self._auto_activated_fan_mode + self._attr_extra_state_attributes[ + "auto_deactivated_fan_mode" + ] = self._auto_deactivated_fan_mode + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -435,6 +543,12 @@ class ThermostatOverClimate(BaseThermostat): else None ) + new_fan_mode = ( + new_state.attributes.get("fan_mode") + 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 ) @@ -545,6 +659,11 @@ class ThermostatOverClimate(BaseThermostat): for under in self._underlyings: await under.set_hvac_mode(new_hvac_mode) + # A quick win to known if it has change by using the self._attr_fan_mode and not only underlying[0].fan_mode + if new_fan_mode != self._attr_fan_mode: + self._attr_fan_mode = new_fan_mode + changes = True + if not changes: # try to manage new target temperature set if state _LOGGER.debug( @@ -576,6 +695,9 @@ class ThermostatOverClimate(BaseThermostat): await self._send_regulated_temperature() + if self._auto_fan_mode and self._auto_fan_mode != CONF_AUTO_FAN_NONE: + await self._send_auto_fan_mode() + return ret @property @@ -583,6 +705,11 @@ class ThermostatOverClimate(BaseThermostat): """Get the regulation mode""" return self._auto_regulation_mode + @property + def auto_fan_mode(self): + """Get the auto fan mode""" + return self._auto_fan_mode + @property def regulated_target_temp(self): """Get the regulated target temperature""" @@ -613,7 +740,8 @@ class ThermostatOverClimate(BaseThermostat): Requires ClimateEntityFeature.FAN_MODE. """ if self.underlying_entity(0): - return self.underlying_entity(0).fan_mode + self._attr_fan_mode = self.underlying_entity(0).fan_mode + return self._attr_fan_mode return None @@ -707,6 +835,31 @@ class ThermostatOverClimate(BaseThermostat): return None + @property + def is_initialized(self) -> bool: + """Check if all underlyings are initialized""" + for under in self._underlyings: + if not under.is_initialized: + return False + return True + + @overrides + def init_underlyings(self): + """Init the underlyings if not already done""" + 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 + self.choose_auto_fan_mode(self._auto_fan_mode) + @overrides def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" @@ -795,3 +948,30 @@ class ThermostatOverClimate(BaseThermostat): await self._send_regulated_temperature() self.update_custom_attributes() + + async def service_set_auto_fan_mode(self, auto_fan_mode): + """Called by a service call: + service: versatile_thermostat.set_auto_fan_mode + data: + auto_fan_mode: [None | Low | Medium | High | Turbo] + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info( + "%s - Calling service_set_auto_fan_mode, auto_fan_mode: %s", + self, + auto_fan_mode, + ) + if auto_fan_mode == "None": + self.choose_auto_fan_mode(CONF_AUTO_FAN_NONE) + elif auto_fan_mode == "Low": + self.choose_auto_fan_mode(CONF_AUTO_FAN_LOW) + elif auto_fan_mode == "Medium": + self.choose_auto_fan_mode(CONF_AUTO_FAN_MEDIUM) + elif auto_fan_mode == "High": + self.choose_auto_fan_mode(CONF_AUTO_FAN_HIGH) + elif auto_fan_mode == "Turbo": + self.choose_auto_fan_mode(CONF_AUTO_FAN_TURBO) + + await self._send_regulated_temperature() + self.update_custom_attributes() diff --git a/custom_components/versatile_thermostat/translations/el.json b/custom_components/versatile_thermostat/translations/el.json index de4676c..aa62c3d 100644 --- a/custom_components/versatile_thermostat/translations/el.json +++ b/custom_components/versatile_thermostat/translations/el.json @@ -42,7 +42,8 @@ "auto_regulation_mode": "Αυτόματη ρύθμιση", "auto_regulation_dtemp": "Όριο ρύθμισης", "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", - "inverse_switch_command": "Αντίστροφη εντολή διακόπτη" + "inverse_switch_command": "Αντίστροφη εντολή διακόπτη", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", @@ -62,7 +63,8 @@ "auto_regulation_mode": "Αυτόματη προσαρμογή της στοχευμένης θερμοκρασίας", "auto_regulation_dtemp": "Το όριο σε ° κάτω από το οποίο η αλλαγή θερμοκρασίας δεν θα αποστέλλεται", "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", - "inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή" + "inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -211,7 +213,8 @@ "auto_regulation_mode": "Αυτορύθμιση", "auto_regulation_dtemp": "Όριο ρύθμισης", "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", - "inverse_switch_command": "Αντίστροφη εντολή διακόπτη" + "inverse_switch_command": "Αντίστροφη εντολή διακόπτη", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", @@ -231,7 +234,8 @@ "auto_regulation_mode": "Αυτόματη ρύθμιση της στοχευόμενης θερμοκρασίας", "auto_regulation_dtemp": "Το κατώφλι σε °C κάτω από το οποίο η αλλαγή της θερμοκρασίας δεν θα αποστέλλεται", "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", - "inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή" + "inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -355,6 +359,15 @@ "auto_regulation_expert": "Εμπειρογνώμων", "auto_regulation_none": "Χωρίς αυτόματη ρύθμιση" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 633260a..26294a5 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -42,7 +42,8 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -62,7 +63,8 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -211,7 +213,8 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Mandatory heater entity id", @@ -231,7 +234,8 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -355,6 +359,15 @@ "auto_regulation_expert": "Expert", "auto_regulation_none": "No auto-regulation" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 777f3f3..e9c6263 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -42,7 +42,8 @@ "auto_regulation_mode": "Auto-régulation", "auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_periode_min": "Période minimale de régulation", - "inverse_switch_command": "Inverser la commande" + "inverse_switch_command": "Inverser la commande", + "auto_fan_mode": " Auto ventilation mode" }, "data_description": { "heater_entity_id": "Entity id du 1er radiateur obligatoire", @@ -62,7 +63,8 @@ "auto_regulation_mode": "Ajustement automatique de la température cible", "auto_regulation_dtemp": "Le seuil en ° au-dessous duquel la régulation ne sera pas envoyée", "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", - "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode" + "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", + "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" } }, "tpi": { @@ -212,7 +214,8 @@ "auto_regulation_mode": "Auto-regulation", "auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_periode_min": "Période minimale de régulation", - "inverse_switch_command": "Inverser la commande" + "inverse_switch_command": "Inverser la commande", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Entity id du 1er radiateur obligatoire", @@ -232,7 +235,8 @@ "auto_regulation_mode": "Ajustement automatique de la consigne", "auto_regulation_dtemp": "Le seuil en ° au-dessous duquel la régulation ne sera pas envoyée", "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", - "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode" + "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", + "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" } }, "tpi": { @@ -356,6 +360,15 @@ "auto_regulation_expert": "Expert", "auto_regulation_none": "Aucune" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "Pas d'auto fan", + "auto_fan_low": "Faible", + "auto_fan_medium": "Moyenne", + "auto_fan_high": "Forte", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/it.json b/custom_components/versatile_thermostat/translations/it.json index 43c9a66..12608d4 100644 --- a/custom_components/versatile_thermostat/translations/it.json +++ b/custom_components/versatile_thermostat/translations/it.json @@ -40,7 +40,8 @@ "valve_entity3_id": "Terza valvola", "valve_entity4_id": "Quarta valvola", "auto_regulation_mode": "Autoregolamentazione", - "inverse_switch_command": "Comando inverso" + "inverse_switch_command": "Comando inverso", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", @@ -58,7 +59,8 @@ "valve_entity3_id": "Entity id della terza valvola", "valve_entity4_id": "Entity id della quarta valvola", "auto_regulation_mode": "Regolazione automatica della temperatura target", - "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo" + "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -198,7 +200,8 @@ "valve_entity3_id": "Terza valvola", "valve_entity4_id": "Quarta valvola", "auto_regulation_mode": "Autoregolamentazione", - "inverse_switch_command": "Comando inverso" + "inverse_switch_command": "Comando inverso", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", @@ -216,7 +219,8 @@ "valve_entity3_id": "Entity id della terza valvola", "valve_entity4_id": "Entity id della quarta valvola", "auto_regulation_mode": "Autoregolamentazione", - "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo" + "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -333,6 +337,15 @@ "auto_regulation_expert": "Esperto", "auto_regulation_none": "Nessuna autoregolamentazione" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "Nessune autofan", + "auto_fan_low": "Leggera", + "auto_fan_medium": "Media", + "auto_fan_high": "Forte", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/sk.json b/custom_components/versatile_thermostat/translations/sk.json index 3dc7415..154aec6 100644 --- a/custom_components/versatile_thermostat/translations/sk.json +++ b/custom_components/versatile_thermostat/translations/sk.json @@ -42,7 +42,8 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "ID entity povinného ohrievača", @@ -62,7 +63,8 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -211,7 +213,8 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", - "inverse_switch_command": "Inverse switch command" + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" }, "data_description": { "heater_entity_id": "ID entity povinného ohrievača", @@ -231,7 +234,8 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send", "auto_regulation_periode_min": "Duration in minutes between two regulation update", - "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command" + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } }, "tpi": { @@ -355,6 +359,15 @@ "auto_regulation_expert": "Expert", "auto_regulation_none": "No auto-regulation" } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto-fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } } }, "entity": { diff --git a/images/config-linked-entity2.png b/images/config-linked-entity2.png index 0aecb11..2c4e451 100644 Binary files a/images/config-linked-entity2.png and b/images/config-linked-entity2.png differ diff --git a/tests/commons.py b/tests/commons.py index 32a3320..c2a8514 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -24,7 +24,10 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry 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 -from custom_components.versatile_thermostat.commons import get_tz, NowClass # pylint: disable=unused-import +from custom_components.versatile_thermostat.commons import ( # pylint: disable=unused-import + get_tz, + NowClass, +) from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_SWITCH_USER_CONFIG, @@ -117,47 +120,80 @@ _LOGGER = logging.getLogger(__name__) class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF, hvac_action:HVACAction = HVACAction.OFF) -> None: # pylint: disable=unused-argument + def __init__( # pylint: disable=unused-argument, dangerous-default-value + self, + hass: HomeAssistant, + unique_id, + name, + entry_infos={}, + hvac_mode: HVACMode = HVACMode.OFF, + hvac_action: HVACAction = HVACAction.OFF, + fan_modes: list[str] = None, + ) -> None: """Initialize the thermostat.""" super().__init__() self.hass = hass - self.platform = 'climate' - self.entity_id= self.platform+'.'+unique_id + self.platform = "climate" + self.entity_id = self.platform + "." + unique_id self._attr_extra_state_attributes = {} self._unique_id = unique_id self._name = name - self._attr_hvac_action = HVACAction.OFF if hvac_mode == HVACMode.OFF else HVACAction.HEATING + self._attr_hvac_action = ( + HVACAction.OFF if hvac_mode == HVACMode.OFF else HVACAction.HEATING + ) self._attr_hvac_mode = hvac_mode self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_target_temperature = 20 self._attr_current_temperature = 15 self._attr_hvac_action = hvac_action + self._fan_modes = fan_modes if fan_modes else None + self._attr_fan_mode = None + + @property + def hvac_action(self): + """The hvac action of the mock climate""" + return self._attr_hvac_action + + @property + def fan_modes(self) -> list[str] | None: + """The list of fan_modes""" + return self._fan_modes + + def set_fan_mode(self, fan_mode): + """Set the fan mode""" + self._attr_fan_mode = fan_mode + + @property + def supported_features(self) -> int: + """The supported feature of this climate entity""" + ret = ClimateEntityFeature.TARGET_TEMPERATURE + if self._fan_modes: + ret = ret | ClimateEntityFeature.FAN_MODE + return ret def set_temperature(self, **kwargs): - """ Set the target temperature""" + """Set the target temperature""" temperature = kwargs.get(ATTR_TEMPERATURE) self._attr_target_temperature = temperature async def async_set_hvac_mode(self, hvac_mode): - """ The hvac mode""" + """The hvac mode""" self._attr_hvac_mode = hvac_mode - @property - def hvac_action(self): - """ The hvac action of the mock climate""" - return self._attr_hvac_action - def set_hvac_action(self, hvac_action: HVACAction): - """ Set the HVACaction """ + """Set the HVACaction""" self._attr_hvac_action = hvac_action + class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: # pylint: disable=unused-argument + def __init__( + self, hass: HomeAssistant, unique_id, name, entry_infos + ) -> None: # pylint: disable=unused-argument """Initialize the thermostat.""" super().__init__() @@ -170,6 +206,8 @@ class MockUnavailableClimate(ClimateEntity): self._attr_hvac_mode = None self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_fan_mode = None + class MagicMockClimate(MagicMock): """A Magic Mock class for a underlying climate entity""" @@ -325,9 +363,7 @@ async def send_ext_temperature_change_event( await asyncio.sleep(0.1) -async def send_power_change_event( - entity: BaseThermostat, new_power, date, sleep=True -): +async def send_power_change_event(entity: BaseThermostat, new_power, date, sleep=True): """Sending a new power event simulating a change on power sensor""" _LOGGER.info( "------- Testu: sending send_temperature_change_event, new_power=%.2f date=%s on %s", @@ -478,6 +514,7 @@ async def send_presence_change_event( await asyncio.sleep(0.1) return ret + async def send_climate_change_event( entity: BaseThermostat, new_hvac_mode: HVACMode, @@ -521,6 +558,7 @@ async def send_climate_change_event( await asyncio.sleep(0.1) return ret + async def send_climate_change_event_with_temperature( entity: BaseThermostat, new_hvac_mode: HVACMode, diff --git a/tests/const.py b/tests/const.py index 71c117a..6cf6cb0 100644 --- a/tests/const.py +++ b/tests/const.py @@ -55,8 +55,11 @@ from custom_components.versatile_thermostat.const import ( CONF_AUTO_REGULATION_NONE, CONF_AUTO_REGULATION_DTEMP, CONF_AUTO_REGULATION_PERIOD_MIN, - CONF_INVERSE_SWITCH + CONF_INVERSE_SWITCH, + CONF_AUTO_FAN_HIGH, + CONF_AUTO_FAN_MODE, ) + MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_NAME: "TheOverSwitchMockName", CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, @@ -103,14 +106,14 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { CONF_HEATER: "switch.mock_switch", CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_AC_MODE: False, - CONF_INVERSE_SWITCH: False + CONF_INVERSE_SWITCH: False, } MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = { CONF_HEATER: "switch.mock_air_conditioner", CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_AC_MODE: True, - CONF_INVERSE_SWITCH: False + CONF_INVERSE_SWITCH: False, } MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = { @@ -120,7 +123,7 @@ MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = { CONF_HEATER_4: "switch.mock_4switch3", CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_AC_MODE: False, - CONF_INVERSE_SWITCH: False + CONF_INVERSE_SWITCH: False, } MOCK_TH_OVER_SWITCH_TPI_CONFIG = { @@ -133,13 +136,14 @@ MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_AC_MODE: False, CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG, CONF_AUTO_REGULATION_DTEMP: 0.5, - CONF_AUTO_REGULATION_PERIOD_MIN: 2 + CONF_AUTO_REGULATION_PERIOD_MIN: 2, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, } MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG = { CONF_CLIMATE: "climate.mock_climate", CONF_AC_MODE: False, - CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_NONE + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_NONE, } MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG = { @@ -147,7 +151,7 @@ MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG = { CONF_AC_MODE: True, CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG, CONF_AUTO_REGULATION_DTEMP: 0.5, - CONF_AUTO_REGULATION_PERIOD_MIN: 1 + CONF_AUTO_REGULATION_PERIOD_MIN: 1, } MOCK_PRESETS_CONFIG = { @@ -203,8 +207,8 @@ MOCK_PRESENCE_AC_CONFIG = { PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17, PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, PRESET_ECO + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 27, - PRESET_COMFORT + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 26, - PRESET_BOOST + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 25, + PRESET_COMFORT + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 26, + PRESET_BOOST + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 25, } MOCK_ADVANCED_CONFIG = { diff --git a/tests/test_auto_fan_mode.py b/tests/test_auto_fan_mode.py new file mode 100644 index 0000000..a360c08 --- /dev/null +++ b/tests/test_auto_fan_mode.py @@ -0,0 +1,285 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + +""" Test the auto fan mode of a over_climate 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.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.thermostat_climate import ( + ThermostatOverClimate, +) + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_auto_fan_mode_turbo( + hass: HomeAssistant, skip_hass_states_is_state, skip_send_event +): + """Test the init of an over climate thermostat with auto_fan_mode = Turbo which exists""" + + fan_modes = ["low", "medium", "high", "boost", "mute", "auto", "turbo"] + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mockUniqueId", + name="MockClimateName", + fan_modes=fan_modes, + ) + + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entity: ThermostatOverClimate = search_entity( + hass, "climate.theoverclimatemockname", "climate" + ) + + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.fan_modes == fan_modes + assert entity._auto_fan_mode == "auto_fan_turbo" + assert entity._auto_activated_fan_mode == "turbo" + assert entity._auto_deactivated_fan_mode == "mute" + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_auto_fan_mode_not_turbo( + hass: HomeAssistant, skip_hass_states_is_state, skip_send_event +): + """Test the init of an over climate thermostat with auto_fan_mode = Turbo which doesn't exists""" + + fan_modes = ["low", "medium", "high", "boost", "auto"] + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mockUniqueId", + name="MockClimateName", + fan_modes=fan_modes, + ) + + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entity: ThermostatOverClimate = search_entity( + hass, "climate.theoverclimatemockname", "climate" + ) + + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.fan_modes == fan_modes + assert entity._auto_fan_mode == "auto_fan_turbo" + # Turbo doesn't exists -> fallback to high + assert entity._auto_activated_fan_mode == "high" + # Mute doesn't exists -> fallback to auto + assert entity._auto_deactivated_fan_mode == "auto" + + +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_auto_fan_mode_turbo_activation( + hass: HomeAssistant, skip_hass_states_is_state, skip_send_event +): + """Test the init of an over climate thermostat with auto_fan_mode = Turbo which exists""" + + fan_modes = ["low", "medium", "high", "boost", "mute", "auto", "turbo"] + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, + }, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mockUniqueId", + name="MockClimateName", + fan_modes=fan_modes, + ) + + # 1. Init fan mode + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entity: ThermostatOverClimate = search_entity( + hass, "climate.theoverclimatemockname", "climate" + ) + + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.fan_modes == fan_modes + assert entity.fan_mode is None + assert entity._auto_fan_mode == "auto_fan_turbo" + assert entity._auto_activated_fan_mode == "turbo" + assert entity._auto_deactivated_fan_mode == "mute" + + # 2. Turn on and set temperature cold + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_fan_mode" + ) as mock_send_fan_mode: + # Force preset mode + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode == PRESET_COMFORT + + # Change the current temperature to 16 which is 2° under + await send_temperature_change_event(entity, 16, now, True) + fake_underlying_climate.set_fan_mode("turbo") + + assert mock_send_fan_mode.call_count == 1 + mock_send_fan_mode.assert_has_calls([call.set_fan_mode("turbo")]) + + assert entity.fan_mode == "turbo" + + # 3. Set another low temperature + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_fan_mode" + ) as mock_send_fan_mode: + fake_underlying_climate.set_fan_mode("turbo") + + # Change the current temperature to 17 which is 1° under + await send_temperature_change_event(entity, 15, now, True) + + # Nothing is send cause we are already in turbo fan mode + assert mock_send_fan_mode.call_count == 0 + + assert entity.fan_mode == "turbo" + + # 4. Set temperature not so cold + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_fan_mode" + ) as mock_send_fan_mode: + # Change the current temperature to 17 which is 1° under + await send_temperature_change_event(entity, 17, now, True) + fake_underlying_climate.set_fan_mode("mute") + + assert mock_send_fan_mode.call_count == 1 + mock_send_fan_mode.assert_has_calls([call.set_fan_mode("mute")]) + + assert entity.fan_mode == "mute" + + # 5. Set temperature not so cold another time + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_fan_mode" + ) as mock_send_fan_mode: + fake_underlying_climate.set_fan_mode("mute") + + # Change the current temperature to 17 which is 1° under + await send_temperature_change_event(entity, 17.1, now, True) + + assert mock_send_fan_mode.call_count == 0 + assert entity.fan_mode == "mute"