Compare commits

...

15 Commits
2.2.0 ... 2.3.0

Author SHA1 Message Date
Jean-Marc Collin
e63213d22a try to fix [MANIFEST] Manifest keys have been sorted: domain, name, then alphabetical order 2023-02-26 11:49:07 +01:00
Jean-Marc Collin
fb7ee1bdac try to FIX Manifest keys have been sorted 2023-02-26 11:44:33 +01:00
Jean-Marc Collin
ca86b310c4 Power of the heater should be accessible event if power management is not selected #53 2023-02-26 11:41:38 +01:00
Jean-Marc Collin
23074e6f46 Add energy for thermostat over climate 2023-02-26 11:22:20 +01:00
Jean-Marc Collin
718315c4fe FIX Documentation is wrong #55 2023-02-24 08:06:29 +01:00
Jean-Marc Collin
46278ca9a3 In certain case, temperature event are registred with an offset of one hour #52 2023-02-19 22:42:02 +01:00
Jean-Marc Collin
0b81a94d0f Add testus and change date timestamp. 2023-02-19 19:10:08 +01:00
Jean-Marc Collin
33590886c1 With energy calculation 2023-02-18 18:10:37 +01:00
Jean-Marc Collin
039b372a53 Add power tests 2023-02-18 11:49:37 +01:00
Jean-Marc Collin
a161540f10 Testus +1 2023-02-18 11:49:20 +01:00
Jean-Marc Collin
8bbcafdf4a Add the device power and device energy into attributes #25 2023-02-18 00:07:54 +01:00
Jean-Marc Collin
08d08e52de FIX A thermostat stays with security_default_on_percent when the preset change during security mode #49 2023-02-15 23:44:09 +01:00
Jean-Marc Collin
81b4f7e5f6 Testus 2 2023-02-12 23:52:04 +01:00
Jean-Marc Collin
7a917c6ff7 Testus 2023-02-12 18:50:56 +01:00
Jean-Marc Collin
20a9e2523e First config flow testu 2023-02-12 12:35:32 +01:00
23 changed files with 2120 additions and 93 deletions

View File

@@ -118,6 +118,37 @@ template:
unique_id: maison_occupee
state: "{{is_state('person.jmc', 'home') }}"
device_class: occupancy
- sensor:
- name: "Total énergie switch1"
unique_id: total_energie_switch1
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_switch_1', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
- name: "Total énergie climate 2"
unique_id: total_energie_climate2
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_climate_2', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
- name: "Total énergie chambre"
unique_id: total_energie_chambre
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >
{% set energy = state_attr('climate.thermostat_chambre', 'total_energy') %}
{% if energy == 'unavailable' or energy is none%}unavailable{% else %}
{{ ((energy | float) / 1.0) | round(2, default=0) }}
{% endif %}
switch:
- platform: template

View File

@@ -323,8 +323,8 @@ Le pourcentage est calculé avec cette formule :
Les valeurs par défaut pour coef_int et coef_ext sont respectivement : ``0.6`` et ``0.01``. Ces valeurs par défaut conviennent à une pièce standard bien isolée.
Pour régler ces coefficients, gardez à l'esprit que :
1. **si la température cible n'est pas atteinte** après une situation stable, vous devez augmenter le ``coef_ext`` (le ``on_percent`` est trop élevé),
2. **si la température cible est dépassée** après une situation stable, vous devez diminuer le ``coef_ext`` (le ``on_percent`` est trop bas),
1. **si la température cible n'est pas atteinte** après une situation stable, vous devez augmenter le ``coef_ext`` (le ``on_percent`` est trop bas),
2. **si la température cible est dépassée** après une situation stable, vous devez diminuer le ``coef_ext`` (le ``on_percent`` est trop haut),
3. **si l'atteinte de la température cible est trop lente**, vous pouvez augmenter le ``coef_int`` pour donner plus de puissance au réchauffeur,
4. **si l'atteinte de la température cible est trop rapide et que des oscillations apparaissent** autour de la cible, vous pouvez diminuer le ``coef_int`` pour donner moins de puissance au radiateur

View File

@@ -309,8 +309,8 @@ The percentage is calculated with this formula:
Defaults values for coef_int and coef_ext are respectively: ``0.6`` and ``0.01``. Those defaults values are suitable for a standard well isolated room.
To tune those coefficients keep in mind that:
1. **if target temperature is not reach** after stable situation, you have to augment the ``coef_ext`` (the ``on_percent`` is too high),
2. **if target temperature is exceeded** after stable situation, you have to decrease the ``coef_ext`` (the ``on_percent`` is too low),
1. **if target temperature is not reach** after stable situation, you have to augment the ``coef_ext`` (the ``on_percent`` is too low),
2. **if target temperature is exceeded** after stable situation, you have to decrease the ``coef_ext`` (the ``on_percent`` is too high),
3. **if reaching the target temperature is too slow**, you can increase the ``coef_int`` to give more power to the heater,
4. **if reaching the target temperature is too fast and some oscillations appears** around the target, you can decrease the ``coef_int`` to give less power to the heater

View File

@@ -8,11 +8,14 @@ from datetime import timedelta, datetime
import voluptuous as vol
from homeassistant.util import dt as dt_util
from homeassistant.core import (
HomeAssistant,
callback,
CoreState,
DOMAIN as HA_DOMAIN,
Event,
State,
)
from homeassistant.components.climate import ClimateEntity
@@ -132,6 +135,8 @@ from .const import (
CONF_CLIMATE,
UnknownEntity,
EventType,
ATTR_MEAN_POWER_CYCLE,
ATTR_TOTAL_ENERGY,
)
from .prop_algorithm import PropAlgorithm
@@ -197,6 +202,14 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# The list of VersatileThermostat entities
# No more needed
# _registry: dict[str, object] = {}
_last_temperature_mesure: datetime
_last_ext_temperature_mesure: datetime
_total_energy: float
_overpowering_state: bool
_window_state: bool
_motion_state: bool
_presence_state: bool
_security_state: bool
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
@@ -247,6 +260,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._attr_translation_key = "versatile_thermostat"
self._total_energy = None
self._underlying_climate_start_hvac_action_date = None
self._underlying_climate_delta_t = 0
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
self.post_init(entry_infos)
def post_init(self, entry_infos):
@@ -343,8 +362,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._presets_away,
)
# Will be restored if possible
self._attr_preset_mode = None
self._saved_preset_mode = None
self._attr_preset_mode = PRESET_NONE
self._saved_preset_mode = PRESET_NONE
# Power management
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
@@ -357,8 +376,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
and self._device_power
):
self._pmax_on = True
self._current_power = 0
self._current_power_max = 0
else:
_LOGGER.info("%s - Power management is not fully configured", self)
@@ -392,8 +409,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
or DEFAULT_SECURITY_DEFAULT_ON_PERCENT
)
self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY)
self._last_temperature_mesure = datetime.now()
self._last_ext_temperature_mesure = datetime.now()
self._last_temperature_mesure = datetime.now(tz=self._current_tz)
self._last_ext_temperature_mesure = datetime.now(tz=self._current_tz)
self._security_state = False
self._saved_hvac_mode = None
@@ -437,6 +454,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if self._motion_on:
self._attr_preset_modes.append(PRESET_ACTIVITY)
self._total_energy = 0
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s",
self,
@@ -546,6 +565,14 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._async_cancel_cycle()
self._async_cancel_cycle = None
def find_underlying_climate(self, climate_entity_id) -> ClimateEntity:
"""Find the underlying climate entity"""
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if climate_entity_id == entity.entity_id:
return entity
return None
async def async_startup(self):
"""Triggered on startup, used to get old state and set internal states accordingly"""
_LOGGER.debug("%s - Calling async_startup", self)
@@ -557,19 +584,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Get the underlying thermostat
if self._is_over_climate:
component: EntityComponent[ClimateEntity] = self.hass.data[
CLIMATE_DOMAIN
]
for entity in component.entities:
if self._climate_entity_id == entity.entity_id:
_LOGGER.info(
"%s - The underlying climate entity: %s have been succesfully found",
self,
entity,
)
self._underlying_climate = entity
break
if self._underlying_climate is None:
self._underlying_climate = self.find_underlying_climate(
self._climate_entity_id
)
if self._underlying_climate:
_LOGGER.info(
"%s - The underlying climate entity: %s have been succesfully found",
self,
self._underlying_climate,
)
else:
_LOGGER.error(
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational",
self,
@@ -771,6 +795,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else:
self._hvac_mode = HVACMode.OFF
old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
if old_total_energy:
self._total_energy = old_total_energy
else:
# No previous state, try and restore defaults
if self._target_temp is None:
@@ -924,7 +951,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
else:
return None
else:
return self.hass.states.is_state(self._heater_entity_id, STATE_ON)
return self._hass.states.is_state(self._heater_entity_id, STATE_ON)
@property
def current_temperature(self):
@@ -972,6 +999,34 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return None
@property
def mean_cycle_power(self) -> float | None:
"""Returns tne mean power consumption during the cycle"""
if not self._device_power or self._is_over_climate:
return None
return float(self._device_power * self._prop_algorithm.on_percent)
@property
def total_energy(self) -> float | None:
"""Returns the total energy calculated for this thermostast"""
return self._total_energy
@property
def overpowering_state(self) -> bool | None:
"""Get the overpowering_state"""
return self._overpowering_state
@property
def window_state(self) -> bool | None:
"""Get the window_state"""
return self._window_state
@property
def motion_state(self) -> bool | None:
"""Get the motion_state"""
return self._motion_state
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self._is_over_climate and self._underlying_climate:
@@ -1071,6 +1126,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if preset_mode == self._attr_preset_mode and not force:
# I don't think we need to call async_write_ha_state if we didn't change the state
return
# In security mode don't change preset but memorise the new expected preset when security will be off
if preset_mode != PRESET_SECURITY and self._security_state:
_LOGGER.debug(
"%s - is in security mode. Just memorise the new expected ", self
)
if preset_mode not in HIDDEN_PRESETS:
self._saved_preset_mode = preset_mode
return
old_preset_mode = self._attr_preset_mode
if preset_mode == PRESET_NONE:
self._attr_preset_mode = PRESET_NONE
@@ -1101,7 +1166,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
):
self._last_temperature_mesure = (
self._last_ext_temperature_mesure
) = datetime.now()
) = datetime.now(tz=self._current_tz)
def find_preset_temp(self, preset_mode):
"""Find the right temperature of a preset considering the presence if configured"""
@@ -1200,6 +1265,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context
)
def get_state_date_or_now(self, state: State):
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_changed.astimezone(self._current_tz)
if state.last_changed is not None
else datetime.now(tz=self._current_tz)
)
def get_last_updated_date_or_now(self, state: State):
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_updated.astimezone(self._current_tz)
if state.last_updated is not None
else datetime.now(tz=self._current_tz)
)
@callback
async def entry_update_listener(
self, _, config_entry: ConfigEntry # hass: HomeAssistant,
@@ -1208,9 +1289,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
@callback
async def _async_temperature_changed(self, event):
"""Handle temperature changes."""
new_state = event.data.get("new_state")
async def _async_temperature_changed(self, event: Event):
"""Handle temperature of the temperature sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - Temperature changed. Event.new_state is %s",
self,
@@ -1223,9 +1304,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.recalculate()
await self._async_control_heating(force=False)
async def _async_ext_temperature_changed(self, event):
"""Handle external temperature changes."""
new_state = event.data.get("new_state")
async def _async_ext_temperature_changed(self, event: Event):
"""Handle external temperature opf the sensor changes."""
new_state: State = event.data.get("new_state")
_LOGGER.debug(
"%s - external Temperature changed. Event.new_state is %s",
self,
@@ -1250,8 +1331,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._hvac_mode,
self._saved_hvac_mode,
)
if new_state is None or old_state is None or new_state.state == old_state.state:
return
# Check delay condition
async def try_window_condition(_):
@@ -1269,6 +1348,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(
"Window delay condition is not satisfied. Ignore window event"
)
self._window_state = old_state.state
return
_LOGGER.debug("%s - Window delay condition is satisfied", self)
@@ -1291,12 +1371,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self.async_set_hvac_mode(HVACMode.OFF)
self.update_custom_attributes()
if new_state is None or old_state is None or new_state.state == old_state.state:
return try_window_condition
if self._window_call_cancel:
self._window_call_cancel()
self._window_call_cancel = None
self._window_call_cancel = async_call_later(
self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition
)
# For testing purpose we need to access the inner function
return try_window_condition
@callback
async def _async_motion_changed(self, event):
@@ -1382,14 +1467,29 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def _async_climate_changed(self, event):
"""Handle unerdlying climate state changes."""
new_state = event.data.get("new_state")
_LOGGER.warning("%s - _async_climate_changed new_state is %s", self, new_state)
old_state = event.data.get("old_state")
old_hvac_action = (
old_state.attributes.get("hvac_action")
if old_state and old_state.attributes
else None
)
new_hvac_action = (
new_state.attributes.get("hvac_action")
if new_state and new_state.attributes
else None
)
_LOGGER.info(
"%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s",
"%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s, hvac_action=%s, old_hvac_action=%s",
self,
new_state,
self._hvac_mode,
new_hvac_action,
old_hvac_action,
)
# old_state = event.data.get("old_state")
if new_state is None or new_state.state not in [
if new_state.state in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
@@ -1398,20 +1498,66 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
HVACMode.AUTO,
HVACMode.FAN_ONLY,
]:
return
self._hvac_mode = new_state.state
self._hvac_mode = new_state.state
# Interpretation of hvac
HVAC_ACTION_ON = [
HVACAction.COOLING,
HVACAction.DRYING,
HVACAction.FAN,
HVACAction.HEATING,
]
if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON:
self._underlying_climate_start_hvac_action_date = (
self.get_last_updated_date_or_now(new_state)
)
_LOGGER.info(
"%s - underlying just switch ON. Set power and energy start date %s",
self,
self._underlying_climate_start_hvac_action_date.isoformat(),
)
if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON:
stop_power_date = self.get_last_updated_date_or_now(new_state)
if self._underlying_climate_start_hvac_action_date:
delta = (
stop_power_date - self._underlying_climate_start_hvac_action_date
)
self._underlying_climate_delta_t = delta.total_seconds() / 3600.0
# increment energy at the end of the cycle
self.incremente_energy()
self._underlying_climate_start_hvac_action_date = None
_LOGGER.info(
"%s - underlying just switch OFF at %s. delta_h=%.3f h",
self,
stop_power_date.isoformat(),
self._underlying_climate_delta_t,
)
self.update_custom_attributes()
await self._async_control_heating(True)
@callback
async def _async_update_temp(self, state):
async def _async_update_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_temp = float(state.state)
if math.isnan(cur_temp) or math.isinf(cur_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_temp = cur_temp
self._last_temperature_mesure = datetime.now()
self._last_temperature_mesure = self.get_state_date_or_now(state)
_LOGGER.debug(
"%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s",
self,
self._last_temperature_mesure,
state.last_changed.astimezone(self._current_tz),
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
@@ -1420,14 +1566,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
@callback
async def _async_update_ext_temp(self, state):
async def _async_update_ext_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
try:
cur_ext_temp = float(state.state)
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_ext_temp = cur_ext_temp
self._last_ext_temperature_mesure = datetime.now()
self._last_ext_temperature_mesure = self.get_state_date_or_now(state)
_LOGGER.debug(
"%s - After setting _last_ext_temperature_mesure %s , state.last_changed.replace=%s",
self,
self._last_ext_temperature_mesure,
state.last_changed.astimezone(self._current_tz),
)
# try to restart if we were in security mode
if self._security_state:
await self.check_security()
@@ -1648,7 +1802,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""
if not self._pmax_on:
return
_LOGGER.debug(
"%s - power not configured. check_overpowering not available", self
)
return False
if (
self._current_power is None
or self._device_power is None
or self._current_power_max is None
):
_LOGGER.warning(
"%s - power not valued. check_overpowering not available", self
)
return False
_LOGGER.debug(
"%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f",
@@ -1657,6 +1824,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._current_power_max,
self._device_power,
)
ret = self._current_power + self._device_power >= self._current_power_max
if not self._overpowering_state and ret and not self._hvac_mode == HVACMode.OFF:
_LOGGER.warning(
@@ -1707,10 +1875,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def check_security(self) -> bool:
"""Check if last temperature date is too long"""
now = datetime.now()
delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0
now = datetime.now(self._current_tz)
delta_temp = (
now - self._last_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
delta_ext_temp = (
now - self._last_ext_temperature_mesure
now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
@@ -1730,6 +1900,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
>= self._security_min_on_percent
)
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
ret = False
if mode_cond and temp_cond and climate_cond:
if not self._security_state:
@@ -1743,17 +1924,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
ret = True
_LOGGER.debug(
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
if mode_cond and temp_cond and switch_cond:
if not self._security_state:
_LOGGER.warning(
@@ -1771,8 +1941,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.send_event(
EventType.TEMPERATURE_EVENT,
{
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1794,8 +1968,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
EventType.SECURITY_EVENT,
{
"type": "start",
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1824,8 +2002,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
EventType.SECURITY_EVENT,
{
"type": "end",
"last_temperature_mesure": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_mesure": self._last_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace(
tzinfo=self._current_tz
).isoformat(),
"current_temp": self._cur_temp,
"current_ext_temp": self._cur_ext_temp,
"target_temp": self.target_temperature,
@@ -1939,7 +2121,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(
"%s - No action on heater cause duration is 0", self
)
self.update_custom_attributes()
self._async_cancel_cycle = async_call_later(
self.hass,
time,
@@ -1953,6 +2134,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
heater_action=self._async_heater_turn_on,
next_cycle_action=_turn_off_later,
)
self.update_custom_attributes()
async def _turn_off_later(_):
await _turn_on_off_later(
@@ -1961,6 +2143,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
heater_action=self._async_underlying_entity_turn_off,
next_cycle_action=_turn_on_later,
)
# increment energy at the end of the cycle
self.incremente_energy()
self.update_custom_attributes()
await _turn_on_later(None)
@@ -1993,11 +2178,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self.update_custom_attributes()
self.async_write_ha_state()
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self.hvac_mode == HVACMode.OFF:
return
added_energy = 0
if self._is_over_climate and self._underlying_climate_delta_t is not None:
added_energy = self._device_power * self._underlying_climate_delta_t
if not self._is_over_climate and self.mean_cycle_power is not None:
added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0
self._total_energy += added_energy
_LOGGER.debug(
"%s - added energy is %.3f . Total energy is now: %.3f",
self,
added_energy,
self._total_energy,
)
def update_custom_attributes(self):
"""Update the custom extra attributes for the entity"""
self._attr_extra_state_attributes: dict(str, str) = {
"hvac_mode": self._hvac_mode,
"hvac_mode": self.hvac_mode,
"preset_mode": self.preset_mode,
"type": self._thermostat_type,
"eco_temp": self._presets[PRESET_ECO],
"boost_temp": self._presets[PRESET_BOOST],
@@ -2025,16 +2231,29 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"security_delay_min": self._security_delay_min,
"security_min_on_percent": self._security_min_on_percent,
"security_default_on_percent": self._security_default_on_percent,
"last_temperature_datetime": self._last_temperature_mesure.isoformat(),
"last_ext_temperature_datetime": self._last_ext_temperature_mesure.isoformat(),
"last_temperature_datetime": self._last_temperature_mesure.astimezone(
self._current_tz
).isoformat(),
"last_ext_temperature_datetime": self._last_ext_temperature_mesure.astimezone(
self._current_tz
).isoformat(),
"security_state": self._security_state,
"minimal_activation_delay_sec": self._minimal_activation_delay,
"last_update_datetime": datetime.now().isoformat(),
"device_power": self._device_power,
ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power,
ATTR_TOTAL_ENERGY: self.total_energy,
"last_update_datetime": datetime.now()
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz),
}
if self._is_over_climate:
self._attr_extra_state_attributes[
"underlying_climate"
] = self._climate_entity_id
self._attr_extra_state_attributes[
"start_hvac_action_date"
] = self._underlying_climate_start_hvac_action_date
else:
self._attr_extra_state_attributes[
"underlying_switch"

View File

@@ -204,6 +204,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int,
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float),
vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean,
vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean,
vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean,
@@ -290,7 +291,6 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
),
), # vol.In(power_sensors),
vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float),
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
}
)
@@ -331,7 +331,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
}
)
async def validate_input(self, data: dict) -> dict[str]:
async def validate_input(self, data: dict) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_*_DATA_SCHEMA with values provided by the user.

View File

@@ -135,6 +135,9 @@ SERVICE_SET_SECURITY = "set_security"
DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5
DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1
ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
class EventType(Enum):
"""The event type that can be sent"""

View File

@@ -1,19 +1,19 @@
{
"version": "0.0.1",
"domain": "versatile_thermostat",
"name": "Versatile Thermostat",
"config_flow": true,
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
"issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues",
"requirements": [],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": [
"@jmcollin78"
],
"quality_scale": "silver",
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
"homekit": {},
"integration_type": "device",
"iot_class": "calculated",
"integration_type": "device"
"issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues",
"quality_scale": "silver",
"requirements": [],
"ssdp": [],
"version": "0.0.1",
"zeroconf": []
}

View File

@@ -1,3 +1,4 @@
""" The TPI calculation module """
import logging
_LOGGER = logging.getLogger(__name__)
@@ -21,7 +22,7 @@ class PropAlgorithm:
tpi_coef_ext,
cycle_min: int,
minimal_activation_delay: int,
):
) -> None:
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
"Creation new PropAlgorithm function_type: %s, tpi_coef_int: %s, tpi_coef_ext: %s, cycle_min:%d, minimal_activation_delay:%d",
@@ -85,6 +86,12 @@ class PropAlgorithm:
def _calculate_internal(self):
"""Finish the calculation to get the on_percent in seconds"""
# calculated on_time duration in seconds
if self._calculated_on_percent > 1:
self._calculated_on_percent = 1
if self._calculated_on_percent < 0:
self._calculated_on_percent = 0
if self._security:
_LOGGER.debug(
"Security is On using the default_on_percent %f",
@@ -98,11 +105,6 @@ class PropAlgorithm:
)
self._on_percent = self._calculated_on_percent
# calculated on_time duration in seconds
if self._on_percent > 1:
self._on_percent = 1
if self._on_percent < 0:
self._on_percent = 0
self._on_time_sec = self._on_percent * self._cycle_min * 60
# Do not heat for less than xx sec

View File

@@ -0,0 +1 @@
homeassistant

View File

@@ -0,0 +1,3 @@
# -r requirements_dev.txt
# aiodiscover
pytest-homeassistant-custom-component

View File

@@ -14,6 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -70,7 +71,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -117,6 +117,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -173,7 +174,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},

View File

@@ -0,0 +1 @@
""" To make this repo a module """

View File

@@ -0,0 +1,271 @@
""" Some common resources """
from typing import Mapping
from unittest.mock import patch, MagicMock
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF
from homeassistant.config_entries import ConfigEntryState
from homeassistant.util import dt as dt_util
from homeassistant.helpers.entity_component import EntityComponent
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from ..const import *
from homeassistant.components.climate import (
ClimateEntity,
DOMAIN as CLIMATE_DOMAIN,
ATTR_PRESET_MODE,
HVACMode,
HVACAction,
ClimateEntityFeature,
)
from .const import (
MOCK_TH_OVER_SWITCH_USER_CONFIG,
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG,
MOCK_WINDOW_CONFIG,
MOCK_MOTION_CONFIG,
MOCK_POWER_CONFIG,
MOCK_PRESENCE_CONFIG,
MOCK_ADVANCED_CONFIG,
# MOCK_DEFAULT_FEATURE_CONFIG,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_NONE,
PRESET_ECO,
PRESET_ACTIVITY,
)
FULL_SWITCH_CONFIG = (
MOCK_TH_OVER_SWITCH_USER_CONFIG
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_WINDOW_CONFIG
| MOCK_MOTION_CONFIG
| MOCK_POWER_CONFIG
| MOCK_PRESENCE_CONFIG
| MOCK_ADVANCED_CONFIG
)
PARTIAL_CLIMATE_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
class MockClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
super().__init__()
self._hass = hass
self._attr_extra_state_attributes = {}
self._unique_id = unique_id
self._name = name
self._attr_hvac_action = HVACAction.OFF
self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
class MagicMockClimate(MagicMock):
@property
def temperature_unit(self):
return UnitOfTemperature.CELSIUS
@property
def hvac_mode(self):
return HVACMode.HEAT
@property
def hvac_action(self):
return HVACAction.IDLE
@property
def target_temperature(self):
return 15
@property
def current_temperature(self):
return 14
@property
def target_temperature_step(self) -> float | None:
return 0.5
@property
def target_temperature_high(self) -> float | None:
return 35
@property
def target_temperature_low(self) -> float | None:
return 7
@property
def hvac_modes(self) -> list[str] | None:
return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL]
@property
def fan_modes(self) -> list[str] | None:
return None
@property
def swing_modes(self) -> list[str] | None:
return None
@property
def fan_mode(self) -> str | None:
return None
@property
def swing_mode(self) -> str | None:
return None
@property
def supported_features(self):
return ClimateEntityFeature.TARGET_TEMPERATURE
async def create_thermostat(
hass: HomeAssistant, entry: MockConfigEntry, entity_id: str
) -> VersatileThermostat:
"""Creates and return a TPI Thermostat"""
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity = find_my_entity(entity_id)
return entity
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
"""Sending a new temperature event simulating a change on temperature sensor"""
temp_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_temp,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_temperature_changed(temp_event)
async def send_power_change_event(entity: VersatileThermostat, new_power, date):
"""Sending a new power event simulating a change on power sensor"""
power_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_power,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_power_changed(power_event)
async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date):
"""Sending a new power max event simulating a change on power max sensor"""
power_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_power_max,
last_changed=date,
last_updated=date,
)
},
)
return await entity._async_max_power_changed(power_event)
async def send_window_change_event(
entity: VersatileThermostat, new_state: bool, old_state: bool, date
):
"""Sending a new window event simulating a change on the window state"""
window_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=STATE_ON if new_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=STATE_ON if old_state else STATE_OFF,
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_windows_changed(window_event)
return ret
def get_tz(hass):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
async def send_climate_change_event(
entity: VersatileThermostat,
new_hvac_mode: HVACMode,
old_hvac_mode: HVACMode,
new_hvac_action: HVACAction,
old_hvac_action: HVACAction,
date,
):
"""Sending a new climate event simulating a change on the underlying climate state"""
climate_event = Event(
EVENT_STATE_CHANGED,
{
"new_state": State(
entity_id=entity.entity_id,
state=new_hvac_mode,
attributes={"hvac_action": new_hvac_action},
last_changed=date,
last_updated=date,
),
"old_state": State(
entity_id=entity.entity_id,
state=old_hvac_mode,
attributes={"hvac_action": old_hvac_action},
last_changed=date,
last_updated=date,
),
},
)
ret = await entity._async_climate_changed(climate_event)

View File

@@ -0,0 +1,80 @@
"""Global fixtures for integration_blueprint integration."""
# Fixtures allow you to replace functions with a Mock object. You can perform
# many options via the Mock to reflect a particular behavior from the original
# function that you want to see without going through the function's actual logic.
# Fixtures can either be passed into tests as parameters, or if autouse=True, they
# will automatically be used across all tests.
#
# Fixtures that are defined in conftest.py are available across all tests. You can also
# define fixtures within a particular test file to scope them locally.
#
# pytest_homeassistant_custom_component provides some fixtures that are provided by
# Home Assistant core. You can find those fixture definitions here:
# https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py
#
# See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that
# pytest includes fixtures OOB which you can use as defined on this page)
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant, StateMachine
from custom_components.versatile_thermostat.config_flow import (
VersatileThermostatBaseConfigFlow,
)
from custom_components.versatile_thermostat.climate import (
VersatileThermostat,
)
pytest_plugins = "pytest_homeassistant_custom_component"
# This fixture enables loading custom integrations in all tests.
# Remove to enable selective use of this fixture
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
yield
# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent
# notifications. These calls would fail without this fixture since the persistent_notification
# integration is never loaded during a test.
@pytest.fixture(name="skip_notifications", autouse=True)
def skip_notifications_fixture():
"""Skip notification calls."""
with patch("homeassistant.components.persistent_notification.async_create"), patch(
"homeassistant.components.persistent_notification.async_dismiss"
):
yield
# This fixture is used to bypass the validate_input function in config_flow
# NOT USED Now (keeped for memory)
@pytest.fixture(name="skip_validate_input")
def skip_validate_input_fixture():
"""Skip the validate_input in config flow"""
with patch.object(VersatileThermostatBaseConfigFlow, "validate_input"):
yield
@pytest.fixture(name="skip_hass_states_get")
def skip_hass_states_get_fixture():
"""Skip the get state in HomeAssistant"""
with patch.object(StateMachine, "get"):
yield
@pytest.fixture(name="skip_hass_states_is_state")
def skip_hass_states_is_state_fixture():
"""Skip the is_state in HomeAssistant"""
with patch.object(StateMachine, "is_state", return_value=False):
yield
@pytest.fixture(name="skip_send_event")
def skip_send_event_fixture():
"""Skip the send_event in VersatileThermostat"""
with patch.object(VersatileThermostat, "send_event"):
yield

View File

@@ -0,0 +1,130 @@
from homeassistant.components.climate.const import (
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
PRESET_ACTIVITY,
)
from custom_components.versatile_thermostat.const import (
CONF_NAME,
CONF_HEATER,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_TYPE,
CONF_TEMP_SENSOR,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_CYCLE_MIN,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_PROP_FUNCTION,
PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT,
CONF_TPI_COEF_EXT,
CONF_MINIMAL_ACTIVATION_DELAY,
CONF_SECURITY_DELAY_MIN,
CONF_SECURITY_MIN_ON_PERCENT,
CONF_SECURITY_DEFAULT_ON_PERCENT,
CONF_USE_WINDOW_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_POWER_FEATURE,
CONF_USE_PRESENCE_FEATURE,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
CONF_POWER_SENSOR,
CONF_MAX_POWER_SENSOR,
CONF_DEVICE_POWER,
CONF_PRESET_POWER,
CONF_PRESENCE_SENSOR,
PRESET_AWAY_SUFFIX,
CONF_CLIMATE,
)
MOCK_TH_OVER_SWITCH_USER_CONFIG = {
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,
CONF_DEVICE_POWER: 1,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True,
}
MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
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,
CONF_DEVICE_POWER: 1,
# Keep default values which are False
}
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
}
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
}
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
}
MOCK_PRESETS_CONFIG = {
PRESET_ECO + "_temp": 16,
PRESET_COMFORT + "_temp": 17,
PRESET_BOOST + "_temp": 18,
}
MOCK_WINDOW_CONFIG = {
CONF_WINDOW_SENSOR: "binary_sensor.window_sensor",
CONF_WINDOW_DELAY: 10,
}
MOCK_MOTION_CONFIG = {
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
CONF_MOTION_DELAY: 10,
CONF_MOTION_PRESET: PRESET_COMFORT,
CONF_NO_MOTION_PRESET: PRESET_ECO,
}
MOCK_POWER_CONFIG = {
CONF_POWER_SENSOR: "sensor.power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor",
CONF_PRESET_POWER: 10,
}
MOCK_PRESENCE_CONFIG = {
CONF_PRESENCE_SENSOR: "person.presence_sensor",
PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16,
PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17,
PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18,
}
MOCK_ADVANCED_CONFIG = {
CONF_MINIMAL_ACTIVATION_DELAY: 10,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.4,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3,
}
MOCK_DEFAULT_FEATURE_CONFIG = {
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
}

View File

@@ -0,0 +1,223 @@
""" Test the Versatile Thermostat config flow """
from homeassistant import data_entry_flow
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
from custom_components.versatile_thermostat.const import DOMAIN
from custom_components.versatile_thermostat import VersatileThermostatAPI
from .const import (
MOCK_TH_OVER_SWITCH_USER_CONFIG,
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG,
MOCK_WINDOW_CONFIG,
MOCK_MOTION_CONFIG,
MOCK_POWER_CONFIG,
MOCK_PRESENCE_CONFIG,
MOCK_ADVANCED_CONFIG,
MOCK_DEFAULT_FEATURE_CONFIG,
)
async def test_show_form(hass: HomeAssistant) -> None:
"""Test that the form is served with no input"""
# Init the API
# hass.data["custom_components"] = None
# loader.async_get_custom_components(hass)
# VersatileThermostatAPI(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
async def test_user_config_flow_over_switch(hass, skip_hass_states_get):
"""Test the config flow with all thermostat_over_switch features"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_USER_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "type"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tpi"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presets"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "window"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_WINDOW_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "motion"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_MOTION_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "power"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_POWER_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presence"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESENCE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "advanced"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
result["data"]
== MOCK_TH_OVER_SWITCH_USER_CONFIG
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_WINDOW_CONFIG
| MOCK_MOTION_CONFIG
| MOCK_POWER_CONFIG
| MOCK_PRESENCE_CONFIG
| MOCK_ADVANCED_CONFIG
)
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 1
assert result["result"].title == "TheOverSwitchMockName"
assert isinstance(result["result"], ConfigEntry)
async def test_user_config_flow_over_climate(hass, skip_hass_states_get):
"""Test the config flow with all thermostat_over_climate features and no additional features"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_USER_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "type"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "presets"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
)
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "window"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_WINDOW_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "motion"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_MOTION_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "power"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_POWER_CONFIG
# )
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# assert result["step_id"] == "presence"
# assert result["errors"] == {}
# result = await hass.config_entries.flow.async_configure(
# result["flow_id"], user_input=MOCK_PRESENCE_CONFIG
# )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "advanced"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
result["data"]
== MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
| MOCK_DEFAULT_FEATURE_CONFIG
)
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 1
assert result["result"].title == "TheOverClimateMockName"
assert isinstance(result["result"], ConfigEntry)

View File

@@ -0,0 +1,447 @@
""" Test the Power management """
from unittest.mock import patch, call, MagicMock
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime, timedelta
from homeassistant.const import UnitOfTemperature
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_power_management_hvac_off(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: "eco",
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.overpowering_state is None
assert entity.hvac_mode == HVACMode.OFF
# Send power mesurement
await send_power_change_event(entity, 50, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is not complete
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
# Send power max mesurement
await send_max_power_change_event(entity, 300, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
# Send power max mesurement too low but HVACMode is off
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
# All configuration is complete and power is > power_max but we stay in Boost cause thermostat if Off
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is True
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 19
# Send power mesurement
await send_power_change_event(entity, 50, datetime.now())
# Send power max mesurement
await send_max_power_change_event(entity, 300, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
# Send power max mesurement too low and HVACMode is on
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_max_power_change_event(entity, 149, datetime.now())
assert await entity.check_overpowering() is True
# All configuration is complete and power is > power_max we switch to POWER preset
assert entity.preset_mode is PRESET_POWER
assert entity.overpowering_state is True
assert entity.target_temperature == 12
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_POWER}),
call.send_event(
EventType.POWER_EVENT,
{
"type": "start",
"current_power": 50,
"device_power": 100,
"current_power_max": 149,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 1
# Send power mesurement low to unseet power preset
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_power_change_event(entity, 48, datetime.now())
assert await entity.check_overpowering() is False
# All configuration is complete and power is < power_max, we restore previous preset
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is False
assert entity.target_temperature == 19
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_BOOST}),
call.send_event(
EventType.POWER_EVENT,
{
"type": "end",
"current_power": 48,
"device_power": 100,
"current_power_max": 149,
},
),
],
any_order=True,
)
# No current temperature is set so the heater wont be turned on
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
async def test_power_management_energy_over_switch(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management energy mesurement"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
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_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
assert entity.total_energy == 0
# set temperature to 15 so that on_percent will be set
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 15, datetime.now())
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.target_temperature == 19
assert entity.current_temperature == 15
assert tpi_algo.on_percent == 1
assert entity.mean_cycle_power == 100.0
assert mock_send_event.call_count == 2
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
entity.incremente_energy()
assert entity.total_energy == 100 * 5 / 60.0
entity.incremente_energy()
assert entity.total_energy == 2 * 100 * 5 / 60.0
# change temperature to a higher value
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 18, datetime.now())
assert tpi_algo.on_percent == 0.3
assert entity.mean_cycle_power == 30.0
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.3) * 100 * 5 / 60.0, 2)
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
# change temperature to a much higher value so that heater will be shut down
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off:
await send_temperature_change_event(entity, 20, datetime.now())
assert tpi_algo.on_percent == 0.0
assert entity.mean_cycle_power == 0.0
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 0
assert mock_heater_off.call_count == 0
entity.incremente_energy()
# No change on energy
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
# Still no change
entity.incremente_energy()
assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2)
async def test_power_management_energy_over_climate(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management for a over_climate thermostat"""
the_mock_underlying = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=the_mock_underlying,
):
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverClimateMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: False,
CONF_CLIMATE: "climate.mock_climate",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_POWER_SENSOR: "sensor.mock_power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor",
CONF_DEVICE_POWER: 100,
CONF_PRESET_POWER: 12,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverclimatemockname"
)
assert entity
assert entity._is_over_climate
now = datetime.now(tz=get_tz(hass))
await send_temperature_change_event(entity, 15, now)
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.hvac_action is HVACAction.IDLE
assert entity.preset_mode is PRESET_BOOST
assert entity.target_temperature == 19
assert entity.current_temperature == 15
# Not initialised yet
assert entity.mean_cycle_power is None
assert entity._underlying_climate_start_hvac_action_date is None
# Send a climate_change event with HVACAction=HEATING
event_timestamp = now - timedelta(minutes=3)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.HEATING,
old_hvac_action=HVACAction.OFF,
date=event_timestamp,
)
# We have the start event and not the end event
assert (entity._underlying_climate_start_hvac_action_date - now).total_seconds() < 1
entity.incremente_energy()
assert entity.total_energy == 0
# Send a climate_change event with HVACAction=IDLE (end of heating)
await send_climate_change_event(
entity,
new_hvac_mode=HVACMode.HEAT,
old_hvac_mode=HVACMode.HEAT,
new_hvac_action=HVACAction.IDLE,
old_hvac_action=HVACAction.HEATING,
date=now,
)
# We have the end event -> we should have some power and on_percent
assert entity._underlying_climate_start_hvac_action_date is None
# 3 minutes at 100 W
assert entity.total_energy == 100 * 3.0 / 60
# Test the re-increment
entity.incremente_energy()
assert entity.total_energy == 2 * 100 * 3.0 / 60

View File

@@ -0,0 +1,188 @@
""" Test the Security featrure """
from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import timedelta, datetime
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the security feature and https://github.com/jmcollin78/versatile_thermostat/issues/49:
1. creates a thermostat and check that security is off
2. activate security feature when date is expired
3. change the preset to boost
4. check that security is still on
5. resolve the date issue
6. check that security is off and preset is changed to boost
"""
tz = get_tz(hass)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
"name": "TheOverSwitchMockName",
"thermostat_type": "thermostat_over_switch",
"temperature_sensor_entity_id": "sensor.mock_temp_sensor",
"external_temperature_sensor_entity_id": "sensor.mock_ext_temp_sensor",
"cycle_min": 5,
"temp_min": 15,
"temp_max": 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"use_window_feature": False,
"use_motion_feature": False,
"use_power_feature": False,
"use_presence_feature": False,
"heater_entity_id": "switch.mock_switch",
"proportional_function": "tpi",
"tpi_coef_int": 0.3,
"tpi_coef_ext": 0.01,
"minimal_activation_delay": 30,
"security_delay_min": 5, # 5 minutes
"security_min_on_percent": 0.2,
"security_default_on_percent": 0.1,
},
)
# 1. creates a thermostat and check that security is off
now: datetime = datetime.now(tz=tz)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
assert entity._security_state is False
assert entity.preset_mode is not PRESET_SECURITY
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity._last_ext_temperature_mesure is not None
assert entity._last_temperature_mesure is not None
assert (entity._last_temperature_mesure.astimezone(tz) - now).total_seconds() < 1
assert (
entity._last_ext_temperature_mesure.astimezone(tz) - now
).total_seconds() < 1
# set a preset
assert entity.preset_mode is PRESET_NONE
await entity.async_set_preset_mode(PRESET_COMFORT)
assert entity.preset_mode is PRESET_COMFORT
# Turn On the thermostat
assert entity.hvac_mode == HVACMode.OFF
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode == HVACMode.HEAT
# 2. activate security feature when date is expired
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
event_timestamp = now - timedelta(minutes=6)
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
await send_temperature_change_event(entity, 15, event_timestamp)
assert entity._security_state is True
assert entity.preset_mode == PRESET_SECURITY
assert entity._saved_preset_mode == PRESET_COMFORT
assert entity._prop_algorithm.on_percent == 0.1
assert entity._prop_algorithm.calculated_on_percent == 0.9
assert mock_send_event.call_count == 3
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_SECURITY}),
call.send_event(
EventType.TEMPERATURE_EVENT,
{
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"current_temp": 15,
"current_ext_temp": None,
"target_temp": 18,
},
),
call.send_event(
EventType.SECURITY_EVENT,
{
"type": "start",
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"current_temp": 15,
"current_ext_temp": None,
"target_temp": 18,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 1
# 3. Change the preset to Boost (we should stay in SECURITY)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
await entity.async_set_preset_mode(PRESET_BOOST)
# 4. check that security is still on
assert entity._security_state is True
assert entity._prop_algorithm.on_percent == 0.1
assert entity._prop_algorithm.calculated_on_percent == 0.9
assert entity._saved_preset_mode == PRESET_BOOST
assert entity.preset_mode is PRESET_SECURITY
# 5. resolve the datetime issue
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on:
event_timestamp = datetime.now()
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
await send_temperature_change_event(entity, 15.2, event_timestamp)
assert entity._security_state is False
assert entity.preset_mode == PRESET_BOOST
assert entity._saved_preset_mode == PRESET_BOOST
assert entity._prop_algorithm.on_percent == 1.0
assert entity._prop_algorithm.calculated_on_percent == 1.0
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_BOOST}),
call.send_event(
EventType.SECURITY_EVENT,
{
"type": "end",
"last_temperature_mesure": event_timestamp.astimezone(
tz
).isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.astimezone(
tz
).isoformat(),
"current_temp": 15.2,
"current_ext_temp": None,
"target_temp": 19,
},
),
],
any_order=True,
)
assert mock_heater_on.call_count == 0

View File

@@ -0,0 +1,145 @@
""" Test the normal start of a Thermostat """
from unittest.mock import patch, call
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.config_entries import ConfigEntryState
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from pytest_homeassistant_custom_component.common import MockConfigEntry
from ..climate import VersatileThermostat
from .commons import *
async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the normal full start of a thermostat in thermostat_over_switch type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data=FULL_SWITCH_CONFIG,
)
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity: VersatileThermostat = find_my_entity("climate.theoverswitchmockname")
assert entity
assert entity.name == "TheOverSwitchMockName"
assert entity._is_over_climate is False
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
PRESET_ACTIVITY,
]
assert entity.preset_mode is PRESET_NONE
assert entity._security_state is False
assert entity._window_state is None
assert entity._motion_state is None
assert entity._presence_state is None
assert entity._prop_algorithm is not None
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.OFF},
),
]
)
async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the normal full start of a thermostat in thermostat_over_climate type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data=PARTIAL_CLIMATE_CONFIG,
)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
return_value=fake_underlying_climate,
) as mock_find_climate:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity = find_my_entity("climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
assert entity._is_over_climate is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
assert entity._security_state is False
assert entity._window_state is None
assert entity._motion_state is None
assert entity._presence_state is None
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.OFF},
),
]
)
assert mock_find_climate.call_count == 1
assert mock_find_climate.mock_calls[0] == call("climate.mock_climate")
mock_find_climate.assert_has_calls(
[call.find_underlying_entity("climate.mock_climate")]
)

View File

@@ -0,0 +1,83 @@
""" Test the TPI algorithm """
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the TPI calculation"""
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,
CONF_USE_WINDOW_FEATURE: False,
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_DEVICE_POWER: 100,
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
tpi_algo.calculate(15, 10, 7)
assert tpi_algo.on_percent == 1
assert tpi_algo.calculated_on_percent == 1
assert tpi_algo.on_time_sec == 300
assert tpi_algo.off_time_sec == 0
assert entity.mean_cycle_power is None # no device power configured
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.4
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 120
assert tpi_algo.off_time_sec == 180
tpi_algo.set_security(0.1)
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.1
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 30 # >= minimal_activation_delay (=30)
assert tpi_algo.off_time_sec == 270
tpi_algo.unset_security()
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.4
assert tpi_algo.calculated_on_percent == 0.4
assert tpi_algo.on_time_sec == 120
assert tpi_algo.off_time_sec == 180
# Test minimal activation delay
tpi_algo.calculate(15, 14.7, 15)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 0.09
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300
tpi_algo.set_security(0.09)
tpi_algo.calculate(15, 14.7, 15)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 0.09
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300

View File

@@ -0,0 +1,200 @@
""" Test the Window management """
from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import datetime
import time
import logging
logging.getLogger().setLevel(logging.DEBUG)
async def test_window_management_time_not_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 0, # important to not been obliged to wait
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 19
assert entity.window_state is None
# Open the window, but condition of time is not satisfied and check the thermostat don't turns off
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=False
) as mock_condition:
await send_temperature_change_event(entity, 15, datetime.now())
try_window_condition = await send_window_change_event(
entity, True, False, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert mock_send_event.call_count == 0
assert mock_heater_on.call_count == 1
assert mock_heater_off.call_count == 0
assert mock_condition.call_count == 1
assert entity.window_state == STATE_OFF
# Close the window
try_window_condition = await send_window_change_event(
entity, False, False, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state == STATE_OFF
async def test_window_management_time_enough(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Power management"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
CONF_USE_WINDOW_FEATURE: True,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor",
CONF_WINDOW_DELAY: 0, # important to not been obliged to wait
},
)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tpi_algo = entity._prop_algorithm
assert tpi_algo
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_BOOST
assert entity.overpowering_state is None
assert entity.target_temperature == 19
assert entity.window_state is None
# Open the window, but condition of time is not satisfied and check the thermostat don't turns off
with patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
) as mock_heater_on, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
) as mock_heater_off, patch(
"homeassistant.helpers.condition.state", return_value=True
) as mock_condition, patch(
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
return_value=True,
):
await send_temperature_change_event(entity, 15, datetime.now())
try_window_condition = await send_window_change_event(
entity, True, False, datetime.now()
)
# simulate the call to try_window_condition
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 mock_heater_on.call_count == 1
# One call in turn_oiff and one call in the control_heating
assert mock_heater_off.call_count == 2
assert mock_condition.call_count == 1
assert entity.window_state == STATE_ON
# Close the window
try_window_condition = await send_window_change_event(
entity, False, True, datetime.now()
)
# simulate the call to try_window_condition
await try_window_condition(None)
assert entity.window_state == STATE_OFF
assert mock_heater_on.call_count == 2
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
call.send_event(
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
),
],
any_order=False,
)
assert entity.preset_mode is PRESET_BOOST

View File

@@ -14,6 +14,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -70,7 +71,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
@@ -117,6 +117,7 @@
"cycle_min": "Cycle duration (minutes)",
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power (kW)",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
@@ -173,7 +174,6 @@
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},

View File

@@ -13,6 +13,7 @@
"cycle_min": "Durée du cycle (minutes)",
"temp_min": "Température minimale permise",
"temp_max": "Température maximale permise",
"device_power": "Puissance de l'équipement",
"use_window_feature": "Avec détection des ouvertures",
"use_motion_feature": "Avec détection de mouvement",
"use_power_feature": "Avec gestion de la puissance",
@@ -69,7 +70,6 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},
@@ -117,6 +117,7 @@
"cycle_min": "Durée du cycle (minutes)",
"temp_min": "Température minimale permise",
"temp_max": "Température maximale permise",
"device_power": "Puissance de l'équipement",
"use_window_feature": "Avec détection des ouvertures",
"use_motion_feature": "Avec détection de mouvement",
"use_power_feature": "Avec gestion de la puissance",
@@ -173,7 +174,6 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},