diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5422c257..c3c0b240 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 67d03c42..e7e27b9c 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 a31154fa..92c55e6a 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 0bf53c74..a0e3db98 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 915d499f..3617145c 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -102,7 +102,6 @@ CONF_TEMP_MIN, HIDDEN_PRESETS, CONF_AC_MODE, - UnknownEntity, EventType, ATTR_MEAN_POWER_CYCLE, ATTR_TOTAL_ENERGY, @@ -259,6 +258,8 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: 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 @@ async def async_added_to_hass(self): 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 @@ async def _async_startup_internal(*_): 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 @@ async def _async_startup_internal(*_): 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 @@ async def check_security(self) -> bool: 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 @@ async def async_control_heating(self, force=False, _=None): 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 ea2dc1b6..6ccd5589 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -15,7 +15,13 @@ 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 @@ 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 3b6b9cd1..5a6a0880 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -107,6 +107,9 @@ 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 @@ def __init__(self, infos) -> None: 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 1b5770c1..9e58a6fc 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -95,6 +95,12 @@ 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_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_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 @@ 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 18f0916d..54367797 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 2e0391f5..26294a58 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 5a008fed..cb489564 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -9,7 +9,7 @@ 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 @@ 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 @@ async def _send_regulated_temperature(self, force=False): 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 @@ def post_init(self, entry_infos): 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 @@ def choose_auto_regulation_mode(self, auto_regulation_mode): 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 @@ async def async_added_to_hass(self): ) ) + # 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 @@ def update_custom_attributes(self): "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 @@ async def end_climate_changed(changes): 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 @@ async def end_climate_changed(changes): 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 @@ async def async_control_heating(self, force=False, _=None): 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 @@ def auto_regulation_mode(self): """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 @@ def fan_mode(self) -> str | None: 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 @@ def is_aux_heat(self) -> bool | None: 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 @@ async def service_set_auto_regulation_mode(self, auto_regulation_mode): 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 de4676c6..aa62c3d4 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 633260ab..26294a58 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 777f3f3c..e9c6263f 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 43c9a66e..12608d43 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 3dc7415b..154aec6c 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 0aecb11e..2c4e4516 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 32a33204..c2a85145 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -24,7 +24,10 @@ 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 @@ 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 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: 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 71c117a3..6cf6cb02 100644 --- a/tests/const.py +++ b/tests/const.py @@ -55,8 +55,11 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 00000000..a360c08d --- /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"