Compare commits

..

14 Commits
4.0.1 ... 4.0.4

Author SHA1 Message Date
Jean-Marc Collin
96076bf7c2 Change the auto-regulation last calculation
Change auto-window detection to 30 sec of delay
2023-11-12 17:44:02 +00:00
Jean-Marc Collin
a3f7043f45 Add target_temperature_step management 2023-11-12 11:20:40 +00:00
Jean-Marc Collin
67c01b02ec Add default target temperature step 2023-11-12 10:37:44 +00:00
Jean-Marc Collin
ab1c6892df Issue #164 - multiple calls to regulation 2023-11-12 09:20:52 +00:00
Jean-Marc Collin
84c8ac4f59 Beers from @Gunnar M 2023-11-11 17:32:41 +00:00
Jean-Marc Collin
faab9648a7 Add Rointe incompatility 2023-11-11 16:41:03 +00:00
Jean-Marc Collin
a30ad38a53 Add logs for issue #164 2023-11-11 15:48:12 +00:00
Jean-Marc Collin
c0b186b8c1 Issue #181 - auto-window for over_climate doesn't work 2023-11-11 15:20:52 +00:00
Jean-Marc Collin
01e761aecd FIX is_device_active flag 2023-11-11 11:39:04 +00:00
Jean-Marc Collin
55a99054fa FIX overpowering is not always saved 2023-11-11 10:30:37 +00:00
Jean-Marc Collin
2c5078cd7f With update for UI card 2023-11-11 08:41:25 +00:00
Jean-Marc Collin
82348adef2 Add Heatzy incompatibility 2023-11-10 22:26:08 +00:00
Jean-Marc Collin
71aad211c6 Add power_percent in over_switch for UI 2023-11-07 00:09:34 +00:00
Jean-Marc Collin
a40f976fd1 Enhance messages when temp are not ready 2023-11-06 16:54:19 +00:00
9 changed files with 294 additions and 179 deletions

View File

@@ -85,7 +85,7 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une
La puissance de l'appareil doit maintenant être la puissance totale de tous les appareils controlée par le VTherm. Cela permet d'avoir des équipements hétérogènes de puissance différente. Dans le cas de plusieurs appareils contrôlés par un seul VTherm, vous devrez éditer et changer la valeur `device_power`. Vous devez configurer la puissance totale de tous les appareils. La puissance de l'appareil doit maintenant être la puissance totale de tous les appareils controlée par le VTherm. Cela permet d'avoir des équipements hétérogènes de puissance différente. Dans le cas de plusieurs appareils contrôlés par un seul VTherm, vous devrez éditer et changer la valeur `device_power`. Vous devez configurer la puissance totale de tous les appareils.
# Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78) # Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome pour les bières. Ca fait très plaisir et ça m'encourage à continuer ! Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M pour les bières. Ca fait très plaisir et ça m'encourage à continuer !
# Quand l'utiliser et ne pas l'utiliser # Quand l'utiliser et ne pas l'utiliser
@@ -105,7 +105,9 @@ Les installations avec fil pilote et diode d'activation bénéficie d'une option
## Incompatibilités ## Incompatibilités
Certains thermostat de type TRV sont réputés incompatibles avec le Versatile Thermostat. C'est le cas des vannes suivantes : Certains thermostat de type TRV sont réputés incompatibles avec le Versatile Thermostat. C'est le cas des vannes suivantes :
1. les vannes POPP de Danfoss avec retour de température. Il est impossible d'éteindre cette vanne et elle s'auto-régule d'elle-même causant des conflits avec le VTherm, 1. les vannes POPP de Danfoss avec retour de température. Il est impossible d'éteindre cette vanne et elle s'auto-régule d'elle-même causant des conflits avec le VTherm,
2. les vannes thermstatiques "Homematic radio". Elles ont un cycle de service incompatible avec une commande par le Versatile Thermostat 2. les vannes thermstatiques "Homematic radio". Elles ont un cycle de service incompatible avec une commande par le Versatile Thermostat,
3. les thermostats de type Heatzy qui ne supportent pas les commandes de type set_temperature
4. les thermostats de type Rointe ont tendance a se réveiller tout seul. Le reste fonctionne normalement.
# Pourquoi une nouvelle implémentation du thermostat ? # Pourquoi une nouvelle implémentation du thermostat ?

View File

@@ -83,7 +83,7 @@ This custom component for Home Assistant is an upgrade and is a complete rewrite
The power of the device should now be the total power of all controler devices by the VTherm. This allow to have eterogeneous equipment with different power. In case of multi-devices controlled by a single VTherm you will have to edit and change the `device_power` value. Set the total power of all devices. The power of the device should now be the total power of all controler devices by the VTherm. This allow to have eterogeneous equipment with different power. In case of multi-devices controlled by a single VTherm you will have to edit and change the `device_power` value. Set the total power of all devices.
# Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78) # Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome for the beers. It's very nice and encourages me to continue! Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M for the beers. It's very nice and encourages me to continue!
# When to use / not use # When to use / not use
This thermostat can control 3 types of equipment: This thermostat can control 3 types of equipment:
@@ -103,7 +103,9 @@ Installations with pilot wire and activation diode benefit from an option which
Some TRV type thermostats are known to be incompatible with the Versatile Thermostat. This is the case for the following valves: Some TRV type thermostats are known to be incompatible with the Versatile Thermostat. This is the case for the following valves:
1. Danfoss POPP valves with temperature feedback. It is impossible to turn off this valve and it self-regulates, causing conflicts with the VTherm, 1. Danfoss POPP valves with temperature feedback. It is impossible to turn off this valve and it self-regulates, causing conflicts with the VTherm,
2. “Homematic radio” thermostatic valves. They have a duty cycle incompatible with control by the Versatile Thermostat 2. “Homematic radio” thermostatic valves. They have a duty cycle incompatible with control by the Versatile Thermostat,
3. Thermostat of type Heatzy which doesn't supports the set_temperature command.
4. Thermostats of type Rointe tends to awake alone even if VTherm turns it off. Others functions works fine.
# Why another thermostat implementation ? # Why another thermostat implementation ?

View File

@@ -138,6 +138,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
ClimateEntity._entity_component_unrecorded_attributes.union( ClimateEntity._entity_component_unrecorded_attributes.union(
frozenset( frozenset(
{ {
"is_on",
"type", "type",
"eco_temp", "eco_temp",
"boost_temp", "boost_temp",
@@ -170,6 +171,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"presence_sensor_entity_id", "presence_sensor_entity_id",
"power_sensor_entity_id", "power_sensor_entity_id",
"max_power_sensor_entity_id", "max_power_sensor_entity_id",
"temperature_unit",
"is_device_active",
"target_temperature_step",
} }
) )
) )
@@ -294,6 +298,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR)
self._attr_max_temp = entry_infos.get(CONF_TEMP_MAX) self._attr_max_temp = entry_infos.get(CONF_TEMP_MAX)
self._attr_min_temp = entry_infos.get(CONF_TEMP_MIN) self._attr_min_temp = entry_infos.get(CONF_TEMP_MIN)
# Default value not configurable
self._attr_target_temperature_step = 0.1
self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR)
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR)
@@ -901,27 +907,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Return the sensor temperature.""" """Return the sensor temperature."""
return self._cur_temp return self._cur_temp
@property
def target_temperature_step(self) -> float | None:
"""Return the supported step of target temperature."""
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
return None
@property @property
def is_aux_heat(self) -> bool | None: def is_aux_heat(self) -> bool | None:
"""Return true if aux heater. """Return true if aux heater.
@@ -1032,6 +1017,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Returns the number of underlying entities""" """Returns the number of underlying entities"""
return len(self._underlyings) return len(self._underlyings)
@property
def is_on(self) -> bool:
"""True if the VTherm is on (! HVAC_OFF)"""
return self.hvac_mode and self.hvac_mode != HVACMode.OFF
def underlying_entity_id(self, index=0) -> str | None: def underlying_entity_id(self, index=0) -> str | None:
"""The climate_entity_id. Added for retrocompatibility reason""" """The climate_entity_id. Added for retrocompatibility reason"""
if index < self.nb_underlying_entities: if index < self.nb_underlying_entities:
@@ -1587,7 +1577,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
await self.async_control_heating(force=True) await self.async_control_heating(force=True)
async def _async_update_presence(self, new_state): async def _async_update_presence(self, new_state):
_LOGGER.debug("%s - Updating presence. New state is %s", self, new_state) _LOGGER.info("%s - Updating presence. New state is %s", self, new_state)
self._presence_state = new_state self._presence_state = new_state
if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False:
_LOGGER.info( _LOGGER.info(
@@ -1605,24 +1595,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]:
return return
# Change temperature with preset named _away
# new_temp = None
# if new_state == STATE_ON or new_state == STATE_HOME:
# new_temp = self._presets[self._attr_preset_mode]
# _LOGGER.info(
# "%s - Someone is back home. Restoring temperature to %.2f",
# self,
# new_temp,
# )
# else:
# new_temp = self._presets_away[
# self.get_preset_away_name(self._attr_preset_mode)
# ]
# _LOGGER.info(
# "%s - No one is at home. Apply temperature %.2f",
# self,
# new_temp,
# )
new_temp = self.find_preset_temp(self.preset_mode) new_temp = self.find_preset_temp(self.preset_mode)
if new_temp is not None: if new_temp is not None:
_LOGGER.debug( _LOGGER.debug(
@@ -1715,8 +1687,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
and self.hvac_mode != HVACMode.OFF and self.hvac_mode != HVACMode.OFF
): ):
if ( if (
not self.proportional_algorithm self.proportional_algorithm
or self.proportional_algorithm.on_percent <= 0.0 and self.proportional_algorithm.on_percent <= 0.0
): ):
_LOGGER.info( _LOGGER.info(
"%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event", "%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event",
@@ -1881,7 +1853,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
}, },
) )
self._overpowering_state = ret if self._overpowering_state != ret:
self._overpowering_state = ret
self.update_custom_attributes()
return self._overpowering_state return self._overpowering_state
async def check_security(self) -> bool: async def check_security(self) -> bool:
@@ -2107,6 +2082,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Update the custom extra attributes for the entity""" """Update the custom extra attributes for the entity"""
self._attr_extra_state_attributes: dict(str, str) = { self._attr_extra_state_attributes: dict(str, str) = {
"is_on": self.is_on,
"hvac_action": self.hvac_action, "hvac_action": self.hvac_action,
"hvac_mode": self.hvac_mode, "hvac_mode": self.hvac_mode,
"preset_mode": self.preset_mode, "preset_mode": self.preset_mode,
@@ -2126,6 +2102,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"power_temp": self._power_temp, "power_temp": self._power_temp,
# Already in super class - "target_temp": self.target_temperature, # Already in super class - "target_temp": self.target_temperature,
# Already in super class - "current_temp": self._cur_temp, # Already in super class - "current_temp": self._cur_temp,
"target_temperature_step": self.target_temperature_step,
"ext_current_temperature": self._cur_ext_temp, "ext_current_temperature": self._cur_ext_temp,
"ac_mode": self._ac_mode, "ac_mode": self._ac_mode,
"current_power": self._current_power, "current_power": self._current_power,
@@ -2166,6 +2143,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"presence_sensor_entity_id": self._presence_sensor_entity_id, "presence_sensor_entity_id": self._presence_sensor_entity_id,
"power_sensor_entity_id": self._power_sensor_entity_id, "power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_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,
} }
@callback @callback

View File

@@ -12,7 +12,7 @@ from datetime import datetime
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# To filter bad values # To filter bad values
MIN_DELTA_T_SEC = 10 # two temp mesure should be > 10 sec MIN_DELTA_T_SEC = 30 # two temp mesure should be > 10 sec
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point

View File

@@ -5,8 +5,9 @@ import logging
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class PITemperatureRegulator: class PITemperatureRegulator:
""" A class implementing a PI Algorithm """A class implementing a PI Algorithm
PI algorithms calculate a target temperature by adding an offset which is calculating as follow: PI algorithms calculate a target temperature by adding an offset which is calculating as follow:
- offset = kp * error + ki * accumulated_error - offset = kp * error + ki * accumulated_error
@@ -16,30 +17,48 @@ class PITemperatureRegulator:
- call set_target_temp when the target temperature change. - call set_target_temp when the target temperature change.
""" """
def __init__(self, target_temp: float, kp: float, ki: float, k_ext: float, offset_max: float, stabilization_threshold: float, accumulated_error_threshold: float): def __init__(
self.target_temp:float = target_temp self,
self.kp:float = kp # proportionnel gain target_temp: float,
self.ki:float = ki # integral gain kp: float,
self.k_ext:float = k_ext # exterior gain ki: float,
self.offset_max:float = offset_max k_ext: float,
self.stabilization_threshold:float = stabilization_threshold offset_max: float,
self.accumulated_error:float = 0 stabilization_threshold: float,
self.accumulated_error_threshold:float = accumulated_error_threshold accumulated_error_threshold: float,
):
self.target_temp: float = target_temp
self.kp: float = kp # proportionnel gain
self.ki: float = ki # integral gain
self.k_ext: float = k_ext # exterior gain
self.offset_max: float = offset_max
self.stabilization_threshold: float = stabilization_threshold
self.accumulated_error: float = 0
self.accumulated_error_threshold: float = accumulated_error_threshold
def reset_accumulated_error(self): def reset_accumulated_error(self):
""" Reset the accumulated error """ """Reset the accumulated error"""
self.accumulated_error = 0 self.accumulated_error = 0
def set_target_temp(self, target_temp): def set_target_temp(self, target_temp):
""" Set the new target_temp""" """Set the new target_temp"""
self.target_temp = target_temp self.target_temp = target_temp
# Do not reset the accumulated error # Do not reset the accumulated error
# self.accumulated_error = 0 # self.accumulated_error = 0
def calculate_regulated_temperature(self, internal_temp: float, external_temp:float): # pylint: disable=unused-argument def calculate_regulated_temperature(
""" Calculate a new target_temp given some temperature""" self, internal_temp: float, external_temp: float
if internal_temp is None or external_temp is None: ): # pylint: disable=unused-argument
_LOGGER.warning("Internal_temp or external_temp are not set. Regulation will be suspended") """Calculate a new target_temp given some temperature"""
if internal_temp is None:
_LOGGER.warning(
"Temporarily skipping the self-regulation algorithm while the configured sensor for room temperature is unavailable"
)
return self.target_temp
if external_temp is None:
_LOGGER.warning(
"Temporarily skipping the self-regulation algorithm while the configured sensor for outdoor temperature is unavailable"
)
return self.target_temp return self.target_temp
# Calculate the error factor (P) # Calculate the error factor (P)
@@ -49,7 +68,10 @@ class PITemperatureRegulator:
self.accumulated_error += error self.accumulated_error += error
# Capping of the error # Capping of the error
self.accumulated_error = min(self.accumulated_error_threshold, max(-self.accumulated_error_threshold, self.accumulated_error)) self.accumulated_error = min(
self.accumulated_error_threshold,
max(-self.accumulated_error_threshold, self.accumulated_error),
)
# Calculate the offset (proportionnel + intégral) # Calculate the offset (proportionnel + intégral)
offset = self.kp * error + self.ki * self.accumulated_error offset = self.kp * error + self.ki * self.accumulated_error
@@ -62,7 +84,6 @@ class PITemperatureRegulator:
total_offset = offset + offset_ext total_offset = offset + offset_ext
total_offset = min(self.offset_max, max(-self.offset_max, total_offset)) total_offset = min(self.offset_max, max(-self.offset_max, total_offset))
# If temperature is near the target_temp, reset the accumulated_error # If temperature is near the target_temp, reset the accumulated_error
if abs(error) < self.stabilization_threshold: if abs(error) < self.stabilization_threshold:
_LOGGER.debug("Stabilisation") _LOGGER.debug("Stabilisation")
@@ -70,7 +91,14 @@ class PITemperatureRegulator:
result = round(self.target_temp + total_offset, 1) result = round(self.target_temp + total_offset, 1)
_LOGGER.debug("PITemperatureRegulator - Error: %.2f accumulated_error: %.2f offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f", _LOGGER.debug(
error, self.accumulated_error, offset, offset_ext, self.target_temp, result) "PITemperatureRegulator - Error: %.2f accumulated_error: %.2f offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f",
error,
self.accumulated_error,
offset,
offset_ext,
self.target_temp,
result,
)
return result return result

View File

@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorDeviceClass, SensorDeviceClass,
SensorStateClass, SensorStateClass,
UnitOfTemperature UnitOfTemperature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -54,7 +54,10 @@ async def async_setup_entry(
] ]
if entry.data.get(CONF_DEVICE_POWER): if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data)) entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) in [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_VALVE]: if entry.data.get(CONF_THERMOSTAT_TYPE) in [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
]:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data)) entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI: if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
@@ -202,6 +205,9 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
if self.my_climate and self.my_climate.proportional_algorithm if self.my_climate and self.my_climate.proportional_algorithm
else None else None
) )
if on_percent is None:
return
if math.isnan(on_percent) or math.isinf(on_percent): if math.isnan(on_percent) or math.isinf(on_percent):
raise ValueError(f"Sensor has illegal state {on_percent}") raise ValueError(f"Sensor has illegal state {on_percent}")
@@ -234,6 +240,7 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Return the suggested number of decimal digits for display.""" """Return the suggested number of decimal digits for display."""
return 1 return 1
class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity): class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on percent sensor which exposes the on_percent in a cycle""" """Representation of a on percent sensor which exposes the on_percent in a cycle"""
@@ -295,6 +302,10 @@ class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
if self.my_climate and self.my_climate.proportional_algorithm if self.my_climate and self.my_climate.proportional_algorithm
else None else None
) )
if on_time is None:
return
if math.isnan(on_time) or math.isinf(on_time): if math.isnan(on_time) or math.isinf(on_time):
raise ValueError(f"Sensor has illegal state {on_time}") raise ValueError(f"Sensor has illegal state {on_time}")
@@ -340,6 +351,9 @@ class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
if self.my_climate and self.my_climate.proportional_algorithm if self.my_climate and self.my_climate.proportional_algorithm
else None else None
) )
if off_time is None:
return
if math.isnan(off_time) or math.isinf(off_time): if math.isnan(off_time) or math.isinf(off_time):
raise ValueError(f"Sensor has illegal state {off_time}") raise ValueError(f"Sensor has illegal state {off_time}")
@@ -476,6 +490,7 @@ class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Return the suggested number of decimal digits for display.""" """Return the suggested number of decimal digits for display."""
return 2 return 2
class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Energy sensor which exposes the energy""" """Representation of a Energy sensor which exposes the energy"""
@@ -493,7 +508,9 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
if math.isnan(self.my_climate.regulated_target_temp) or math.isinf( if math.isnan(self.my_climate.regulated_target_temp) or math.isinf(
self.my_climate.regulated_target_temp self.my_climate.regulated_target_temp
): ):
raise ValueError(f"Sensor has illegal state {self.my_climate.regulated_target_temp}") raise ValueError(
f"Sensor has illegal state {self.my_climate.regulated_target_temp}"
)
old_state = self._attr_native_value old_state = self._attr_native_value
self._attr_native_value = round( self._attr_native_value = round(

View File

@@ -4,7 +4,10 @@ import logging
from datetime import timedelta, datetime from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.components.climate import HVACAction, HVACMode
@@ -29,27 +32,40 @@ from .const import (
RegulationParamSlow, RegulationParamSlow,
RegulationParamLight, RegulationParamLight,
RegulationParamMedium, RegulationParamMedium,
RegulationParamStrong RegulationParamStrong,
) )
from .underlyings import UnderlyingClimate from .underlyings import UnderlyingClimate
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ThermostatOverClimate(BaseThermostat): class ThermostatOverClimate(BaseThermostat):
"""Representation of a base class for a Versatile Thermostat over a climate""" """Representation of a base class for a Versatile Thermostat over a climate"""
_auto_regulation_mode:str = None
_auto_regulation_mode: str = None
_regulation_algo = None _regulation_algo = None
_regulated_target_temp: float = None _regulated_target_temp: float = None
_auto_regulation_dtemp: float = None _auto_regulation_dtemp: float = None
_auto_regulation_period_min: int = None _auto_regulation_period_min: int = None
_last_regulation_change: datetime = None _last_regulation_change: datetime = None
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( _entity_component_unrecorded_attributes = (
{ BaseThermostat._entity_component_unrecorded_attributes.union(
"is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1", frozenset(
"underlying_climate_2", "underlying_climate_3", "regulation_accumulated_error" {
})) "is_over_climate",
"start_hvac_action_date",
"underlying_climate_0",
"underlying_climate_1",
"underlying_climate_2",
"underlying_climate_3",
"regulation_accumulated_error",
"auto_regulation_mode",
}
)
)
)
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat over switch.""" """Initialize the thermostat over switch."""
@@ -60,12 +76,12 @@ class ThermostatOverClimate(BaseThermostat):
@property @property
def is_over_climate(self) -> bool: def is_over_climate(self) -> bool:
""" True if the Thermostat is over_climate""" """True if the Thermostat is over_climate"""
return True return True
@property @property
def hvac_action(self) -> HVACAction | None: def hvac_action(self) -> HVACAction | None:
""" Returns the current hvac_action by checking all hvac_action of the underlyings """ """Returns the current hvac_action by checking all hvac_action of the underlyings"""
# if one not IDLE or OFF -> return it # if one not IDLE or OFF -> return it
# else if one IDLE -> IDLE # else if one IDLE -> IDLE
@@ -92,29 +108,53 @@ class ThermostatOverClimate(BaseThermostat):
await self._send_regulated_temperature(force=True) await self._send_regulated_temperature(force=True)
async def _send_regulated_temperature(self, force=False): async def _send_regulated_temperature(self, force=False):
""" Sends the regulated temperature to all underlying """ """Sends the regulated temperature to all underlying"""
_LOGGER.info(
"%s - Calling ThermostatClimate._send_regulated_temperature force=%s",
self,
force,
)
now: datetime = NowClass.get_now(self._hass)
period = float((now - self._last_regulation_change).total_seconds()) / 60.0
if not force and period < self._auto_regulation_period_min:
_LOGGER.info(
"%s - period (%.1f) min is < %.0f min -> forget the regulation send",
self,
period,
self._auto_regulation_period_min,
)
return
if not self._regulated_target_temp: if not self._regulated_target_temp:
self._regulated_target_temp = self.target_temperature self._regulated_target_temp = self.target_temperature
_LOGGER.info("%s - regulation calculation will be done", self)
self._last_regulation_change = now
new_regulated_temp = round_to_nearest( new_regulated_temp = round_to_nearest(
self._regulation_algo.calculate_regulated_temperature(self.current_temperature, self._cur_ext_temp), self._regulation_algo.calculate_regulated_temperature(
self._auto_regulation_dtemp) self.current_temperature, self._cur_ext_temp
),
self._auto_regulation_dtemp,
)
dtemp = new_regulated_temp - self._regulated_target_temp dtemp = new_regulated_temp - self._regulated_target_temp
if not force and abs(dtemp) < self._auto_regulation_dtemp: if not force and abs(dtemp) < self._auto_regulation_dtemp:
_LOGGER.debug("%s - dtemp (%.1f) is < %.1f -> forget the regulation send", self, dtemp, self._auto_regulation_dtemp) _LOGGER.info(
"%s - dtemp (%.1f) is < %.1f -> forget the regulation send",
self,
dtemp,
self._auto_regulation_dtemp,
)
return return
now:datetime = NowClass.get_now(self._hass)
period = float((now - self._last_regulation_change).total_seconds()) / 60.
if not force and period < self._auto_regulation_period_min:
_LOGGER.debug("%s - period (%.1f) is < %.0f -> forget the regulation send", self, period, self._auto_regulation_period_min)
return
self._regulated_target_temp = new_regulated_temp self._regulated_target_temp = new_regulated_temp
_LOGGER.info("%s - Regulated temp have changed to %.1f. Resend it to underlyings", self, new_regulated_temp) _LOGGER.info(
self._last_regulation_change = now "%s - Regulated temp have changed to %.1f. Resend it to underlyings",
self,
new_regulated_temp,
)
for under in self._underlyings: for under in self._underlyings:
await under.set_temperature( await under.set_temperature(
@@ -123,7 +163,7 @@ class ThermostatOverClimate(BaseThermostat):
@overrides @overrides
def post_init(self, entry_infos): def post_init(self, entry_infos):
""" Initialize the Thermostat""" """Initialize the Thermostat"""
super().post_init(entry_infos) super().post_init(entry_infos)
for climate in [ for climate in [
@@ -142,14 +182,24 @@ class ThermostatOverClimate(BaseThermostat):
) )
self.choose_auto_regulation_mode( self.choose_auto_regulation_mode(
entry_infos.get(CONF_AUTO_REGULATION_MODE) if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None else CONF_AUTO_REGULATION_NONE entry_infos.get(CONF_AUTO_REGULATION_MODE)
if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None
else CONF_AUTO_REGULATION_NONE
) )
self._auto_regulation_dtemp = entry_infos.get(CONF_AUTO_REGULATION_DTEMP) if entry_infos.get(CONF_AUTO_REGULATION_DTEMP) is not None else 0.5 self._auto_regulation_dtemp = (
self._auto_regulation_period_min = entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) if entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None else 5 entry_infos.get(CONF_AUTO_REGULATION_DTEMP)
if entry_infos.get(CONF_AUTO_REGULATION_DTEMP) is not None
else 0.5
)
self._auto_regulation_period_min = (
entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN)
if entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
else 5
)
def choose_auto_regulation_mode(self, auto_regulation_mode): def choose_auto_regulation_mode(self, auto_regulation_mode):
""" Choose or change the regulation mode""" """Choose or change the regulation mode"""
self._auto_regulation_mode = auto_regulation_mode self._auto_regulation_mode = auto_regulation_mode
if self._auto_regulation_mode == CONF_AUTO_REGULATION_LIGHT: if self._auto_regulation_mode == CONF_AUTO_REGULATION_LIGHT:
self._regulation_algo = PITemperatureRegulator( self._regulation_algo = PITemperatureRegulator(
@@ -159,7 +209,8 @@ class ThermostatOverClimate(BaseThermostat):
RegulationParamLight.k_ext, RegulationParamLight.k_ext,
RegulationParamLight.offset_max, RegulationParamLight.offset_max,
RegulationParamLight.stabilization_threshold, RegulationParamLight.stabilization_threshold,
RegulationParamLight.accumulated_error_threshold) RegulationParamLight.accumulated_error_threshold,
)
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_MEDIUM: elif self._auto_regulation_mode == CONF_AUTO_REGULATION_MEDIUM:
self._regulation_algo = PITemperatureRegulator( self._regulation_algo = PITemperatureRegulator(
self.target_temperature, self.target_temperature,
@@ -168,7 +219,8 @@ class ThermostatOverClimate(BaseThermostat):
RegulationParamMedium.k_ext, RegulationParamMedium.k_ext,
RegulationParamMedium.offset_max, RegulationParamMedium.offset_max,
RegulationParamMedium.stabilization_threshold, RegulationParamMedium.stabilization_threshold,
RegulationParamMedium.accumulated_error_threshold) RegulationParamMedium.accumulated_error_threshold,
)
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_STRONG: elif self._auto_regulation_mode == CONF_AUTO_REGULATION_STRONG:
self._regulation_algo = PITemperatureRegulator( self._regulation_algo = PITemperatureRegulator(
self.target_temperature, self.target_temperature,
@@ -177,7 +229,8 @@ class ThermostatOverClimate(BaseThermostat):
RegulationParamStrong.k_ext, RegulationParamStrong.k_ext,
RegulationParamStrong.offset_max, RegulationParamStrong.offset_max,
RegulationParamStrong.stabilization_threshold, RegulationParamStrong.stabilization_threshold,
RegulationParamStrong.accumulated_error_threshold) RegulationParamStrong.accumulated_error_threshold,
)
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_SLOW: elif self._auto_regulation_mode == CONF_AUTO_REGULATION_SLOW:
self._regulation_algo = PITemperatureRegulator( self._regulation_algo = PITemperatureRegulator(
self.target_temperature, self.target_temperature,
@@ -186,11 +239,13 @@ class ThermostatOverClimate(BaseThermostat):
RegulationParamSlow.k_ext, RegulationParamSlow.k_ext,
RegulationParamSlow.offset_max, RegulationParamSlow.offset_max,
RegulationParamSlow.stabilization_threshold, RegulationParamSlow.stabilization_threshold,
RegulationParamSlow.accumulated_error_threshold) RegulationParamSlow.accumulated_error_threshold,
)
else: else:
# A default empty algo (which does nothing) # A default empty algo (which does nothing)
self._regulation_algo = PITemperatureRegulator( self._regulation_algo = PITemperatureRegulator(
self.target_temperature, 0, 0, 0, 0, 0.1, 0) self.target_temperature, 0, 0, 0, 0, 0.1, 0
)
@overrides @overrides
async def async_added_to_hass(self): async def async_added_to_hass(self):
@@ -219,27 +274,37 @@ class ThermostatOverClimate(BaseThermostat):
@overrides @overrides
def update_custom_attributes(self): def update_custom_attributes(self):
""" Custom attributes """ """Custom attributes"""
super().update_custom_attributes() super().update_custom_attributes()
self._attr_extra_state_attributes["is_over_climate"] = self.is_over_climate self._attr_extra_state_attributes["is_over_climate"] = self.is_over_climate
self._attr_extra_state_attributes["start_hvac_action_date"] = ( self._attr_extra_state_attributes[
self._underlying_climate_start_hvac_action_date) "start_hvac_action_date"
self._attr_extra_state_attributes["underlying_climate_0"] = ( ] = self._underlying_climate_start_hvac_action_date
self._underlyings[0].entity_id) self._attr_extra_state_attributes["underlying_climate_0"] = self._underlyings[
0
].entity_id
self._attr_extra_state_attributes["underlying_climate_1"] = ( self._attr_extra_state_attributes["underlying_climate_1"] = (
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
) )
self._attr_extra_state_attributes["underlying_climate_2"] = ( self._attr_extra_state_attributes["underlying_climate_2"] = (
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
) )
self._attr_extra_state_attributes["underlying_climate_3"] = ( self._attr_extra_state_attributes["underlying_climate_3"] = (
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
) )
if self.is_regulated: if self.is_regulated:
self._attr_extra_state_attributes["regulated_target_temperature"] = self._regulated_target_temp self._attr_extra_state_attributes["is_regulated"] = self.is_regulated
self._attr_extra_state_attributes["regulation_accumulated_error"] = self._regulation_algo.accumulated_error self._attr_extra_state_attributes[
"regulated_target_temperature"
] = self._regulated_target_temp
self._attr_extra_state_attributes[
"auto_regulation_mode"
] = self.auto_regulation_mode
self._attr_extra_state_attributes[
"regulation_accumulated_error"
] = self._regulation_algo.accumulated_error
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug( _LOGGER.debug(
@@ -473,17 +538,17 @@ class ThermostatOverClimate(BaseThermostat):
@property @property
def auto_regulation_mode(self): def auto_regulation_mode(self):
""" Get the regulation mode """ """Get the regulation mode"""
return self._auto_regulation_mode return self._auto_regulation_mode
@property @property
def regulated_target_temp(self): def regulated_target_temp(self):
""" Get the regulated target temperature """ """Get the regulated target temperature"""
return self._regulated_target_temp return self._regulated_target_temp
@property @property
def is_regulated(self): def is_regulated(self):
""" Check if the ThermostatOverClimate is regulated """ """Check if the ThermostatOverClimate is regulated"""
return self.auto_regulation_mode != CONF_AUTO_REGULATION_NONE return self.auto_regulation_mode != CONF_AUTO_REGULATION_NONE
@property @property
@@ -668,7 +733,11 @@ class ThermostatOverClimate(BaseThermostat):
target: target:
entity_id: climate.thermostat_1 entity_id: climate.thermostat_1
""" """
_LOGGER.info("%s - Calling service_set_auto_regulation_mode, auto_regulation_mode: %s", self, auto_regulation_mode) _LOGGER.info(
"%s - Calling service_set_auto_regulation_mode, auto_regulation_mode: %s",
self,
auto_regulation_mode,
)
if auto_regulation_mode == "None": if auto_regulation_mode == "None":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_NONE) self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_NONE)
elif auto_regulation_mode == "Light": elif auto_regulation_mode == "Light":

View File

@@ -12,7 +12,7 @@ from .const import (
CONF_HEATER_3, CONF_HEATER_3,
CONF_HEATER_4, CONF_HEATER_4,
CONF_INVERSE_SWITCH, CONF_INVERSE_SWITCH,
overrides overrides,
) )
from .base_thermostat import BaseThermostat from .base_thermostat import BaseThermostat
@@ -21,15 +21,31 @@ from .prop_algorithm import PropAlgorithm
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ThermostatOverSwitch(BaseThermostat): class ThermostatOverSwitch(BaseThermostat):
"""Representation of a base class for a Versatile Thermostat over a switch.""" """Representation of a base class for a Versatile Thermostat over a switch."""
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( _entity_component_unrecorded_attributes = (
{ BaseThermostat._entity_component_unrecorded_attributes.union(
"is_over_switch", "underlying_switch_0", "underlying_switch_1", frozenset(
"underlying_switch_2", "underlying_switch_3", "on_time_sec", "off_time_sec", {
"cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" "is_over_switch",
})) "is_inversed",
"underlying_switch_0",
"underlying_switch_1",
"underlying_switch_2",
"underlying_switch_3",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
"power_percent",
}
)
)
)
# useless for now # useless for now
# def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
@@ -39,17 +55,25 @@ class ThermostatOverSwitch(BaseThermostat):
@property @property
def is_over_switch(self) -> bool: def is_over_switch(self) -> bool:
""" True if the Thermostat is over_switch""" """True if the Thermostat is over_switch"""
return True return True
@property @property
def is_inversed(self) -> bool: def is_inversed(self) -> bool:
""" True if the switch is inversed (for pilot wire and diode)""" """True if the switch is inversed (for pilot wire and diode)"""
return self._is_inversed is True return self._is_inversed is True
@property
def power_percent(self) -> float | None:
"""Get the current on_percent value"""
if self._prop_algorithm:
return round(self._prop_algorithm.on_percent * 100, 0)
else:
return None
@overrides @overrides
def post_init(self, entry_infos): def post_init(self, entry_infos):
""" Initialize the Thermostat""" """Initialize the Thermostat"""
super().post_init(entry_infos) super().post_init(entry_infos)
@@ -96,31 +120,34 @@ class ThermostatOverSwitch(BaseThermostat):
async_track_state_change_event( async_track_state_change_event(
self.hass, [switch.entity_id], self._async_switch_changed self.hass, [switch.entity_id], self._async_switch_changed
) )
) )
self.hass.create_task(self.async_control_heating()) self.hass.create_task(self.async_control_heating())
@overrides @overrides
def update_custom_attributes(self): def update_custom_attributes(self):
""" Custom attributes """ """Custom attributes"""
super().update_custom_attributes() super().update_custom_attributes()
self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch
self._attr_extra_state_attributes["underlying_switch_0"] = ( self._attr_extra_state_attributes["is_inversed"] = self.is_inversed
self._underlyings[0].entity_id) self._attr_extra_state_attributes["underlying_switch_0"] = self._underlyings[
0
].entity_id
self._attr_extra_state_attributes["underlying_switch_1"] = ( self._attr_extra_state_attributes["underlying_switch_1"] = (
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
) )
self._attr_extra_state_attributes["underlying_switch_2"] = ( self._attr_extra_state_attributes["underlying_switch_2"] = (
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
) )
self._attr_extra_state_attributes["underlying_switch_3"] = ( self._attr_extra_state_attributes["underlying_switch_3"] = (
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
) )
self._attr_extra_state_attributes[ self._attr_extra_state_attributes[
"on_percent" "on_percent"
] = self._prop_algorithm.on_percent ] = self._prop_algorithm.on_percent
self._attr_extra_state_attributes["power_percent"] = self.power_percent
self._attr_extra_state_attributes[ self._attr_extra_state_attributes[
"on_time_sec" "on_time_sec"
] = self._prop_algorithm.on_time_sec ] = self._prop_algorithm.on_time_sec
@@ -182,3 +209,4 @@ class ThermostatOverSwitch(BaseThermostat):
if old_state is None: if old_state is None:
self.hass.create_task(self._check_initial_state()) self.hass.create_task(self._check_initial_state())
self.async_write_ha_state() self.async_write_ha_state()
self.update_custom_attributes()

View File

@@ -242,7 +242,7 @@ async def test_window_management_time_enough(
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Power management""" """Test the Window management"""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@@ -430,11 +430,11 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
title="TheOverSwitchMockName", title="TheOverClimateMockName",
unique_id="uniqueId", unique_id="uniqueId",
data={ data={
CONF_NAME: "TheOverSwitchMockName", CONF_NAME: "TheOverClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
@@ -447,10 +447,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False, CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch", CONF_CLIMATE: "switch.mock_climate",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30, CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5, CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3, CONF_SECURITY_MIN_ON_PERCENT: 0.3,
@@ -461,7 +458,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
) )
entity: BaseThermostat = await create_thermostat( entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname" hass, entry, "climate.theoverclimatemockname"
) )
assert entity assert entity
@@ -469,7 +466,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
now = datetime.now(tz) now = datetime.now(tz)
tpi_algo = entity._prop_algorithm tpi_algo = entity._prop_algorithm
assert tpi_algo assert tpi_algo is None
await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST) await entity.async_set_preset_mode(PRESET_BOOST)
@@ -484,18 +481,16 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch( ) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_heater_on, patch( ) as mock_set_hvac_mode, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.is_device_active",
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True, return_value=True,
): ):
event_timestamp = now - timedelta(minutes=4) event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 19, event_timestamp) await send_temperature_change_event(entity, 19, event_timestamp)
# The heater turns on # The climate turns on but was alredy on
assert mock_heater_on.call_count == 1 assert mock_set_hvac_mode.call_count == 0
assert entity.last_temperature_slope is None assert entity.last_temperature_slope is None
assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_open_detected() is False
assert entity._window_auto_algo.is_window_close_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False
@@ -505,10 +500,8 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch( ) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_heater_on, patch( ) as mock_set_hvac_mode, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True, return_value=True,
): ):
@@ -531,8 +524,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
], ],
any_order=True, any_order=True,
) )
assert mock_heater_on.call_count == 0 assert mock_set_hvac_mode.call_count >= 1
assert mock_heater_off.call_count >= 1
assert entity.last_temperature_slope == -1 assert entity.last_temperature_slope == -1
assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_open_detected() is True
assert entity._window_auto_algo.is_window_close_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False
@@ -543,17 +535,14 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch( ) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_heater_on, patch( ) as mock_set_hvac_mode, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=False, return_value=False,
): ):
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
assert mock_heater_on.call_count == 1 assert mock_set_hvac_mode.call_count == 1
assert mock_heater_off.call_count == 0
assert round(entity.last_temperature_slope, 3) == -1 assert round(entity.last_temperature_slope, 3) == -1
# Because the algorithm is not aware of the expiration, for the algo we are still in alert # Because the algorithm is not aware of the expiration, for the algo we are still in alert
assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_open_detected() is True
@@ -674,12 +663,11 @@ async def test_window_auto_no_on_percent(
# Clean the entity # Clean the entity
entity.remove_thermostat() entity.remove_thermostat()
#PR - Adding Window Bypass
# PR - Adding Window Bypass
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_bypass( async def test_window_bypass(hass: HomeAssistant, skip_hass_states_is_state):
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Window management when bypass enabled""" """Test the Window management when bypass enabled"""
entry = MockConfigEntry( entry = MockConfigEntry(
@@ -810,7 +798,8 @@ async def test_window_bypass(
# Clean the entity # Clean the entity
entity.remove_thermostat() entity.remove_thermostat()
#PR - Adding Window bypass for window auto algorithm
# PR - Adding Window bypass for window auto algorithm
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state): async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state):
@@ -921,7 +910,8 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state
# Clean the entity # Clean the entity
entity.remove_thermostat() entity.remove_thermostat()
#PR - Adding Window bypass AFTER detection have been done should reactivate the heater
# PR - Adding Window bypass AFTER detection have been done should reactivate the heater
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is_state): async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is_state):
@@ -1049,4 +1039,4 @@ async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is
) )
# Clean the entity # Clean the entity
entity.remove_thermostat() entity.remove_thermostat()