Compare commits

..

15 Commits

Author SHA1 Message Date
Jean-Marc Collin
ea7b6a0425 FIX error on startup when my_climate is not found 2023-03-03 22:03:08 +01:00
Jean-Marc Collin
7c8717553b Add duration cycle sensors 2023-03-03 18:28:33 +01:00
Jean-Marc Collin
f672fc807d Add sensors 2023-03-01 08:11:53 +01:00
Jean-Marc Collin
168568ac5d With all binary_sensor ok 2023-02-28 22:48:07 +01:00
Jean-Marc Collin
330c3323d1 Add binary_sensors and it's ok 2023-02-26 23:34:37 +01:00
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
21 changed files with 1757 additions and 81 deletions

View File

@@ -129,6 +129,26 @@ template:
{% 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

@@ -15,7 +15,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -58,13 +58,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
class VersatileThermostatAPI(Dict):
class VersatileThermostatAPI(dict):
"""The VersatileThermostatAPI"""
_hass: HomeAssistant
# _entries: Dict(str, ConfigEntry)
def __init__(self, hass):
def __init__(self, hass: HomeAssistant) -> None:
_LOGGER.debug("building a VersatileThermostatAPI")
super().__init__()
self._hass = hass
@@ -96,12 +96,11 @@ class VersatileThermostatAPI(Dict):
# Example migration function
async def async_migrate_entry(hass, config_entry: ConfigEntry):
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {**config_entry.data}
# TODO: modify Config Entry data

View File

@@ -0,0 +1,184 @@
""" Implements the VersatileThermostat binary sensors component """
import logging
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import STATE_ON
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .commons import VersatileThermostatBaseEntity
from .const import (
CONF_NAME,
CONF_USE_POWER_FEATURE,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_WINDOW_FEATURE,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat binary sensors with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
entities = [SecurityBinarySensor(hass, unique_id, name, entry.data)]
if entry.data.get(CONF_USE_MOTION_FEATURE):
entities.append(MotionBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_WINDOW_FEATURE):
entities.append(WindowBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_PRESENCE_FEATURE):
entities.append(PresenceBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_POWER_FEATURE):
entities.append(OverpoweringBinarySensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the security state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the SecurityState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Security state"
self._attr_unique_id = f"{self._device_name}_security_state"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.security_state
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:shield-alert"
else:
return "mdi:shield-check-outline"
class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the overpowering state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the OverpoweringState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Overpowering state"
self._attr_unique_id = f"{self._device_name}_overpowering_state"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.overpowering_state
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:flash-alert-outline"
else:
return "mdi:flash-outline"
class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the window state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the WindowState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Window state"
self._attr_unique_id = f"{self._device_name}_window_state"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.window_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:window-open-variant"
else:
return "mdi:window-closed-variant"
class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the motion state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the MotionState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Motion state"
self._attr_unique_id = f"{self._device_name}_motion_state"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:motion-sensor"
else:
return "mdi:motion-sensor-off"
class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the presence state"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the PresenceState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Presence state"
self._attr_unique_id = f"{self._device_name}_presence_state"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:home-account"
else:
return "mdi:nature-people"

View File

@@ -8,6 +8,7 @@ from datetime import timedelta, datetime
import voluptuous as vol
from homeassistant.util import dt as dt_util
from homeassistant.core import (
HomeAssistant,
callback,
@@ -20,6 +21,7 @@ from homeassistant.core import (
from homeassistant.components.climate import ClimateEntity
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_component import EntityComponent
import homeassistant.helpers.config_validation as cv
@@ -90,7 +92,8 @@ from homeassistant.const import (
)
from .const import (
# DOMAIN,
DOMAIN,
DEVICE_MANUFACTURER,
CONF_HEATER,
CONF_POWER_SENSOR,
CONF_TEMP_SENSOR,
@@ -204,6 +207,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_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."""
@@ -255,9 +263,24 @@ 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)
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._unique_id)},
name=self._name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
def post_init(self, entry_infos):
"""Finish the initialization of the thermostast"""
@@ -366,8 +389,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)
@@ -401,8 +422,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
@@ -994,18 +1015,56 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
@property
def mean_cycle_power(self) -> float | None:
"""Returns tne mean power consumption during the cycle"""
if self._is_over_climate:
return None
elif self._device_power:
return self._device_power * self._prop_algorithm.on_percent
else:
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 security_state(self) -> bool | None:
"""Get the security_state"""
return self._security_state
@property
def motion_state(self) -> bool | None:
"""Get the motion_state"""
return self._motion_state
@property
def presence_state(self) -> bool | None:
"""Get the presence_state"""
return self._presence_state
@property
def proportional_algorithm(self) -> PropAlgorithm | None:
"""Get the eventual ProportionalAlgorithm"""
return self._prop_algorithm
@property
def last_temperature_mesure(self) -> datetime | None:
"""Get the last temperature datetime"""
return self._last_temperature_mesure
@property
def last_ext_temperature_mesure(self) -> datetime | None:
"""Get the last external temperature datetime"""
return self._last_ext_temperature_mesure
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self._is_over_climate and self._underlying_climate:
@@ -1145,7 +1204,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"""
@@ -1244,6 +1303,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,
@@ -1294,8 +1369,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(_):
@@ -1313,6 +1386,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)
@@ -1335,12 +1409,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):
@@ -1426,14 +1505,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.debug("%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,
@@ -1442,8 +1536,45 @@ 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)
@@ -1455,9 +1586,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
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 = (
state.last_changed if state.last_changed is not None else 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()
@@ -1473,9 +1611,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
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 = (
state.last_changed if state.last_changed is not None else 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()
@@ -1696,7 +1840,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",
@@ -1705,6 +1862,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(
@@ -1755,12 +1913,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
async def check_security(self) -> bool:
"""Check if last temperature date is too long"""
now = datetime.now()
now = datetime.now(self._current_tz)
delta_temp = (
now - self._last_temperature_mesure.replace(tzinfo=None)
now - self._last_temperature_mesure.replace(tzinfo=self._current_tz)
).total_seconds() / 60.0
delta_ext_temp = (
now - self._last_ext_temperature_mesure.replace(tzinfo=None)
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
@@ -1821,8 +1979,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,
@@ -1844,8 +2006,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,
@@ -1874,8 +2040,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,
@@ -2048,14 +2218,30 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self.hvac_mode != HVACMode.OFF:
self._total_energy += self.mean_cycle_power * float(self._cycle_min) / 60.0
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],
@@ -2083,19 +2269,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,
"device_power": self._device_power,
ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power,
ATTR_TOTAL_ENERGY: self.total_energy,
"last_update_datetime": datetime.now().isoformat(),
"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

@@ -0,0 +1,104 @@
""" Some usefull commons class """
import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity, DeviceInfo, DeviceEntryType
from homeassistant.helpers.event import async_track_state_change_event, async_call_later
from .climate import VersatileThermostat
from .const import DOMAIN, DEVICE_MANUFACTURER
_LOGGER = logging.getLogger(__name__)
class VersatileThermostatBaseEntity(Entity):
"""A base class for all entities"""
_my_climate: VersatileThermostat
hass: HomeAssistant
_config_id: str
_devince_name: str
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
"""The CTOR"""
self.hass = hass
self._config_id = config_id
self._device_name = device_name
self._my_climate = None
self._cancel_call = None
self._attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""Do not poll for those entities"""
return False
@property
def my_climate(self) -> VersatileThermostat | None:
"""Returns my climate if found"""
if not self._my_climate:
self._my_climate = self.find_my_versatile_thermostat()
return self._my_climate
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
def find_my_versatile_thermostat(self) -> VersatileThermostat:
"""Find the underlying climate entity"""
try:
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
_LOGGER.debug("Device_info is %s", entity.device_info)
if entity.device_info == self.device_info:
_LOGGER.debug("Found %s!", entity)
return entity
except KeyError:
pass
return None
@callback
async def async_added_to_hass(self):
"""Listen to my climate state change"""
# Check delay condition
async def try_find_climate(_):
_LOGGER.debug(
"%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self
)
mcl = self.my_climate
if mcl:
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
self.async_on_remove(
async_track_state_change_event(
self.hass,
[mcl.entity_id],
self.async_my_climate_changed,
)
)
else:
_LOGGER.warning("%s - no entity to listen. Try later", self)
self._cancel_call = async_call_later(
self.hass, timedelta(seconds=1), try_find_climate
)
await try_find_climate(None)
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change
This method aims to be overriden to take the status change
"""
return

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),
}
)

View File

@@ -2,16 +2,19 @@
from enum import Enum
from homeassistant.const import CONF_NAME
from homeassistant.components.climate.const import (
from homeassistant.components.climate import (
# PRESET_ACTIVITY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
SUPPORT_TARGET_TEMPERATURE,
ClimateEntityFeature,
)
from homeassistant.exceptions import HomeAssistantError
DEVICE_MANUFACTURER = "JMCOLLIN"
DEVICE_MODEL = "Versatile Thermostat"
from .prop_algorithm import (
PROPORTIONAL_FUNCTION_TPI,
)
@@ -126,7 +129,7 @@ CONF_FUNCTIONS = [
CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE]
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
SERVICE_SET_PRESENCE = "set_presence"
SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature"

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": "3.0.0",
"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",

View File

@@ -0,0 +1,358 @@
""" Implements the VersatileThermostat sensors component """
import logging
import math
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import UnitOfTime
from homeassistant.components.sensor import (
SensorEntity,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .commons import VersatileThermostatBaseEntity
from .const import (
CONF_NAME,
CONF_DEVICE_POWER,
CONF_PROP_FUNCTION,
PROPORTIONAL_FUNCTION_TPI,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_TYPE,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat sensors with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_SWITCH:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
entities.append(OnTimeSensor(hass, unique_id, name, entry.data))
entities.append(OffTimeSensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
class EnergySensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Energy sensor which exposes the energy"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Energy"
self._attr_unique_id = f"{self._device_name}_energy"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
if math.isnan(self.my_climate.total_energy) or math.isinf(
self.my_climate.total_energy
):
raise ValueError(f"Sensor has illegal state {self.my_climate.total_energy}")
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.total_energy, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:lightning-bolt"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.ENERGY
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.TOTAL_INCREASING
@property
def native_unit_of_measurement(self) -> str | None:
return "kWh"
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 3
class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a power sensor which exposes the mean power in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Mean power cycle"
self._attr_unique_id = f"{self._device_name}_mean_power_cycle"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf(
self.my_climate.mean_cycle_power
):
raise ValueError(
f"Sensor has illegal state {self.my_climate.mean_cycle_power}"
)
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.mean_cycle_power, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:flash-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.POWER
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return "kW"
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 3
class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on percent sensor which exposes the on_percent in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Power percent"
self._attr_unique_id = f"{self._device_name}_power_percent"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
on_percent = (
float(self.my_climate.proportional_algorithm.on_percent)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(on_percent) or math.isinf(on_percent):
raise ValueError(f"Sensor has illegal state {on_percent}")
old_state = self._attr_native_value
self._attr_native_value = round(
on_percent * 100.0, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:meter-electric-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.POWER_FACTOR
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return "%"
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 1
class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on time sensor which exposes the on_time_sec in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "On time"
self._attr_unique_id = f"{self._device_name}_on_time"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
on_time = (
float(self.my_climate.proportional_algorithm.on_time_sec)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(on_time) or math.isinf(on_time):
raise ValueError(f"Sensor has illegal state {on_time}")
old_state = self._attr_native_value
self._attr_native_value = round(on_time)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:timer-play"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.DURATION
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return UnitOfTime.SECONDS
class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a on time sensor which exposes the off_time_sec in a cycle"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Off time"
self._attr_unique_id = f"{self._device_name}_off_time"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
off_time = (
float(self.my_climate.proportional_algorithm.off_time_sec)
if self.my_climate and self.my_climate.proportional_algorithm
else None
)
if math.isnan(off_time) or math.isinf(off_time):
raise ValueError(f"Sensor has illegal state {off_time}")
old_state = self._attr_native_value
self._attr_native_value = round(off_time)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:timer-off-outline"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.DURATION
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
return UnitOfTime.SECONDS
class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a last temperature datetime sensor"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the last temperature datetime sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Last temperature date"
self._attr_unique_id = f"{self._device_name}_last_temp_datetime"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_temperature_mesure
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:home-clock"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TIMESTAMP
class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a last external temperature datetime sensor"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the last temperature datetime sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Last external temperature date"
self._attr_unique_id = f"{self._device_name}_last_ext_temp_datetime"
@callback
async def async_my_climate_changed(self, event: Event):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", event.origin.name)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.last_ext_temperature_mesure
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:sun-clock"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TIMESTAMP

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

@@ -1,12 +1,14 @@
""" Some common resources """
from unittest.mock import patch
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
from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF
from homeassistant.config_entries import ConfigEntryState
from pytest_homeassistant_custom_component.common import MockConfigEntry
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 *
@@ -17,6 +19,7 @@ from homeassistant.components.climate import (
ATTR_PRESET_MODE,
HVACMode,
HVACAction,
ClimateEntityFeature,
)
from .const import (
@@ -77,6 +80,64 @@ class MockClimate(ClimateEntity):
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:
@@ -113,4 +174,98 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d
)
},
)
await entity._async_temperature_changed(temp_event)
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

@@ -51,6 +51,7 @@ MOCK_TH_OVER_SWITCH_USER_CONFIG = {
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,
@@ -65,6 +66,7 @@ MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1,
# Keep default values which are False
}
@@ -103,7 +105,6 @@ MOCK_MOTION_CONFIG = {
MOCK_POWER_CONFIG = {
CONF_POWER_SENSOR: "sensor.power_sensor",
CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor",
CONF_DEVICE_POWER: 1,
CONF_PRESET_POWER: 10,
}

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

@@ -19,6 +19,8 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
6. check that security is off and preset is changed to boost
"""
tz = get_tz(hass)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
@@ -50,7 +52,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
)
# 1. creates a thermostat and check that security is off
now: datetime = datetime.now()
now: datetime = datetime.now(tz=tz)
entity: VersatileThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
@@ -66,8 +68,10 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
]
assert entity._last_ext_temperature_mesure is not None
assert entity._last_temperature_mesure is not None
assert (entity._last_temperature_mesure - now).total_seconds() < 1
assert (entity._last_ext_temperature_mesure - now).total_seconds() < 1
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
@@ -166,8 +170,12 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
EventType.SECURITY_EVENT,
{
"type": "end",
"last_temperature_mesure": event_timestamp.isoformat(),
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
"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,

View File

@@ -46,7 +46,7 @@ async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
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
assert entity.mean_cycle_power is None # no device power configured
tpi_algo.calculate(15, 14, 5)
assert tpi_algo.on_percent == 0.4

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"
}
},