Compare commits

..

4 Commits

Author SHA1 Message Date
Jean-Marc Collin
49ebf9ddae central boiler - add entites to fine tune the boiler start 2024-01-20 10:40:55 +00:00
Jean-Marc Collin
60de43f241 Add en and string translation 2024-01-20 06:31:03 +00:00
Jean-Marc Collin
f651fbdb37 Issue #255 - Specify window action on window open detection 2024-01-17 18:56:00 +00:00
Jean-Marc Collin
685911a9aa Issue #343 - disable safety mode for outdoor thermometer 2024-01-16 18:30:52 +00:00
18 changed files with 1342 additions and 139 deletions

View File

@@ -28,6 +28,8 @@ versatile_thermostat:
max_alpha: 0.6
halflife_sec: 301
precision: 3
safety_mode:
check_outdoor_sensor: false
input_number:
fake_temperature_sensor1:
@@ -67,10 +69,10 @@ input_number:
icon: mdi:pipe-valve
unit_of_measurement: percentage
fake_boiler_temperature:
name: Boiler temperature
name: Central thermostat temp
min: 0
max: 30
icon: mdi:water-boiler
icon: mdi:thermostat
unit_of_measurement: °C
mode: box

View File

@@ -84,7 +84,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 5.3** : Ajout d'une fonction de pilotage d'une chaudière centrale [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234). Plus d'infos ici: [Le contrôle d'une chaudière centrale](#le-contrôle-dune-chaudière-centrale),
> * **Release 5.3** : Ajout d'une fonction de pilotage d'une chaudière centrale [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234) - plus d'infos ici: [Le contrôle d'une chaudière centrale](#le-contrôle-dune-chaudière-centrale). Ajout de la possibilité de désactiver le mode sécurité pour le thermomètre extérieur [#343](https://github.com/jmcollin78/versatile_thermostat/issues/343)
> * **Release 5.2** : Ajout d'un `central_mode` permettant de piloter tous les VTherms de façon centralisée [#158](https://github.com/jmcollin78/versatile_thermostat/issues/158).
> * **Release 5.1** : Limitation des valeurs envoyées aux valves et au température envoyées au climate sous-jacent.
> * **Release 5.0** : Ajout d'une configuration centrale permettant de mettre en commun les attributs qui peuvent l'être [#239](https://github.com/jmcollin78/versatile_thermostat/issues/239).
@@ -533,6 +533,15 @@ Mettre ce paramètre à ``0.00`` déclenchera le préréglage sécurité quelque
Le quatrième param§tre (``security_default_on_percent``) est la valeur de ``on_percent`` qui sera utilisée lorsque le thermostat passe en mode ``security``. Si vous mettez ``0`` alors le thermostat sera coupé lorsqu'il passe en mode ``security``, mettre 0,2% par exemple permet de garder un peu de chauffage (20% dans ce cas), même en mode ``security``. Ca évite de retrouver son logement totalement gelé lors d'une panne de thermomètre.
Depuis la version 5.3 il est possible de désactiver la mise en sécurité suite à une absence de données du thermomètre extérieure. En effet, celui-ci ayant la plupart du temps un impact faible sur la régulation (dépendant de votre paramètrage), il est possible qu'il soit absent sans mettre en danger le logement. Pour cela, il faut ajouter les lignes suivantes dans votre `configuration.yaml` :
```
versatile_thermostat:
...
safety_mode:
check_outdoor_sensor: false
```
Par défaut, le thermomètre extérieur peut déclencher une mise en sécurité si il n'envoit plus de valeur.
Voir [exemple de réglages](#examples-tuning) pour avoir des exemples de réglage communs
> ![Astuce](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
@@ -1313,7 +1322,7 @@ Avec un VTherm de type `over_switch` ou `over_valve`, ce défaut montre juste qu
### Type `over_climate`
Avec un VTherm de type `over_climate`, la régulation est faite par le `climate` sous-jacent directement et VTherm se contente de lui transmettre les consignes. Donc si le radiateur chauffe alors que la température de consigne est dépassée, c'est certainement que sa mesure de température interne est biaisée. Ca arrive très souvent avec les TRV et les clims réversibles qui ont un capteur de température interne, soit trop près de l'élément de chauffe (donc trop froid l'hiver).
Exemple de discussion autour de ces sujets: [#316](https://github.com/jmcollin78/versatile_thermostat/issues/316), [#312](https://github.com/jmcollin78/versatile_thermostat/discussions/312), [#278](https://github.com/jmcollin78/versatile_thermostat/discussions/278)
Exemple de discussion autour de ces sujets: [#348](https://github.com/jmcollin78/versatile_thermostat/issues/348), [#316](https://github.com/jmcollin78/versatile_thermostat/issues/316), [#312](https://github.com/jmcollin78/versatile_thermostat/discussions/312), [#278](https://github.com/jmcollin78/versatile_thermostat/discussions/278)
Pour s'en sortir, VTherm est équipé d'une fonction nommée auto-régulation qui permet d'adapter la consigne envoyée au sous-jacent jusqu'à ce que la consigne soit respectée. Cette fonction permet de compenser le biais de mesure des thermomètres internes. Si le biais est important la régulation doit être importante. Voir [L'auto-régulation](#lauto-régulation) pour configurer l'auto-régulation.

View File

@@ -84,7 +84,7 @@
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
>![New](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*News*_
> * **Release 5.3**: Added a central boiler control function [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234). More information here: [Controlling a central boiler](#controlling-a-central-boiler),
> * **Release 5.3**: Added a central boiler control function [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234) - more information here: [Controlling a central boiler](#controlling-a-central-boiler). Added the ability to disable security mode for outdoor thermometer [#343](https://github.com/jmcollin78/versatile_thermostat/issues/343)
> * **Release 5.2**: Added a `central_mode` allowing all VTherms to be controlled centrally [#158](https://github.com/jmcollin78/versatile_thermostat/issues/158).
> * **Release 5.1**: Limitation of the values sent to the valves and the temperature sent to the underlying climate.
> * **Release 5.0**: Added a central configuration allowing the sharing of attributes that can be shared [#239](https://github.com/jmcollin78/versatile_thermostat/issues/239).
@@ -519,6 +519,15 @@ Setting this parameter to ``0.00`` will trigger the safety preset regardless of
The fourth parameter (``security_default_on_percent``) is the ``on_percent`` value that will be used when the thermostat enters ``safety`` mode. If you put ``0`` then the thermostat will be cut off when it goes into ``safety`` mode, putting 0.2% for example allows you to keep a little heating (20% in this case), even in mode ``safety``. It avoids finding your home totally frozen during a thermometer failure.
Since version 5.3 it is possible to deactivate the safety device following a lack of data from the outdoor thermometer. Indeed, this most of the time having a low impact on regulation (depending on your settings), it is possible that it is absent without endangering the home. To do this, you must add the following lines to your `configuration.yaml`:
```
versatile_thermostat:
...
safety_mode:
check_outdoor_sensor: false
```
By default, the outdoor thermometer can trigger a trip if it no longer sends a value.
See [example tuning](#examples-tuning) for common tuning examples
>![Tip](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/tips.png?raw=true) _*Notes*_
@@ -1298,7 +1307,7 @@ With a VTherm of type `over_switch` or `over_valve`, this fault just shows that
### Type `over_climate`
With an `over_climate` type VTherm, the regulation is done by the underlying `climate` directly and VTherm simply transmits the instructions to it. So if the radiator heats up when the set temperature is exceeded, it is certainly because its internal temperature measurement is biased. This happens very often with TRVs and reversible air conditioning units which have an internal temperature sensor, or too close to the heating element (therefore too cold in winter).
Example of discussion around these topics: [#316](https://github.com/jmcollin78/versatile_thermostat/issues/316), [#312](https://github.com/jmcollin78/versatile_thermostat/discussions/312 ), [#278](https://github.com/jmcollin78/versatile_thermostat/discussions/278)
Example of discussion around these topics: [#348](https://github.com/jmcollin78/versatile_thermostat/issues/348), [#316](https://github.com/jmcollin78/versatile_thermostat/issues/316), [#312](https://github.com/jmcollin78/versatile_thermostat/discussions/312 ), [#278](https://github.com/jmcollin78/versatile_thermostat/discussions/278)
To get around this, VTherm is equipped with a function called self-regulation which allows the instruction sent to the underlying to be adapted until the target temperature is respected. This function compensates for the measurement bias of internal thermometers. If the bias is important the regulation must be important. See [Self-regulation](#self-regulation) to configure self-regulation.

View File

@@ -24,6 +24,7 @@ from .const import (
CONF_AUTO_REGULATION_SLOW,
CONF_AUTO_REGULATION_EXPERT,
CONF_SHORT_EMA_PARAMS,
CONF_SAFETY_MODE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_THERMOSTAT_TYPE,
)
@@ -47,12 +48,17 @@ EMA_PARAM_SCHEMA = {
vol.Required("precision"): cv.positive_int,
}
SAFETY_MODE_PARAM_SCHEMA = {
vol.Required("check_outdoor_sensor"): bool,
}
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA),
CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA),
CONF_SAFETY_MODE: vol.Schema(SAFETY_MODE_PARAM_SCHEMA),
}
),
},

View File

@@ -197,6 +197,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"window_auto_open_threshold",
"window_auto_close_threshold",
"window_auto_max_duration",
"window_action",
"motion_sensor_entity_id",
"presence_sensor_entity_id",
"power_sensor_entity_id",
@@ -268,8 +269,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._window_auto_state = False
self._window_auto_on = False
self._window_auto_algo = None
# PR - Adding Window ByPass
self._window_bypass_state = False
self._window_action = None
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
@@ -286,6 +287,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._is_central_mode = None
self._last_central_mode = None
self._is_used_by_central_boiler = False
self.post_init(entry_infos)
def clean_central_config_doublon(self, config_entry, central_config) -> dict:
@@ -576,6 +578,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
entry_infos.get(CONF_USED_BY_CENTRAL_BOILER) is True
)
self._window_action = (
entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF
)
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
self,
@@ -1092,6 +1098,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Get the Window Bypass"""
return self._window_bypass_state
@property
def window_action(self) -> bool | None:
"""Get the Window Action"""
return self._window_action
@property
def security_state(self) -> bool | None:
"""Get the security_state"""
@@ -1155,6 +1166,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Returns the number of underlying entities"""
return len(self._underlyings)
@property
def underlying_entities(self) -> int:
"""Returns the underlying entities"""
return self._underlyings
@property
def is_on(self) -> bool:
"""True if the VTherm is on (! HVAC_OFF)"""
@@ -1481,29 +1497,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._window_state = new_state.state == STATE_ON
# PR - Adding Window ByPass
_LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state)
if self._window_bypass_state:
_LOGGER.info(
"%s - Window ByPass is activated. Ignore window event", self
)
else:
if not self._window_state:
_LOGGER.info(
"%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED",
self,
self._saved_hvac_mode,
)
if self.last_central_mode != CENTRAL_MODE_STOPPED:
await self.restore_hvac_mode(True)
elif self._window_state:
_LOGGER.info(
"%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF
)
if self.last_central_mode in [CENTRAL_MODE_AUTO, None]:
self.save_hvac_mode()
await self.change_window_detection_state(self._window_state)
await self.async_set_hvac_mode(HVACMode.OFF)
self.update_custom_attributes()
if new_state is None or old_state is None or new_state.state == old_state.state:
@@ -1632,6 +1633,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings:
await under.check_initial_state(self._hvac_mode)
# Starts the initial control loop (don't wait for an update of temperature)
await self.async_control_heating(force=True)
@callback
async def _async_update_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
@@ -1657,7 +1661,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# try to restart if we were in safety mode
if self._security_state:
await self.check_security()
await self.check_safety()
# check window_auto
return await self._async_manage_window_auto()
@@ -1684,7 +1688,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# try to restart if we were in safety mode
if self._security_state:
await self.check_security()
await self.check_safety()
except ValueError as ex:
_LOGGER.error("Unable to update external temperature from sensor: %s", ex)
@@ -1841,7 +1845,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
)
# Set attributes
self._window_auto_state = False
await self.restore_hvac_mode(True)
await self.change_window_detection_state(self._window_auto_state)
# await self.restore_hvac_mode(True)
if self._window_call_cancel:
self._window_call_cancel()
@@ -1901,8 +1906,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
)
# Set attributes
self._window_auto_state = True
self.save_hvac_mode()
await self.async_set_hvac_mode(HVACMode.OFF)
await self.change_window_detection_state(self._window_auto_state)
# self.save_hvac_mode()
# await self.async_set_hvac_mode(HVACMode.OFF)
# Arm the end trigger
if self._window_call_cancel:
@@ -2122,7 +2128,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Get now. The local datetime or the overloaded _set_now date"""
return self._now if self._now is not None else datetime.now(self._current_tz)
async def check_security(self) -> bool:
async def check_safety(self) -> bool:
"""Check if last temperature date is too long"""
now = self.now
delta_temp = (
@@ -2134,9 +2140,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
mode_cond = self._hvac_mode != HVACMode.OFF
temp_cond: bool = (
delta_temp > self._security_delay_min
or delta_ext_temp > self._security_delay_min
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api()
is_outdoor_checked = (
not api.safety_mode
or api.safety_mode.get("check_outdoor_sensor") is not False
)
temp_cond: bool = delta_temp > self._security_delay_min or (
is_outdoor_checked and delta_ext_temp > self._security_delay_min
)
climate_cond: bool = self.is_over_climate and self.hvac_action not in [
HVACAction.COOLING,
@@ -2280,6 +2291,63 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
should have found the underlying climate to be operational"""
return True
async def change_window_detection_state(self, new_state):
"""Change the window detection state.
new_state is on if an open window have been detected or off else
"""
if not new_state:
_LOGGER.info(
"%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED",
self,
self._saved_hvac_mode,
)
if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]:
await self._async_internal_set_temperature(self._saved_target_temp)
# default to TURN_OFF
elif self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
if self.last_central_mode != CENTRAL_MODE_STOPPED:
await self.restore_hvac_mode(True)
else:
_LOGGER.error(
"%s - undefined window_action %s. Please open a bug in the github of this project with this log",
self,
self._window_action,
)
else:
_LOGGER.info(
"%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF
)
if self.last_central_mode in [CENTRAL_MODE_AUTO, None]:
if self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
self.save_hvac_mode()
elif self._window_action in [
CONF_WINDOW_FROST_TEMP,
CONF_WINDOW_ECO_TEMP,
]:
self._saved_target_temp = self._target_temp
if (
self._window_action == CONF_WINDOW_FAN_ONLY
and HVACMode.FAN_ONLY in self.hvac_modes
):
await self.async_set_hvac_mode(HVACMode.FAN_ONLY)
elif (
self._window_action == CONF_WINDOW_FROST_TEMP
and self._presets.get(PRESET_FROST_PROTECTION) is not None
):
await self._async_internal_set_temperature(
self.find_preset_temp(PRESET_FROST_PROTECTION)
)
elif (
self._window_action == CONF_WINDOW_ECO_TEMP
and self._presets.get(PRESET_ECO) is not None
):
await self._async_internal_set_temperature(
self.find_preset_temp(PRESET_ECO)
)
else: # default is to turn_off
await self.async_set_hvac_mode(HVACMode.OFF)
async def async_control_heating(self, force=False, _=None):
"""The main function used to run the calculation at each cycle"""
@@ -2307,7 +2375,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug("%s - End of cycle (overpowering)", self)
return True
security: bool = await self.check_security()
security: bool = await self.check_safety()
if security and self.is_over_climate:
_LOGGER.debug("%s - End of cycle (security and over climate)", self)
return True
@@ -2373,8 +2441,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.get_preset_away_name(PRESET_COMFORT)
),
"power_temp": self._power_temp,
# Already in super class - "target_temp": self.target_temperature,
# Already in super class - "current_temp": self._cur_temp,
"target_temperature_step": self.target_temperature_step,
"ext_current_temperature": self._cur_ext_temp,
"ac_mode": self._ac_mode,
@@ -2383,12 +2449,23 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"saved_preset_mode": self._saved_preset_mode,
"saved_target_temp": self._saved_target_temp,
"saved_hvac_mode": self._saved_hvac_mode,
"window_state": self.window_state,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"motion_state": self._motion_state,
"power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
"overpowering_state": self.overpowering_state,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"presence_state": self._presence_state,
"window_state": self.window_state,
"window_auto_state": self.window_auto_state,
"window_bypass_state": self._window_bypass_state,
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_auto_enabled": self.is_window_auto_enabled,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
"window_action": self.window_action,
"security_delay_min": self._security_delay_min,
"security_min_on_percent": self._security_min_on_percent,
"security_default_on_percent": self._security_default_on_percent,
@@ -2407,16 +2484,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz),
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_auto_enabled": self.is_window_auto_enabled,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
"temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active,
"ema_temp": self._ema_temp,

View File

@@ -1,5 +1,5 @@
""" Implements the VersatileThermostat binary sensors component """
# pylint: disable=unused-argument
# pylint: disable=unused-argument, line-too-long
import logging
@@ -14,7 +14,6 @@ from homeassistant.core import (
from homeassistant.const import STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.components.binary_sensor import (
@@ -25,14 +24,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.climate import (
ClimateEntity,
HVACMode,
HVACAction,
DOMAIN as CLIMATE_DOMAIN,
)
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from .vtherm_api import VersatileThermostatAPI
from .commons import (
VersatileThermostatBaseEntity,
@@ -399,7 +390,7 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
self._hass
)
api.register_central_boiler(self)
await self.listen_vtherms_entities()
await self.listen_nb_active_vtherm_entity()
if self.hass.state == CoreState.running:
await _async_startup_internal()
@@ -408,29 +399,28 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
async def listen_vtherms_entities(self):
async def listen_nb_active_vtherm_entity(self):
"""Initialize the listening of state change of VTherms"""
# Listen to all VTherm state change
self._entities = []
entities_id = []
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler:
self._entities.append(entity)
entities_id.append(entity.entity_id)
if len(self._entities) > 0:
# Arme l'écoute de la première entité
if (
api.nb_active_vtherm_for_boiler_entity
and api.nb_active_device_for_boiler_threshold_entity
):
listener_cancel = async_track_state_change_event(
self._hass,
entities_id,
[
api.nb_active_vtherm_for_boiler_entity.entity_id,
api.nb_active_device_for_boiler_threshold_entity.entity_id,
],
self.calculate_central_boiler_state,
)
_LOGGER.info(
"%s - VTherm that could controls the central boiler are %s",
_LOGGER.debug(
"%s - entity to get the nb of active VTherm is %s",
self,
entities_id,
api.nb_active_vtherm_for_boiler_entity.entity_id,
)
self.async_on_remove(listener_cancel)
else:
@@ -443,18 +433,20 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
controls this central boiler"""
_LOGGER.debug("%s - calculating the new central boiler state", self)
active = False
for entity in self._entities:
_LOGGER.debug(
"Examining the hvac_action of %s",
entity.name,
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
if (
api.nb_active_vtherm_for_boiler is None
or api.nb_active_device_for_boiler_threshold is None
):
_LOGGER.warning(
"%s - the entities to calculate the boiler state are not initialized. Boiler state cannot be calculated",
self,
)
if (
entity.hvac_mode == HVACMode.HEAT
and entity.hvac_action == HVACAction.HEATING
):
active = True
break
return False
active = (
api.nb_active_vtherm_for_boiler >= api.nb_active_device_for_boiler_threshold
)
if self._attr_is_on != active:
try:

View File

@@ -28,7 +28,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
CONF_THERMOSTAT_TYPE, default=CONF_THERMOSTAT_SWITCH
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_THERMOSTAT_TYPES, translation_key="thermostat_type"
options=CONF_THERMOSTAT_TYPES,
translation_key="thermostat_type",
mode="list",
)
)
}
@@ -125,6 +127,7 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name
selector.SelectSelectorConfig(
options=CONF_AUTO_REGULATION_MODES,
translation_key="auto_regulation_mode",
mode="dropdown",
)
),
vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=0.5): vol.Coerce(float),
@@ -135,6 +138,7 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name
selector.SelectSelectorConfig(
options=CONF_AUTO_FAN_MODES,
translation_key="auto_fan_mode",
mode="dropdown",
)
),
}
@@ -212,12 +216,30 @@ STEP_CENTRAL_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD, default=3): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD, default=0): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION, default=30): cv.positive_int,
vol.Optional(
CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_WINDOW_ACTIONS,
translation_key="window_action",
mode="dropdown",
)
),
}
)
STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
vol.Optional(
CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_WINDOW_ACTIONS,
translation_key="window_action",
mode="dropdown",
)
),
}
)
@@ -236,11 +258,19 @@ STEP_CENTRAL_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_OFF_DELAY, default=300): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
CONF_PRESETS_SELECTIONABLE
vol.Optional(CONF_MOTION_PRESET, default="comfort"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_PRESETS_SELECTIONABLE,
translation_key="presets",
mode="dropdown",
)
),
vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): vol.In(
CONF_PRESETS_SELECTIONABLE
vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_PRESETS_SELECTIONABLE,
translation_key="presets",
mode="dropdown",
)
),
}
)

View File

@@ -44,6 +44,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SELECT,
Platform.NUMBER,
]
CONF_HEATER = "heater_entity_id"
@@ -105,7 +106,6 @@ CONF_AUTO_REGULATION_EXPERT = "auto_regulation_expert"
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"
@@ -113,6 +113,10 @@ CONF_AUTO_FAN_MEDIUM = "auto_fan_medium"
CONF_AUTO_FAN_HIGH = "auto_fan_high"
CONF_AUTO_FAN_TURBO = "auto_fan_turbo"
# Global params into configuration.yaml
CONF_SHORT_EMA_PARAMS = "short_ema_params"
CONF_SAFETY_MODE = "safety_mode"
CONF_USE_MAIN_CENTRAL_CONFIG = "use_main_central_config"
CONF_USE_TPI_CENTRAL_CONFIG = "use_tpi_central_config"
CONF_USE_WINDOW_CENTRAL_CONFIG = "use_window_central_config"
@@ -129,6 +133,7 @@ CONF_CENTRAL_BOILER_ACTIVATION_SRV = "central_boiler_activation_service"
CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service"
CONF_USED_BY_CENTRAL_BOILER = "used_by_controls_central_boiler"
CONF_WINDOW_ACTION = "window_action"
DEFAULT_SHORT_EMA_PARAMS = {
"max_alpha": 0.5,
@@ -264,6 +269,7 @@ ALL_CONF = (
CONF_USED_BY_CENTRAL_BOILER,
CONF_CENTRAL_BOILER_ACTIVATION_SRV,
CONF_CENTRAL_BOILER_DEACTIVATION_SRV,
CONF_WINDOW_ACTION,
]
+ CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES
@@ -299,6 +305,18 @@ CONF_AUTO_FAN_MODES = [
CONF_AUTO_FAN_TURBO,
]
CONF_WINDOW_TURN_OFF = "window_turn_off"
CONF_WINDOW_FAN_ONLY = "window_fan_only"
CONF_WINDOW_FROST_TEMP = "window_frost_temp"
CONF_WINDOW_ECO_TEMP = "window_eco_temp"
CONF_WINDOW_ACTIONS = [
CONF_WINDOW_TURN_OFF,
CONF_WINDOW_FAN_ONLY,
CONF_WINDOW_FROST_TEMP,
CONF_WINDOW_ECO_TEMP,
]
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
SERVICE_SET_PRESENCE = "set_presence"

View File

@@ -0,0 +1,117 @@
# pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
# from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.core import HomeAssistant, CoreState # , callback
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_ADD_CENTRAL_BOILER_CONTROL,
overrides,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat selects with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
is_central_boiler = entry.data.get(CONF_ADD_CENTRAL_BOILER_CONTROL)
if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG or not is_central_boiler:
return
entities = [
ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data),
]
async_add_entities(entities, True)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.register_central_boiler_activation_number_threshold(entities[0])
class ActivateBoilerThresholdNumber(NumberEntity, RestoreEntity):
"""Representation of the threshold of the number of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Boiler Activation threshold"
self._attr_unique_id = "boiler_activation_threshold"
self._attr_value = self._attr_native_value = 1 # default value
self._attr_native_min_value = 1
self._attr_native_max_value = 9
self._attr_step = 1 # default value
self._attr_mode = NumberMode.AUTO
@property
def icon(self) -> str | None:
if isinstance(self._attr_native_value, int):
val = int(self._attr_native_value)
return f"mdi:numeric-{val}-box-outline"
else:
return "mdi:numeric-0-box-outline"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
if old_state is not None:
self._attr_value = self._attr_native_value = int(float(old_state.state))
@overrides
def set_native_value(self, value: float) -> None:
"""Change the value"""
int_value = int(value)
old_value = int(self._attr_native_value)
if int_value == old_value:
return
self._attr_value = self._attr_native_value = int_value
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -55,7 +55,7 @@ async def async_setup_entry(
class CentralModeSelect(SelectEntity, RestoreEntity):
"""Representation of a Energy sensor which exposes the energy"""
"""Representation of the central mode choice"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""

View File

@@ -3,9 +3,15 @@
import logging
import math
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.core import HomeAssistant, callback, Event, CoreState
from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAGE
from homeassistant.const import (
UnitOfTime,
UnitOfPower,
UnitOfEnergy,
PERCENTAGE,
EVENT_HOMEASSISTANT_START,
)
from homeassistant.components.sensor import (
SensorEntity,
@@ -16,9 +22,24 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.components.climate import (
ClimateEntity,
DOMAIN as CLIMATE_DOMAIN,
HVACAction,
HVACMode,
)
from .base_thermostat import BaseThermostat
from .vtherm_api import VersatileThermostatAPI
from .commons import VersatileThermostatBaseEntity
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_DEVICE_POWER,
CONF_PROP_FUNCTION,
@@ -28,6 +49,7 @@ from .const import (
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
overrides,
)
THRESHOLD_WATT_KILO = 100
@@ -49,33 +71,39 @@ async def async_setup_entry(
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
entities = None
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
return
entities = [NbActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data)]
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.register_nb_vtherm_active_boiler(entities[0])
else:
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) in [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
]:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) in [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
]:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
entities.append(OnTimeSensor(hass, unique_id, name, entry.data))
entities.append(OffTimeSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
entities.append(OnTimeSensor(hass, unique_id, name, entry.data))
entities.append(OffTimeSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(RegulatedTemperatureSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(
RegulatedTemperatureSensor(hass, unique_id, name, entry.data)
)
async_add_entities(entities, True)
@@ -597,3 +625,112 @@ class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 2
class NbActiveDeviceForBoilerSensor(SensorEntity):
"""Representation of the threshold of the number of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Nb device active for boiler"
self._attr_unique_id = "nb_device_active_boiler"
self._attr_value = self._attr_native_value = None # default value
self._entities = []
@property
def icon(self) -> str | None:
return "mdi:heat-wave"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 0
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
@callback
async def _async_startup_internal(*_):
_LOGGER.debug("%s - Calling async_startup_internal", self)
await self.listen_vtherms_entities()
if self.hass.state == CoreState.running:
await _async_startup_internal()
else:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
async def listen_vtherms_entities(self):
"""Initialize the listening of state change of VTherms"""
# Listen to all VTherm state change
self._entities = []
entities_id = []
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler:
self._entities.append(entity)
entities_id.append(entity.entity_id)
if len(self._entities) > 0:
# Arme l'écoute de la première entité
listener_cancel = async_track_state_change_event(
self._hass,
entities_id,
self.calculate_nb_active_devices,
)
_LOGGER.info(
"%s - VTherm that could controls the central boiler are %s",
self,
entities_id,
)
self.async_on_remove(listener_cancel)
else:
_LOGGER.debug("%s - no VTherm could controls the central boiler", self)
await self.calculate_nb_active_devices(None)
async def calculate_nb_active_devices(self, _):
"""Calculate the number of active VTherm that have an
influence on central boiler"""
_LOGGER.debug("%s - calculating the number of active VTherm", self)
nb_active = 0
for entity in self._entities:
_LOGGER.debug(
"Examining the hvac_action of %s",
entity.name,
)
if (
entity.hvac_mode == HVACMode.HEAT
and entity.hvac_action == HVACAction.HEATING
):
for under in entity.underlying_entities:
nb_active += 1 if under.is_device_active else 0
self._attr_native_value = nb_active
self.async_write_ha_state()
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -130,7 +130,8 @@
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)",
"use_window_central_config": "Use central window configuration"
"use_window_central_config": "Use central window configuration",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection",
@@ -138,7 +139,8 @@
"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",
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm"
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm",
"window_action": "Action to do if window is deteted as open"
}
},
"motion": {
@@ -362,7 +364,8 @@
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)",
"use_window_central_config": "Use central window configuration"
"use_window_central_config": "Use central window configuration",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection",
@@ -370,7 +373,8 @@
"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",
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm"
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm",
"window_action": "Action to do if window is deteted as open"
}
},
"motion": {
@@ -492,6 +496,22 @@
"auto_fan_high": "High",
"auto_fan_turbo": "Turbo"
}
},
"window_action": {
"options": {
"window_turn_off": "Turn off",
"window_fan_only": "Fan only",
"window_frost_temp": "Frost protect",
"window_eco_temp": "Eco"
}
},
"presets": {
"options": {
"frost": "Frost protect",
"eco": "Eco",
"comfort": "Comfort",
"boost": "Boost"
}
}
},
"entity": {

View File

@@ -130,7 +130,8 @@
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)",
"use_window_central_config": "Use central window configuration"
"use_window_central_config": "Use central window configuration",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection",
@@ -138,7 +139,8 @@
"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",
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm"
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm",
"window_action": "Action to do if window is deteted as open"
}
},
"motion": {
@@ -362,7 +364,8 @@
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)",
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)",
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)",
"use_window_central_config": "Use central window configuration"
"use_window_central_config": "Use central window configuration",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection",
@@ -370,7 +373,8 @@
"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",
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm"
"use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm",
"window_action": "Action to do if window is deteted as open"
}
},
"motion": {
@@ -492,6 +496,22 @@
"auto_fan_high": "High",
"auto_fan_turbo": "Turbo"
}
},
"window_action": {
"options": {
"window_turn_off": "Turn off",
"window_fan_only": "Fan only",
"window_frost_temp": "Frost protect",
"window_eco_temp": "Eco"
}
},
"presets": {
"options": {
"frost": "Frost protect",
"eco": "Eco",
"comfort": "Comfort",
"boost": "Boost"
}
}
},
"entity": {

View File

@@ -130,7 +130,8 @@
"window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)",
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)",
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)",
"use_window_central_config": "Utiliser la configuration centrale des ouvertures"
"use_window_central_config": "Utiliser la configuration centrale des ouvertures",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique",
@@ -138,7 +139,8 @@
"window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique",
"use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures"
"use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures",
"window_action": "Action a effectuer si la fenêtre est détectée comme ouverte"
}
},
"motion": {
@@ -368,7 +370,8 @@
"window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)",
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)",
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)",
"use_window_central_config": "Utiliser la configuration centrale des ouvertures"
"use_window_central_config": "Utiliser la configuration centrale des ouvertures",
"window_action": "Action"
},
"data_description": {
"window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique",
@@ -376,7 +379,8 @@
"window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique",
"window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique",
"use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures"
"use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures",
"window_action": "Action a effectuer si la fenêtre est détectée comme ouverte"
}
},
"motion": {
@@ -510,6 +514,22 @@
"auto_fan_high": "Forte",
"auto_fan_turbo": "Turbo"
}
},
"window_action": {
"options": {
"window_turn_off": "Eteindre",
"window_fan_only": "Ventilateur seul",
"window_frost_temp": "Hors gel",
"window_eco_temp": "Eco"
}
},
"presets": {
"options": {
"frost": "Hors-gel",
"eco": "Eco",
"comfort": "Confort",
"boost": "Renforcé (boost)"
}
}
},
"entity": {

View File

@@ -133,14 +133,14 @@ class UnderlyingEntity:
async def check_initial_state(self, hvac_mode: HVACMode):
"""Prevent the underlying to be on but thermostat is off"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
_LOGGER.info(
"%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
elif hvac_mode != HVACMode.OFF and not self.is_device_active:
_LOGGER.warning(
_LOGGER.info(
"%s - The hvac mode is %s, but the underlying device is not ON. Turning on device %s if needed",
self,
hvac_mode,
@@ -356,7 +356,7 @@ class UnderlyingSwitch(UnderlyingEntity):
_LOGGER.debug("%s - End of cycle (3)", self)
return
# safety mode could have change the on_time percent
await self._thermostat.check_security()
await self._thermostat.check_safety()
time = self._on_time_sec
action_label = "start"
@@ -771,12 +771,14 @@ class UnderlyingValve(UnderlyingEntity):
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
"""Set the HVACmode. Returns true if something have change"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
await self.turn_off()
if hvac_mode != HVACMode.OFF and not self.is_device_active:
await self.turn_on()
if self._hvac_mode != hvac_mode:
self._hvac_mode = hvac_mode
if hvac_mode == HVACMode.OFF:
await self.turn_off()
else:
await self.turn_on()
return True
else:
return False

View File

@@ -1,12 +1,13 @@
""" The API of Versatile Thermostat"""
import logging
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, HassJob
from homeassistant.config_entries import ConfigEntry
from .const import (
DOMAIN,
CONF_AUTO_REGULATION_EXPERT,
CONF_SHORT_EMA_PARAMS,
CONF_SAFETY_MODE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
)
@@ -46,7 +47,10 @@ class VersatileThermostatAPI(dict):
super().__init__()
self._expert_params = None
self._short_ema_params = None
self._safety_mode = None
self._central_boiler_entity = None
self._threshold_number_entity = None
self._nb_active_number_entity = None
def find_central_configuration(self):
"""Search for a central configuration"""
@@ -88,15 +92,35 @@ class VersatileThermostatAPI(dict):
if self._short_ema_params:
_LOGGER.debug("We have found short ema params %s", self._short_ema_params)
self._safety_mode = config.get(CONF_SAFETY_MODE)
if self._safety_mode:
_LOGGER.debug("We have found safet_mode params %s", self._safety_mode)
def register_central_boiler(self, central_boiler_entity):
"""Register the central boiler entity. This is used by the CentralBoilerBinarySensor
class to register itself at creation"""
self._central_boiler_entity = central_boiler_entity
def register_central_boiler_activation_number_threshold(
self, threshold_number_entity
):
"""register the two number entities needed for boiler activation"""
self._threshold_number_entity = threshold_number_entity
def register_nb_vtherm_active_boiler(self, nb_active_number_entity):
"""register the two number entities needed for boiler activation"""
self._nb_active_number_entity = nb_active_number_entity
if self._central_boiler_entity:
job = HassJob(
self._central_boiler_entity.listen_nb_active_vtherm_entity,
"init listen nb active VTherm",
)
self._hass.async_run_hass_job(job)
async def reload_central_boiler_entities_list(self):
"""Reload the central boiler list of entities if a central boiler is used"""
if self._central_boiler_entity is not None:
await self._central_boiler_entity.listen_vtherms_entities()
if self._nb_active_number_entity is not None:
await self._nb_active_number_entity.listen_vtherms_entities()
@property
def self_regulation_expert(self):
@@ -105,9 +129,43 @@ class VersatileThermostatAPI(dict):
@property
def short_ema_params(self):
"""Get the self regulation params"""
"""Get the short EMA params in expert mode"""
return self._short_ema_params
@property
def safety_mode(self):
"""Get the safety_mode params"""
return self._safety_mode
@property
def nb_active_vtherm_for_boiler(self):
"""Returns the number of active VTherm which have an
influence on boiler"""
if self._nb_active_number_entity is None:
return None
else:
return self._nb_active_number_entity.native_value
@property
def nb_active_vtherm_for_boiler_entity(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
return self._nb_active_number_entity
@property
def nb_active_device_for_boiler_threshold_entity(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
return self._threshold_number_entity
@property
def nb_active_device_for_boiler_threshold(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
if self._threshold_number_entity is None:
return None
return int(self._threshold_number_entity.native_value)
@property
def hass(self):
"""Get the HomeAssistant object"""

View File

@@ -152,6 +152,7 @@ MOCK_WINDOW_AUTO_CONFIG = {
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 1.0,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.0,
CONF_WINDOW_AUTO_MAX_DURATION: 5.0,
CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY,
}
MOCK_MOTION_CONFIG = {

View File

@@ -6,6 +6,9 @@ from unittest.mock import patch, call, PropertyMock
from datetime import datetime, timedelta
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
logging.getLogger().setLevel(logging.DEBUG)
@@ -46,6 +49,7 @@ async def test_window_management_time_not_enough(
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 0, # important to not been obliged to wait
CONF_WINDOW_ACTION: CONF_WINDOW_TURN_OFF,
},
)
@@ -134,6 +138,7 @@ async def test_window_management_time_enough(
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 0, # important to not been obliged to wait
CONF_WINDOW_ACTION: CONF_WINDOW_TURN_OFF,
},
)
@@ -242,7 +247,7 @@ async def test_window_management_time_enough(
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Window management"""
"""Test the auto Window management with fast slope down"""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -822,7 +827,6 @@ async def test_window_auto_no_on_percent(
entity.remove_thermostat()
# PR - Adding Window Bypass
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_bypass(hass: HomeAssistant, skip_hass_states_is_state):
@@ -1207,3 +1211,694 @@ async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is
# Clean the entity
entity.remove_thermostat()
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_action_fan_only(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Window management with the fan_only option"""
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: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "climate.mock_climate",
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 1,
CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY,
# CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6,
# CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6,
# CONF_WINDOW_AUTO_MAX_DURATION: 1, # 0 will deactivate window auto detection
},
)
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",
hvac_modes=[HVACMode.HEAT, HVACMode.COOL, HVACMode.FAN_ONLY],
)
# 1. intialize climate entity
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 entity.is_over_climate is True
assert entity.window_action == CONF_WINDOW_FAN_ONLY
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
assert entity.target_temperature == 18
assert entity.window_state is STATE_OFF
# 2. Open the window, condition of time is satisfied, check the thermostat and heater turns off
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"homeassistant.helpers.condition.state", return_value=True
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
event_timestamp = now - timedelta(minutes=2)
try_window_condition = await send_window_change_event(
entity, True, False, event_timestamp
)
await try_window_condition(None)
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.FAN_ONLY}
)
]
)
# The underlying should be in OFF hvac_mode
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.FAN_ONLY),
]
)
assert entity.window_state == STATE_ON
# The underlying should be in FAN_ONLY hvac_mode
assert entity.hvac_mode is HVACMode.FAN_ONLY
assert entity._saved_hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_COMFORT
# 3. Close the window
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"homeassistant.helpers.condition.state", return_value=True
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
event_timestamp = now - timedelta(minutes=1)
try_function = await send_window_change_event(
entity, False, True, event_timestamp, sleep=False
)
await try_function(None)
# Wait for initial delay of heater
await asyncio.sleep(0.3)
assert entity.window_state == STATE_OFF
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
),
],
any_order=False,
)
# The underlying should be in OFF hvac_mode
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_COMFORT
# Clean the entity
entity.remove_thermostat()
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_action_fan_only_ko(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Window management with the fan_only option but the underlyings doesn't have the FAN_ONLY mode
So the VTherm switch to OFF which is the fallback mode"""
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: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "climate.mock_climate",
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 1,
CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY,
# CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6,
# CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6,
# CONF_WINDOW_AUTO_MAX_DURATION: 1, # 0 will deactivate window auto detection
},
)
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",
hvac_modes=[HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO],
)
# 1. intialize climate entity
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 entity.is_over_climate is True
assert entity.window_action == CONF_WINDOW_FAN_ONLY
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
assert entity.target_temperature == 18
assert entity.window_state is STATE_OFF
# 2. Open the window, condition of time is satisfied, check the thermostat and heater turns off
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"homeassistant.helpers.condition.state", return_value=True
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
event_timestamp = now - timedelta(minutes=2)
try_window_condition = await send_window_change_event(
entity, True, False, event_timestamp
)
await try_window_condition(None)
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})]
)
assert entity.window_state == STATE_ON
assert entity.hvac_mode is HVACMode.OFF
# The underlying should be in OFF hvac_mode
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.OFF),
]
)
assert entity._saved_hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_COMFORT
# 3. Close the window
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"homeassistant.helpers.condition.state", return_value=True
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
event_timestamp = now - timedelta(minutes=1)
try_function = await send_window_change_event(
entity, False, True, event_timestamp, sleep=False
)
await try_function(None)
# Wait for initial delay of heater
await asyncio.sleep(0.3)
assert entity.window_state == STATE_OFF
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
),
],
any_order=False,
)
# The underlying should be in OFF hvac_mode
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.HEAT),
]
)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_COMFORT
# Clean the entity
entity.remove_thermostat()
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_action_eco_temp(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Window management with the eco_temp option"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 21,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test
CONF_WINDOW_ACTION: CONF_WINDOW_ECO_TEMP,
},
)
entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 21
assert entity.window_state is STATE_OFF
assert entity.is_window_auto_enabled is True
# 1. Initialize the slope algo with 2 measurements
event_timestamp = now + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# 2. Make the temperature down
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert entity.is_device_active is True
assert entity.hvac_mode is HVACMode.HEAT
assert entity.window_state is STATE_OFF
assert entity.window_auto_state is STATE_OFF
# 3. send one degre down in one minute
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 18, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 1
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert entity.last_temperature_slope == -6.24
assert entity.window_auto_state == STATE_ON
assert entity.window_state == STATE_OFF
# No change on HVACMode
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 17
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "start", "cause": "slope alert", "curve_slope": -6.24},
),
],
any_order=True,
)
# 4. send another 0.1 degre in one minute -> no change
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 17.9, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == -7.49
assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 17
# 5. send another plus 1.1 degre in one minute -> restore state
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{
"type": "end",
"cause": "end of slope alert",
"curve_slope": 0.42,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert entity.last_temperature_slope == 0.42
assert entity.window_auto_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 21
# Clean the entity
entity.remove_thermostat()
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Window management with the frost_temp option"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 21,
"frost_temp": 10,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test
CONF_WINDOW_ACTION: CONF_WINDOW_FROST_TEMP,
},
)
entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz)
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 21
assert entity.window_state is STATE_OFF
assert entity.is_window_auto_enabled is True
# 1. Initialize the slope algo with 2 measurements
event_timestamp = now + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# 2. Make the temperature down
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert entity.is_device_active is True
assert entity.hvac_mode is HVACMode.HEAT
assert entity.window_state is STATE_OFF
assert entity.window_auto_state is STATE_OFF
# 3. send one degre down in one minute
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 18, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 1
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert entity.last_temperature_slope == -6.24
assert entity.window_auto_state == STATE_ON
assert entity.window_state == STATE_OFF
# No change on HVACMode
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 10
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "start", "cause": "slope alert", "curve_slope": -6.24},
),
],
any_order=True,
)
# 4. send another 0.1 degre in one minute -> no change
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 17.9, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == -7.49
assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 10
# 5. send another plus 1.1 degre in one minute -> restore state
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
new_callable=PropertyMock,
return_value=False,
):
event_timestamp = event_timestamp + timedelta(minutes=1)
await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on
assert mock_send_event.call_count == 1
mock_send_event.assert_has_calls(
[
call.send_event(
EventType.WINDOW_AUTO_EVENT,
{
"type": "end",
"cause": "end of slope alert",
"curve_slope": 0.42,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
assert entity.last_temperature_slope == 0.42
assert entity.window_auto_state == STATE_OFF
assert entity.hvac_mode is HVACMode.HEAT
# No change on preset
assert entity.preset_mode is PRESET_BOOST
# The eco temp
assert entity.target_temperature == 21
# Clean the entity
entity.remove_thermostat()