Compare commits
9 Commits
3.2.0.beta
...
3.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a467b8c3 | ||
|
|
4e3ee0703b | ||
|
|
539ec4a6bd | ||
|
|
9b085f1264 | ||
|
|
637367bd65 | ||
|
|
bcc0a32b6a | ||
|
|
80fa977c15 | ||
|
|
67d20dd083 | ||
|
|
e2e8499bdb |
@@ -3,7 +3,9 @@ default_config:
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.versatile_thermostat: debug
|
||||
custom_components.versatile_thermostat: info
|
||||
custom_components.versatile_thermostat.underlyings: debug
|
||||
custom_components.versatile_thermostat.climate: debug
|
||||
|
||||
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
|
||||
debugpy:
|
||||
@@ -49,14 +51,26 @@ input_boolean:
|
||||
name: Window 1
|
||||
icon: mdi:window-closed-variant
|
||||
# input_boolean to simulate the heater entity switch. Only for development environment.
|
||||
fake_heater_switch1:
|
||||
name: Heater 1 (Linear)
|
||||
fake_heater_switch3:
|
||||
name: Heater 3
|
||||
icon: mdi:radiator
|
||||
fake_heater_switch2:
|
||||
name: Heater (TPI with presence preset)
|
||||
name: Heater 2
|
||||
icon: mdi:radiator
|
||||
fake_heater_switch3:
|
||||
name: Heater (TPI with offset)
|
||||
fake_heater_switch1:
|
||||
name: Heater 1
|
||||
icon: mdi:radiator
|
||||
fake_heater_4switch1:
|
||||
name: Heater (multiswitch1)
|
||||
icon: mdi:radiator
|
||||
fake_heater_4switch2:
|
||||
name: Heater (multiswitch2)
|
||||
icon: mdi:radiator
|
||||
fake_heater_4switch3:
|
||||
name: Heater (multiswitch3)
|
||||
icon: mdi:radiator
|
||||
fake_heater_4switch4:
|
||||
name: Heater (multiswitch4)
|
||||
icon: mdi:radiator
|
||||
# input_boolean to simulate the motion sensor entity. Only for development environment.
|
||||
fake_motion_sensor1:
|
||||
|
||||
13
README-fr.md
13
README-fr.md
@@ -15,7 +15,7 @@
|
||||
- [Installation manuelle](#installation-manuelle)
|
||||
- [Configuration](#configuration)
|
||||
- [Choix des attributs de base](#choix-des-attributs-de-base)
|
||||
- [Sélectionnez l'entité pilotée](#sélectionnez-lentité-pilotée)
|
||||
- [Sélectionnez des entités pilotées](#sélectionnez-des-entités-pilotées)
|
||||
- [Configurez les coefficients de l'algorithme TPI](#configurez-les-coefficients-de-lalgorithme-tpi)
|
||||
- [Configurer la température préréglée](#configurer-la-température-préréglée)
|
||||
- [Configurer les portes/fenêtres en allumant/éteignant les thermostats](#configurer-les-portesfenêtres-en-allumantéteignant-les-thermostats)
|
||||
@@ -53,6 +53,7 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une
|
||||
|
||||
|
||||
>  _*Nouveautés*_
|
||||
> * **Release 3.2** : ajout de la possibilité de commander plusieurs switch à partir du même thermostat. Dans ce mode, les switchs sont déclenchés avec un délai pour minimiser la puissance nécessaire à un instant (on minimise les périodes de recouvrement). Voir [Configuration](#sélectionnez-des-entités-pilotées)
|
||||
> * **Release 3.1** : ajout d'une détection de fenêtres/portes ouvertes par chute de température. Cette nouvelle fonction permet de stopper automatiquement un radiateur lorsque la température chute brutalement. Voir [Le mode auto](#le-mode-auto)
|
||||
> * **Release majeure 3.0** : ajout d'un équipement thermostat et de capteurs (binaires et non binaires) associés. Beaucoup plus proche de la philosphie Home Assistant, vous avez maintenant un accès direct à l'énergie consommée par le radiateur piloté par le thermostat et à plein d'autres capteurs qui seront utiles dans vos automatisations et dashboard.
|
||||
> * **release 2.3** : ajout de la mesure de puissance et d'énergie du radiateur piloté par le thermostat.
|
||||
@@ -139,12 +140,16 @@ Donnez les principaux attributs obligatoires :
|
||||
1. avec le type ```thermostat_over_swutch```, les calculs sont effectués à chaque cycle. Donc en cas de changement de conditions, il faudra attendre le prochain cycle pour voir un changement. Pour cette raison, le cycle ne doit pas être trop long. **5 min est une bonne valeur**,
|
||||
2. si le cycle est trop court, le radiateur ne pourra jamais atteindre la température cible en effet pour le radiateur à accumulation et il sera sollicité inutilement
|
||||
|
||||
## Sélectionnez l'entité pilotée
|
||||
En fonction de votre choix sur le type de thermostat, vous devrez choisir une entité de type switch ou une entité de type climate. Seules les entités compatibles sont présentées.
|
||||
## Sélectionnez des entités pilotées
|
||||
En fonction de votre choix sur le type de thermostat, vous devrez choisir une ou plusieurs entités de type switch ou une entité de type climate. Seules les entités compatibles sont présentées.
|
||||
|
||||
Pour un thermostat de type ```thermostat_over_switch```:
|
||||

|
||||
L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme)
|
||||
L'algorithme à utiliser est aujourd'hui limité à TPI est disponible. Voir [algorithme](#algorithme).
|
||||
Si plusieurs entités de type sont configurées, la thermostat décale les activations afin de minimiser le nombre de switch actif à un instant t. Ca permet une meilleure répartition de la puissance puisque chaque radiateur va s'allumer à son tour.
|
||||
Exemple de déclenchement synchronisé :
|
||||

|
||||
|
||||
|
||||
Pour un thermostat de type ```thermostat_over_climate```:
|
||||

|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
|
||||
|
||||
> _*News*_
|
||||
> * **Release 3.2**: added the ability to control multiple switches from the same thermostat. In this mode, the switches are triggered with a delay to minimize the power required at one time (we minimize the recovery periods). See [Configuration](#select-the-driven-entity)
|
||||
> * **Release 3.1**: added detection of open windows/doors by temperature drop. This new function makes it possible to automatically stop a radiator when the temperature drops suddenly. See [Auto mode](#auto-mode)
|
||||
> * **Major release 3.0**: addition of thermostat equipment and associated sensors (binary and non-binary). Much closer to the Home Assistant philosophy, you now have direct access to the energy consumed by the radiator controlled by the thermostat and many other sensors that will be useful in your automations and dashboard.
|
||||
> * **release 2.3**: addition of the power and energy measurement of the radiator controlled by the thermostat.
|
||||
@@ -141,6 +142,9 @@ Depending on your choice on the type of thermostat, you will have to choose a sw
|
||||
For a ```thermostat_over_switch``` thermostat:
|
||||

|
||||
The algorithm to be used today is limited to TPI is available. See [algorithm](#algorithm)
|
||||
If several type entities are configured, the thermostat staggers the activations in order to minimize the number of active switches at a time t. This allows a better distribution of power since each radiator will turn on in turn.
|
||||
Example of synchronized triggering:
|
||||

|
||||
|
||||
For a ```thermostat_over_climate``` thermostat:
|
||||

|
||||
|
||||
@@ -4,8 +4,6 @@ import logging
|
||||
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
# from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -13,7 +11,6 @@ from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
CoreState,
|
||||
DOMAIN as HA_DOMAIN,
|
||||
Event,
|
||||
State,
|
||||
)
|
||||
@@ -23,7 +20,6 @@ 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
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
|
||||
@@ -40,7 +36,6 @@ from homeassistant.helpers import (
|
||||
) # , config_validation as cv
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ATTR_PRESET_MODE,
|
||||
# ATTR_FAN_MODE,
|
||||
HVACMode,
|
||||
@@ -57,14 +52,6 @@ from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
# PRESET_SLEEP,
|
||||
ClimateEntityFeature,
|
||||
# ClimateEntityFeature.PRESET_MODE,
|
||||
# SUPPORT_TARGET_TEMPERATURE,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HUMIDITY,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
# SERVICE_SET_PRESET_MODE,
|
||||
SERVICE_SET_SWING_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
)
|
||||
|
||||
# from homeassistant.components.climate import (
|
||||
@@ -85,9 +72,6 @@ from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
)
|
||||
@@ -97,6 +81,9 @@ from .const import (
|
||||
PLATFORMS,
|
||||
DEVICE_MANUFACTURER,
|
||||
CONF_HEATER,
|
||||
CONF_HEATER_2,
|
||||
CONF_HEATER_3,
|
||||
CONF_HEATER_4,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_TEMP_SENSOR,
|
||||
CONF_EXTERNAL_TEMP_SENSOR,
|
||||
@@ -146,11 +133,15 @@ from .const import (
|
||||
ATTR_TOTAL_ENERGY,
|
||||
)
|
||||
|
||||
from .underlyings import UnderlyingSwitch, UnderlyingClimate, UnderlyingEntity
|
||||
|
||||
from .prop_algorithm import PropAlgorithm
|
||||
from .open_window_algorithm import WindowOpenDetectionAlgorithm
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# _LOGGER.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -212,6 +203,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
# The list of VersatileThermostat entities
|
||||
# No more needed
|
||||
# _registry: dict[str, object] = {}
|
||||
_hass: HomeAssistant
|
||||
_last_temperature_mesure: datetime
|
||||
_last_ext_temperature_mesure: datetime
|
||||
_total_energy: float
|
||||
@@ -219,8 +211,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
_window_state: bool
|
||||
_motion_state: bool
|
||||
_presence_state: bool
|
||||
_security_state: bool
|
||||
_window_auto_state: bool
|
||||
_underlyings: list[UnderlyingEntity]
|
||||
|
||||
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||
"""Initialize the thermostat."""
|
||||
@@ -264,14 +256,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self._security_state = None
|
||||
|
||||
self._thermostat_type = None
|
||||
self._heater_entity_id = None
|
||||
self._climate_entity_id = None
|
||||
self._is_over_climate = False
|
||||
self._underlying_climate = None
|
||||
|
||||
self._attr_translation_key = "versatile_thermostat"
|
||||
|
||||
self._total_energy = None
|
||||
|
||||
# because energy of climate is calculated in the thermostat we have to keep that here and not in underlying entity
|
||||
self._underlying_climate_start_hvac_action_date = None
|
||||
self._underlying_climate_delta_t = 0
|
||||
|
||||
@@ -286,18 +277,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
|
||||
|
||||
self.post_init(entry_infos)
|
||||
self._underlyings = []
|
||||
|
||||
@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,
|
||||
)
|
||||
self.post_init(entry_infos)
|
||||
|
||||
def post_init(self, entry_infos):
|
||||
"""Finish the initialization of the thermostast"""
|
||||
@@ -324,10 +306,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
else:
|
||||
_LOGGER.debug("value %s not found in Entry", value)
|
||||
|
||||
# Stop eventual cycle running
|
||||
if self._async_cancel_cycle is not None:
|
||||
self._async_cancel_cycle()
|
||||
self._async_cancel_cycle = None
|
||||
if self._window_call_cancel is not None:
|
||||
self._window_call_cancel()
|
||||
self._window_call_cancel = None
|
||||
@@ -335,16 +313,40 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self._motion_call_cancel()
|
||||
self._motion_call_cancel = None
|
||||
|
||||
# Exploit usable attributs
|
||||
self._cycle_min = entry_infos.get(CONF_CYCLE_MIN)
|
||||
|
||||
# Initialize underlying entities
|
||||
self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE)
|
||||
if self._thermostat_type == CONF_THERMOSTAT_CLIMATE:
|
||||
self._is_over_climate = True
|
||||
self._climate_entity_id = entry_infos.get(CONF_CLIMATE)
|
||||
self._underlyings.append(
|
||||
UnderlyingClimate(
|
||||
hass=self._hass,
|
||||
thermostat=self,
|
||||
climate_entity_id=entry_infos.get(CONF_CLIMATE),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._heater_entity_id = entry_infos.get(CONF_HEATER)
|
||||
self._is_over_climate = False
|
||||
lst_switches = [entry_infos.get(CONF_HEATER)]
|
||||
if entry_infos.get(CONF_HEATER_2):
|
||||
lst_switches.append(entry_infos.get(CONF_HEATER_2))
|
||||
if entry_infos.get(CONF_HEATER_3):
|
||||
lst_switches.append(entry_infos.get(CONF_HEATER_3))
|
||||
if entry_infos.get(CONF_HEATER_4):
|
||||
lst_switches.append(entry_infos.get(CONF_HEATER_4))
|
||||
|
||||
delta_cycle = self._cycle_min * 60 / len(lst_switches)
|
||||
self._underlyings = []
|
||||
for idx, switch in enumerate(lst_switches):
|
||||
self._underlyings.append(
|
||||
UnderlyingSwitch(
|
||||
hass=self._hass,
|
||||
thermostat=self,
|
||||
switch_entity_id=switch,
|
||||
initial_delay_sec=idx * delta_cycle,
|
||||
)
|
||||
)
|
||||
|
||||
self._cycle_min = entry_infos.get(CONF_CYCLE_MIN)
|
||||
self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION)
|
||||
self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR)
|
||||
self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR)
|
||||
@@ -511,10 +513,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self._total_energy = 0
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s",
|
||||
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
|
||||
self,
|
||||
self.unique_id,
|
||||
self._heater_entity_id,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
@@ -523,19 +524,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Add listener
|
||||
if self._thermostat_type == CONF_THERMOSTAT_CLIMATE:
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._climate_entity_id], self._async_climate_changed
|
||||
# Add listener to all underlying entities
|
||||
if self.is_over_climate:
|
||||
for climate in self._underlyings:
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [climate.entity_id], self._async_climate_changed
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._heater_entity_id], self._async_switch_changed
|
||||
for switch in self._underlyings:
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [switch.entity_id], self._async_switch_changed
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
@@ -606,30 +609,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
# Ingore this error which is possible if underlying climate is not found temporary
|
||||
pass
|
||||
|
||||
# starts a cycle if we are in over_climate type
|
||||
if self._is_over_climate:
|
||||
self.async_on_remove(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_control_heating,
|
||||
interval=timedelta(minutes=self._cycle_min),
|
||||
)
|
||||
)
|
||||
|
||||
def async_remove_thermostat(self):
|
||||
"""Called when the thermostat will be removed"""
|
||||
_LOGGER.info("%s - Removing thermostat", self)
|
||||
if self._async_cancel_cycle:
|
||||
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
|
||||
for under in self._underlyings:
|
||||
under.remove_entity()
|
||||
|
||||
async def async_startup(self):
|
||||
"""Triggered on startup, used to get old state and set internal states accordingly"""
|
||||
@@ -640,28 +624,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
_LOGGER.debug("%s - Calling async_startup_internal", self)
|
||||
need_write_state = False
|
||||
|
||||
# Get the underlying thermostat
|
||||
if self._is_over_climate:
|
||||
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,
|
||||
self._climate_entity_id,
|
||||
)
|
||||
# #56 keep the over_climate and try periodically to find the underlying climate
|
||||
# self._is_over_climate = False
|
||||
raise UnknownEntity(
|
||||
f"Underlying thermostat {self._climate_entity_id} not found"
|
||||
)
|
||||
# Initialize all UnderlyingEntities
|
||||
for under in self._underlyings:
|
||||
under.startup()
|
||||
|
||||
temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
|
||||
if temperature_state and temperature_state.state not in (
|
||||
@@ -701,22 +666,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self,
|
||||
)
|
||||
|
||||
if self._is_over_climate:
|
||||
climate_state = self.hass.states.get(self._climate_entity_id)
|
||||
if climate_state and climate_state.state not in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
):
|
||||
self._hvac_mode = climate_state.state
|
||||
need_write_state = True
|
||||
else:
|
||||
switch_state = self.hass.states.get(self._heater_entity_id)
|
||||
if switch_state and switch_state.state not in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
):
|
||||
self.hass.create_task(self._check_switch_initial_state())
|
||||
|
||||
if self._pmax_on:
|
||||
# try to acquire current power and power max
|
||||
current_power_state = self.hass.states.get(self._power_sensor_entity_id)
|
||||
@@ -801,7 +750,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self._prop_algorithm.calculate(
|
||||
self._target_temp, self._cur_temp, self._cur_ext_temp
|
||||
)
|
||||
self.hass.create_task(self._async_control_heating())
|
||||
|
||||
self.hass.create_task(self._check_switch_initial_state())
|
||||
# Start the control_heating
|
||||
# starts a cycle if we are in over_climate type
|
||||
if self._is_over_climate:
|
||||
self.async_on_remove(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_control_heating,
|
||||
interval=timedelta(minutes=self._cycle_min),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.hass.create_task(self._async_control_heating())
|
||||
|
||||
await self.get_my_previous_state()
|
||||
|
||||
@@ -888,6 +850,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
def __str__(self):
|
||||
return f"VersatileThermostat-{self.name}"
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return self._unique_id
|
||||
@@ -903,8 +876,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
@property
|
||||
def hvac_modes(self):
|
||||
"""List of available operation modes."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.hvac_modes
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).hvac_modes
|
||||
|
||||
return self._hvac_list
|
||||
|
||||
@@ -914,8 +887,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.FAN_MODE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.fan_mode
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).fan_mode
|
||||
|
||||
return None
|
||||
|
||||
@@ -925,8 +898,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.FAN_MODE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.fan_modes
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).fan_modes
|
||||
|
||||
return []
|
||||
|
||||
@@ -936,8 +909,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.SWING_MODE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.swing_mode
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).swing_mode
|
||||
|
||||
return None
|
||||
|
||||
@@ -947,42 +920,56 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.SWING_MODE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.swing_modes
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).swing_modes
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.temperature_unit
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).temperature_unit
|
||||
|
||||
return self._unit
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return current operation."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.hvac_mode
|
||||
if self._is_over_climate:
|
||||
# if one not OFF -> return it
|
||||
# else OFF
|
||||
for under in self._underlyings:
|
||||
if (action := under.hvac_mode) not in [HVACMode.OFF]:
|
||||
return action
|
||||
return HVACMode.OFF
|
||||
|
||||
return self._hvac_mode
|
||||
|
||||
@property
|
||||
def hvac_action(self):
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current running hvac operation if supported.
|
||||
|
||||
Need to be one of CURRENT_HVAC_*.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.hvac_action
|
||||
if self._is_over_climate:
|
||||
# if one not IDLE or OFF -> return it
|
||||
# else if one IDLE -> IDLE
|
||||
# else OFF
|
||||
one_idle = False
|
||||
for under in self._underlyings:
|
||||
if action := under.hvac_action not in [HVACAction.IDLE, HVACAction.OFF]:
|
||||
return action
|
||||
if under.hvac_action == HVACAction.IDLE:
|
||||
one_idle = True
|
||||
if one_idle:
|
||||
return HVACAction.IDLE
|
||||
return HVACAction.OFF
|
||||
|
||||
if self._hvac_mode == HVACMode.OFF:
|
||||
return HVACAction.OFF
|
||||
if not self._is_device_active:
|
||||
return HVACAction.IDLE
|
||||
if self._ac_mode:
|
||||
return HVACAction.COOLING
|
||||
return HVACAction.HEATING
|
||||
|
||||
@property
|
||||
@@ -993,24 +980,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.supported_features | self._support_flags
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).supported_features | self._support_flags
|
||||
|
||||
return self._support_flags
|
||||
|
||||
@property
|
||||
def _is_device_active(self):
|
||||
"""If the toggleable device is currently active."""
|
||||
if self._is_over_climate:
|
||||
if self._underlying_climate:
|
||||
return self._underlying_climate.hvac_action not in [
|
||||
HVACAction.IDLE,
|
||||
HVACAction.OFF,
|
||||
]
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return self._hass.states.is_state(self._heater_entity_id, STATE_ON)
|
||||
"""Returns true if one underlying is active"""
|
||||
for under in self._underlyings:
|
||||
if under.is_device_active:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
@@ -1020,8 +1001,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
@property
|
||||
def target_temperature_step(self) -> float | None:
|
||||
"""Return the supported step of target temperature."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.target_temperature_step
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).target_temperature_step
|
||||
|
||||
return None
|
||||
|
||||
@@ -1031,8 +1012,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.target_temperature_high
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).target_temperature_high
|
||||
|
||||
return None
|
||||
|
||||
@@ -1042,8 +1023,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.target_temperature_low
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).target_temperature_low
|
||||
|
||||
return None
|
||||
|
||||
@@ -1053,8 +1034,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
Requires ClimateEntityFeature.AUX_HEAT.
|
||||
"""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.is_aux_heat
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).is_aux_heat
|
||||
|
||||
return None
|
||||
|
||||
@@ -1064,7 +1045,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
if not self._device_power or self._is_over_climate:
|
||||
return None
|
||||
|
||||
return float(self._device_power * self._prop_algorithm.on_percent)
|
||||
return float(
|
||||
self.nb_underlying_entities
|
||||
* self._device_power
|
||||
* self._prop_algorithm.on_percent
|
||||
)
|
||||
|
||||
@property
|
||||
def total_energy(self) -> float | None:
|
||||
@@ -1156,31 +1141,53 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
"""True if the Window auto feature is enabled"""
|
||||
return self._window_auto_on
|
||||
|
||||
@property
|
||||
def nb_underlying_entities(self) -> int:
|
||||
"""Returns the number of underlying entities"""
|
||||
return len(self._underlyings)
|
||||
|
||||
def underlying_entity_id(self, index=0) -> str | None:
|
||||
"""The climate_entity_id. Added for retrocompatibility reason"""
|
||||
if index < self.nb_underlying_entities:
|
||||
return self.underlying_entity(index).entity_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def underlying_entity(self, index=0) -> UnderlyingEntity | None:
|
||||
"""Get the underlying entity at specified index"""
|
||||
if index < self.nb_underlying_entities:
|
||||
return self._underlyings[index]
|
||||
else:
|
||||
return None
|
||||
|
||||
def turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.turn_aux_heat_on()
|
||||
if self._is_over_climate and self.underlying_entity(0):
|
||||
return self.underlying_entity(0).turn_aux_heat_on()
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
await self._underlying_climate.async_turn_aux_heat_on()
|
||||
if self._is_over_climate:
|
||||
for under in self._underlyings:
|
||||
await under.async_turn_aux_heat_on()
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
return self._underlying_climate.turn_aux_heat_off()
|
||||
if self._is_over_climate:
|
||||
for under in self._underlyings:
|
||||
return under.turn_aux_heat_off()
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
await self._underlying_climate.async_turn_aux_heat_off()
|
||||
if self._is_over_climate:
|
||||
for under in self._underlyings:
|
||||
await under.async_turn_aux_heat_off()
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -1191,28 +1198,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
if hvac_mode is None:
|
||||
return
|
||||
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
data = {ATTR_ENTITY_ID: self._climate_entity_id, "hvac_mode": hvac_mode}
|
||||
await self.hass.services.async_call(
|
||||
CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, context=self._context
|
||||
self._hvac_mode = hvac_mode
|
||||
|
||||
# Delegate to all underlying
|
||||
need_control_heating = False
|
||||
for under in self._underlyings:
|
||||
need_control_heating = (
|
||||
await under.set_hvac_mode(hvac_mode) or need_control_heating
|
||||
)
|
||||
# await self._underlying_climate.async_set_hvac_mode(hvac_mode)
|
||||
self._hvac_mode = hvac_mode # self._underlying_climate.hvac_mode
|
||||
else:
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
self._hvac_mode = HVACMode.HEAT
|
||||
await self._async_control_heating(force=True)
|
||||
elif hvac_mode == HVACMode.COOL:
|
||||
self._hvac_mode = HVACMode.COOL
|
||||
await self._async_control_heating(force=True)
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
if self._is_device_active:
|
||||
await self._async_underlying_entity_turn_off()
|
||||
await self._async_control_heating(force=True)
|
||||
else:
|
||||
_LOGGER.error("Unrecognized hvac mode: %s", hvac_mode)
|
||||
return
|
||||
|
||||
if need_control_heating:
|
||||
await self._async_control_heating(force=True)
|
||||
|
||||
# Ensure we update the current operation after changing the mode
|
||||
self.reset_last_temperature_time()
|
||||
|
||||
@@ -1304,52 +1301,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target fan mode."""
|
||||
_LOGGER.info("%s - Set fan mode: %s", self, fan_mode)
|
||||
if fan_mode is None:
|
||||
if fan_mode is None or not self._is_over_climate:
|
||||
return
|
||||
|
||||
for under in self._underlyings:
|
||||
await under.set_fan_mode(fan_mode)
|
||||
self._fan_mode = fan_mode
|
||||
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._climate_entity_id,
|
||||
"fan_mode": fan_mode,
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, data, context=self._context
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_humidity(self, humidity: int):
|
||||
"""Set new target humidity."""
|
||||
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
|
||||
if humidity is None:
|
||||
if humidity is None or not self._is_over_climate:
|
||||
return
|
||||
for under in self._underlyings:
|
||||
await under.set_humidity(humidity)
|
||||
self._humidity = humidity
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._climate_entity_id,
|
||||
"humidity": humidity,
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, data, context=self._context
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing operation."""
|
||||
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
|
||||
if swing_mode is None:
|
||||
if swing_mode is None or not self._is_over_climate:
|
||||
return
|
||||
for under in self._underlyings:
|
||||
await under.set_swing_mode(swing_mode)
|
||||
self._swing_mode = swing_mode
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._climate_entity_id,
|
||||
"swing_mode": swing_mode,
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, data, context=self._context
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
@@ -1366,16 +1343,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
async def _async_internal_set_temperature(self, temperature):
|
||||
"""Set the target temperature and the target temperature of underlying climate if any"""
|
||||
self._target_temp = temperature
|
||||
if self._is_over_climate and self._underlying_climate:
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._climate_entity_id,
|
||||
"temperature": temperature,
|
||||
"target_temp_high": self._attr_max_temp,
|
||||
"target_temp_low": self._attr_min_temp,
|
||||
}
|
||||
if not self._is_over_climate:
|
||||
return
|
||||
|
||||
await self.hass.services.async_call(
|
||||
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context
|
||||
for under in self._underlyings:
|
||||
await under.set_temperature(
|
||||
temperature, self._attr_max_temp, self._attr_min_temp
|
||||
)
|
||||
|
||||
def get_state_date_or_now(self, state: State):
|
||||
@@ -1547,7 +1520,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
new_preset,
|
||||
)
|
||||
# We do not change the preset which is kept to ACTIVITY but only the target_temperature
|
||||
await self._async_internal_set_temperature(self._presets[new_preset])
|
||||
# We take the presence into account
|
||||
await self._async_internal_set_temperature(self.find_preset_temp(new_preset))
|
||||
self.recalculate()
|
||||
await self._async_control_heating(force=True)
|
||||
|
||||
@@ -1565,12 +1539,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
async def _check_switch_initial_state(self):
|
||||
"""Prevent the device from keep running if HVAC_MODE_OFF."""
|
||||
_LOGGER.debug("%s - Calling _check_switch_initial_state", self)
|
||||
if self._hvac_mode == HVACMode.OFF and self._is_device_active:
|
||||
_LOGGER.warning(
|
||||
"The climate mode is OFF, but the switch device is ON. Turning off device %s",
|
||||
self._heater_entity_id,
|
||||
)
|
||||
await self._async_underlying_entity_turn_off()
|
||||
if self.is_over_climate:
|
||||
return
|
||||
for under in self._underlyings:
|
||||
await under.check_initial_state(self._hvac_mode)
|
||||
|
||||
@callback
|
||||
def _async_switch_changed(self, event):
|
||||
@@ -1661,7 +1633,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
self.update_custom_attributes()
|
||||
await self._async_control_heating(True)
|
||||
await self._async_control_heating()
|
||||
|
||||
@callback
|
||||
async def _async_update_temp(self, state: State):
|
||||
@@ -1856,31 +1828,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
self._target_temp,
|
||||
)
|
||||
|
||||
async def _async_heater_turn_on(self):
|
||||
"""Turn heater toggleable device on."""
|
||||
data = {ATTR_ENTITY_ID: self._heater_entity_id}
|
||||
await self.hass.services.async_call(
|
||||
HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context
|
||||
)
|
||||
|
||||
async def _async_underlying_entity_turn_off(self):
|
||||
"""Turn heater toggleable device off."""
|
||||
if not self._is_over_climate:
|
||||
_LOGGER.debug(
|
||||
"%s - Stopping underlying switch %s", self, self._heater_entity_id
|
||||
)
|
||||
data = {ATTR_ENTITY_ID: self._heater_entity_id}
|
||||
await self.hass.services.async_call(
|
||||
HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - Stopping underlying switch %s", self, self._climate_entity_id
|
||||
)
|
||||
data = {ATTR_ENTITY_ID: self._climate_entity_id}
|
||||
await self.hass.services.async_call(
|
||||
HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
||||
)
|
||||
"""Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off"""
|
||||
|
||||
for under in self._underlyings:
|
||||
await under.turn_off()
|
||||
|
||||
async def _async_manage_window_auto(self):
|
||||
"""The management of the window auto feature"""
|
||||
@@ -2039,7 +1991,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
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:
|
||||
if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF:
|
||||
_LOGGER.warning(
|
||||
"%s - overpowering is detected. Heater preset will be set to 'power'",
|
||||
self,
|
||||
@@ -2241,17 +2193,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
|
||||
if self._is_over_climate and self._underlying_climate is None:
|
||||
_LOGGER.info(
|
||||
"%s - Underlying climate is not initialized. Try to initialize it", self
|
||||
)
|
||||
try:
|
||||
await self.async_startup()
|
||||
except UnknownEntity as err:
|
||||
# still not found, we an stop here
|
||||
raise err
|
||||
for under in self._underlyings:
|
||||
if not under.is_initialized:
|
||||
_LOGGER.info(
|
||||
"%s - Underlying %s is not initialized. Try to initialize it",
|
||||
self,
|
||||
under.entity_id,
|
||||
)
|
||||
try:
|
||||
under.startup()
|
||||
except UnknownEntity as err:
|
||||
# still not found, we an stop here
|
||||
raise err
|
||||
|
||||
# Check overpowering condition
|
||||
# Not necessary for switch because each switch is checking at startup
|
||||
overpowering: bool = await self.check_overpowering()
|
||||
if overpowering:
|
||||
_LOGGER.debug("%s - End of cycle (overpowering)", self)
|
||||
@@ -2265,128 +2221,19 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
# Stop here if we are off
|
||||
if self._hvac_mode == HVACMode.OFF:
|
||||
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", self)
|
||||
# A security to force stop heater if still active
|
||||
if self._is_device_active:
|
||||
await self._async_underlying_entity_turn_off()
|
||||
return
|
||||
|
||||
if not self._is_over_climate:
|
||||
on_time_sec: int = self._prop_algorithm.on_time_sec
|
||||
off_time_sec: int = self._prop_algorithm.off_time_sec
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - Checking new cycle. on_time_sec=%.0f, off_time_sec=%.0f, security_state=%s, preset_mode=%s",
|
||||
self,
|
||||
on_time_sec,
|
||||
off_time_sec,
|
||||
self._security_state,
|
||||
self._attr_preset_mode,
|
||||
)
|
||||
|
||||
# Cancel eventual previous cycle if any
|
||||
if self._async_cancel_cycle is not None:
|
||||
if force:
|
||||
_LOGGER.debug("%s - we force a new cycle", self)
|
||||
self._async_cancel_cycle()
|
||||
self._async_cancel_cycle = None
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - A previous cycle is alredy running and no force -> waits for its end",
|
||||
self,
|
||||
)
|
||||
self._should_relaunch_control_heating = True
|
||||
_LOGGER.debug("%s - End of cycle (2)", self)
|
||||
return
|
||||
|
||||
if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0:
|
||||
|
||||
async def _turn_on_off_later(
|
||||
on: bool, # pylint: disable=invalid-name
|
||||
time,
|
||||
heater_action,
|
||||
next_cycle_action,
|
||||
):
|
||||
if self._async_cancel_cycle:
|
||||
self._async_cancel_cycle()
|
||||
self._async_cancel_cycle = None
|
||||
_LOGGER.debug("%s - Stopping cycle during calculation", self)
|
||||
|
||||
if self._hvac_mode == HVACMode.OFF:
|
||||
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
|
||||
if self._is_device_active:
|
||||
await self._async_underlying_entity_turn_off()
|
||||
return
|
||||
|
||||
if on:
|
||||
if await self.check_overpowering():
|
||||
_LOGGER.debug("%s - End of cycle (3)", self)
|
||||
return
|
||||
# Security mode could have change the on_time percent
|
||||
await self.check_security()
|
||||
time = self._prop_algorithm.on_time_sec
|
||||
|
||||
action_label = "start" if on else "stop"
|
||||
if self._should_relaunch_control_heating:
|
||||
_LOGGER.debug(
|
||||
"Don't %s cause a cycle have to be relaunch", action_label
|
||||
)
|
||||
self._should_relaunch_control_heating = False
|
||||
self.hass.create_task(self._async_control_heating())
|
||||
# await self._async_control_heating()
|
||||
_LOGGER.debug("%s - End of cycle (3)", self)
|
||||
return
|
||||
|
||||
if time > 0:
|
||||
_LOGGER.info(
|
||||
"%s - %s heating for %d min %d sec",
|
||||
self,
|
||||
action_label,
|
||||
time // 60,
|
||||
time % 60,
|
||||
)
|
||||
await heater_action()
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - No action on heater cause duration is 0", self
|
||||
)
|
||||
self._async_cancel_cycle = async_call_later(
|
||||
self.hass,
|
||||
time,
|
||||
next_cycle_action,
|
||||
)
|
||||
|
||||
async def _turn_on_later(_):
|
||||
await _turn_on_off_later(
|
||||
on=True,
|
||||
time=self._prop_algorithm.on_time_sec,
|
||||
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(
|
||||
on=False,
|
||||
time=self._prop_algorithm.off_time_sec,
|
||||
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)
|
||||
|
||||
elif self._is_device_active:
|
||||
_LOGGER.info(
|
||||
"%s - stop heating (2) for %d min %d sec",
|
||||
self,
|
||||
off_time_sec // 60,
|
||||
off_time_sec % 60,
|
||||
for under in self._underlyings:
|
||||
await under.start_cycle(
|
||||
self._hvac_mode,
|
||||
self._prop_algorithm.on_time_sec,
|
||||
self._prop_algorithm.off_time_sec,
|
||||
force,
|
||||
)
|
||||
await self._async_underlying_entity_turn_off()
|
||||
|
||||
else:
|
||||
_LOGGER.debug("%s - nothing to do", self)
|
||||
|
||||
self.update_custom_attributes()
|
||||
|
||||
@@ -2480,16 +2327,25 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||
"window_auto_max_duration": self._window_auto_max_duration,
|
||||
}
|
||||
if self._is_over_climate:
|
||||
self._attr_extra_state_attributes[
|
||||
"underlying_climate"
|
||||
] = self._climate_entity_id
|
||||
self._attr_extra_state_attributes["underlying_climate"] = self._underlyings[
|
||||
0
|
||||
].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"
|
||||
] = self._heater_entity_id
|
||||
"underlying_switch_1"
|
||||
] = self._underlyings[0].entity_id
|
||||
self._attr_extra_state_attributes["underlying_switch_2"] = (
|
||||
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
|
||||
)
|
||||
self._attr_extra_state_attributes["underlying_switch_3"] = (
|
||||
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
|
||||
)
|
||||
self._attr_extra_state_attributes["underlying_switch_4"] = (
|
||||
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
|
||||
)
|
||||
self._attr_extra_state_attributes[
|
||||
"on_percent"
|
||||
] = self._prop_algorithm.on_percent
|
||||
|
||||
@@ -19,7 +19,7 @@ class VersatileThermostatBaseEntity(Entity):
|
||||
_my_climate: VersatileThermostat
|
||||
hass: HomeAssistant
|
||||
_config_id: str
|
||||
_devince_name: str
|
||||
_device_name: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
|
||||
"""The CTOR"""
|
||||
|
||||
@@ -41,6 +41,9 @@ from .const import (
|
||||
DOMAIN,
|
||||
CONF_NAME,
|
||||
CONF_HEATER,
|
||||
CONF_HEATER_2,
|
||||
CONF_HEATER_3,
|
||||
CONF_HEATER_4,
|
||||
CONF_TEMP_SENSOR,
|
||||
CONF_EXTERNAL_TEMP_SENSOR,
|
||||
CONF_POWER_SENSOR,
|
||||
@@ -224,6 +227,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_HEATER_2): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_HEATER_3): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_HEATER_4): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||
),
|
||||
),
|
||||
vol.Required(
|
||||
CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
|
||||
): vol.In(
|
||||
|
||||
@@ -30,6 +30,9 @@ DOMAIN = "versatile_thermostat"
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
CONF_HEATER = "heater_entity_id"
|
||||
CONF_HEATER_2 = "heater_entity2_id"
|
||||
CONF_HEATER_3 = "heater_entity3_id"
|
||||
CONF_HEATER_4 = "heater_entity4_id"
|
||||
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
|
||||
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
|
||||
CONF_POWER_SENSOR = "power_sensor_entity_id"
|
||||
|
||||
@@ -22,12 +22,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Linked entity",
|
||||
"description": "Linked entity attributes",
|
||||
"title": "Linked entities",
|
||||
"description": "Linked entities attributes",
|
||||
"data": {
|
||||
"heater_entity_id": "Heater entity id",
|
||||
"heater_entity_id": "Heater switch",
|
||||
"heater_entity2_id": "2nd Heater switch",
|
||||
"heater_entity3_id": "3rd Heater switch",
|
||||
"heater_entity4_id": "4th Heater switch",
|
||||
"proportional_function": "Algorithm",
|
||||
"climate_entity_id": "Underlying thermostat"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Mandatory heater entity id",
|
||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||
"climate_entity_id": "Underlying thermostat entity id"
|
||||
"climate_entity_id": "Underlying climate entity id"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -142,12 +153,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Linked entity",
|
||||
"description": "Linked entity attributes",
|
||||
"title": "Linked entities",
|
||||
"description": "Linked entities attributes",
|
||||
"data": {
|
||||
"heater_entity_id": "Heater entity id",
|
||||
"heater_entity_id": "Heater switch",
|
||||
"heater_entity2_id": "2nd Heater switch",
|
||||
"heater_entity3_id": "3rd Heater switch",
|
||||
"heater_entity4_id": "4th Heater switch",
|
||||
"proportional_function": "Algorithm",
|
||||
"climate_entity_id": "Underlying thermostat"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Mandatory heater entity id",
|
||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||
"climate_entity_id": "Underlying thermostat entity id"
|
||||
"climate_entity_id": "Underlying climate entity id"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -262,16 +284,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_attributes": {
|
||||
"_": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"power": "Shedding",
|
||||
"security": "Security",
|
||||
"none": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
""" Some common resources """
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
|
||||
@@ -19,11 +21,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from ..climate import VersatileThermostat
|
||||
from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from ..underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
MOCK_TH_OVER_SWITCH_USER_CONFIG,
|
||||
MOCK_TH_OVER_4SWITCH_USER_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
|
||||
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
|
||||
MOCK_PRESETS_CONFIG,
|
||||
@@ -59,6 +64,20 @@ PARTIAL_CLIMATE_CONFIG = (
|
||||
| MOCK_ADVANCED_CONFIG
|
||||
)
|
||||
|
||||
FULL_4SWITCH_CONFIG = (
|
||||
MOCK_TH_OVER_4SWITCH_USER_CONFIG
|
||||
| MOCK_TH_OVER_4SWITCH_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
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MockClimate(ClimateEntity):
|
||||
"""A Mock Climate class used for Underlying climate mode"""
|
||||
@@ -180,8 +199,16 @@ def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity:
|
||||
return None
|
||||
|
||||
|
||||
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
|
||||
async def send_temperature_change_event(
|
||||
entity: VersatileThermostat, new_temp, date, sleep=True
|
||||
):
|
||||
"""Sending a new temperature event simulating a change on temperature sensor"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s",
|
||||
new_temp,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
temp_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -193,13 +220,21 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d
|
||||
)
|
||||
},
|
||||
)
|
||||
return await entity._async_temperature_changed(temp_event)
|
||||
await entity._async_temperature_changed(temp_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def send_ext_temperature_change_event(
|
||||
entity: VersatileThermostat, new_temp, date
|
||||
entity: VersatileThermostat, new_temp, date, sleep=True
|
||||
):
|
||||
"""Sending a new external temperature event simulating a change on temperature sensor"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s",
|
||||
new_temp,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
temp_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -211,11 +246,21 @@ async def send_ext_temperature_change_event(
|
||||
)
|
||||
},
|
||||
)
|
||||
return await entity._async_ext_temperature_changed(temp_event)
|
||||
await entity._async_ext_temperature_changed(temp_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def send_power_change_event(entity: VersatileThermostat, new_power, date):
|
||||
async def send_power_change_event(
|
||||
entity: VersatileThermostat, new_power, date, sleep=True
|
||||
):
|
||||
"""Sending a new power event simulating a change on power sensor"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_power=%.2f date=%s on %s",
|
||||
new_power,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
power_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -227,11 +272,21 @@ async def send_power_change_event(entity: VersatileThermostat, new_power, date):
|
||||
)
|
||||
},
|
||||
)
|
||||
return await entity._async_power_changed(power_event)
|
||||
await entity._async_power_changed(power_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date):
|
||||
async def send_max_power_change_event(
|
||||
entity: VersatileThermostat, new_power_max, date, sleep=True
|
||||
):
|
||||
"""Sending a new power max event simulating a change on power max sensor"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_power_max=%.2f date=%s on %s",
|
||||
new_power_max,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
power_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -243,13 +298,22 @@ async def send_max_power_change_event(entity: VersatileThermostat, new_power_max
|
||||
)
|
||||
},
|
||||
)
|
||||
return await entity._async_max_power_changed(power_event)
|
||||
await entity._async_max_power_changed(power_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def send_window_change_event(
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
|
||||
):
|
||||
"""Sending a new window event simulating a change on the window state"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
|
||||
new_state,
|
||||
old_state,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
window_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -268,13 +332,22 @@ async def send_window_change_event(
|
||||
},
|
||||
)
|
||||
ret = await entity._async_windows_changed(window_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
return ret
|
||||
|
||||
|
||||
async def send_motion_change_event(
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
|
||||
):
|
||||
"""Sending a new motion event simulating a change on the window state"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
|
||||
new_state,
|
||||
old_state,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
motion_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -293,13 +366,22 @@ async def send_motion_change_event(
|
||||
},
|
||||
)
|
||||
ret = await entity._async_motion_changed(motion_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
return ret
|
||||
|
||||
|
||||
async def send_presence_change_event(
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date
|
||||
entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True
|
||||
):
|
||||
"""Sending a new presence event simulating a change on the window state"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s",
|
||||
new_state,
|
||||
old_state,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
presence_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -318,6 +400,8 @@ async def send_presence_change_event(
|
||||
},
|
||||
)
|
||||
ret = await entity._async_presence_changed(presence_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -334,8 +418,18 @@ async def send_climate_change_event(
|
||||
new_hvac_action: HVACAction,
|
||||
old_hvac_action: HVACAction,
|
||||
date,
|
||||
sleep=True,
|
||||
):
|
||||
"""Sending a new climate event simulating a change on the underlying climate state"""
|
||||
_LOGGER.info(
|
||||
"------- Testu: sending send_temperature_change_event, new_hvac_mode=%s old_hvac_mode=%s new_hvac_action=%s old_hvac_action=%s date=%s on %s",
|
||||
new_hvac_mode,
|
||||
old_hvac_mode,
|
||||
new_hvac_action,
|
||||
old_hvac_action,
|
||||
date,
|
||||
entity,
|
||||
)
|
||||
climate_event = Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
@@ -356,4 +450,14 @@ async def send_climate_change_event(
|
||||
},
|
||||
)
|
||||
ret = await entity._async_climate_changed(climate_event)
|
||||
if sleep:
|
||||
await asyncio.sleep(0.1)
|
||||
return ret
|
||||
|
||||
|
||||
def cancel_switchs_cycles(entity: VersatileThermostat):
|
||||
"""This method will cancel all running cycle on all underlying switch entity"""
|
||||
if entity._is_over_climate:
|
||||
return
|
||||
for under in entity._underlyings:
|
||||
under._cancel_cycle()
|
||||
|
||||
@@ -55,9 +55,9 @@ def skip_notifications_fixture():
|
||||
def skip_turn_on_off_heater():
|
||||
"""Skip turning on and off the heater"""
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingEntity.turn_on"
|
||||
), patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingEntity.turn_off"
|
||||
):
|
||||
yield
|
||||
|
||||
@@ -87,6 +87,15 @@ def skip_control_heating_fixture():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="skip_find_underlying_climate")
|
||||
def skip_find_underlying_climate_fixture():
|
||||
"""Skip the find_underlying_climate of VersatileThermostat"""
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="skip_hass_states_is_state")
|
||||
def skip_hass_states_is_state_fixture():
|
||||
"""Skip the is_state in HomeAssistant"""
|
||||
|
||||
@@ -9,6 +9,9 @@ from homeassistant.components.climate.const import ( # pylint: disable=unused-i
|
||||
from custom_components.versatile_thermostat.const import (
|
||||
CONF_NAME,
|
||||
CONF_HEATER,
|
||||
CONF_HEATER_2,
|
||||
CONF_HEATER_3,
|
||||
CONF_HEATER_4,
|
||||
CONF_THERMOSTAT_CLIMATE,
|
||||
CONF_THERMOSTAT_SWITCH,
|
||||
CONF_THERMOSTAT_TYPE,
|
||||
@@ -62,6 +65,21 @@ MOCK_TH_OVER_SWITCH_USER_CONFIG = {
|
||||
CONF_USE_PRESENCE_FEATURE: True,
|
||||
}
|
||||
|
||||
MOCK_TH_OVER_4SWITCH_USER_CONFIG = {
|
||||
CONF_NAME: "TheOver4SwitchMockName",
|
||||
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: 8,
|
||||
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,
|
||||
@@ -79,6 +97,14 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
|
||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||
}
|
||||
|
||||
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = {
|
||||
CONF_HEATER: "switch.mock_4switch0",
|
||||
CONF_HEATER_2: "switch.mock_4switch1",
|
||||
CONF_HEATER_3: "switch.mock_4switch2",
|
||||
CONF_HEATER_4: "switch.mock_4switch3",
|
||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||
}
|
||||
|
||||
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
|
||||
CONF_TPI_COEF_INT: 0.3,
|
||||
CONF_TPI_COEF_EXT: 0.1,
|
||||
|
||||
@@ -442,7 +442,7 @@ async def test_binary_sensors_over_climate_minimal(
|
||||
|
||||
the_mock_underlying = MagicMockClimate()
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=the_mock_underlying,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
|
||||
@@ -18,7 +18,7 @@ async def test_bug_56(
|
||||
|
||||
the_mock_underlying = MagicMockClimate()
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=None, # dont find the underlying climate
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
@@ -53,7 +53,7 @@ async def test_bug_56(
|
||||
assert entity
|
||||
# cause the underlying climate was not found
|
||||
assert entity.is_over_climate is True
|
||||
assert entity._underlying_climate is None
|
||||
assert entity.underlying_entity(0)._underlying_climate is None
|
||||
|
||||
# Should not failed
|
||||
entity.update_custom_attributes()
|
||||
@@ -70,7 +70,7 @@ async def test_bug_56(
|
||||
|
||||
# This time the underlying will be found
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=the_mock_underlying, # dont find the underlying climate
|
||||
):
|
||||
# try to call _async_control_heating
|
||||
@@ -241,24 +241,26 @@ async def test_bug_66(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
await send_temperature_change_event(entity, 15, now)
|
||||
try_window_condition = await send_window_change_event(entity, True, False, now)
|
||||
try_window_condition = await send_window_change_event(
|
||||
entity, True, False, now, False
|
||||
)
|
||||
|
||||
# simulate the call to try_window_condition
|
||||
await try_window_condition(None)
|
||||
|
||||
assert mock_send_event.call_count == 1
|
||||
assert mock_heater_on.call_count == 1
|
||||
assert mock_heater_off.call_count == 2
|
||||
assert mock_heater_off.call_count >= 1
|
||||
assert mock_condition.call_count == 1
|
||||
|
||||
assert entity.window_state == STATE_ON
|
||||
@@ -267,13 +269,13 @@ async def test_bug_66(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"homeassistant.helpers.condition.state", return_value=False
|
||||
) as mock_condition, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
event_timestamp = now + timedelta(minutes=1)
|
||||
@@ -290,13 +292,13 @@ async def test_bug_66(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
try_window_condition = await send_window_change_event(
|
||||
@@ -313,13 +315,13 @@ async def test_bug_66(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
event_timestamp = now + timedelta(minutes=2)
|
||||
|
||||
@@ -369,3 +369,92 @@ async def test_user_config_flow_window_auto_ko(
|
||||
assert result["errors"] == {
|
||||
"window_sensor_entity_id": "window_open_detection_method"
|
||||
}
|
||||
|
||||
|
||||
async def test_user_config_flow_over_4_switches(
|
||||
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
|
||||
):
|
||||
"""Test the config flow with 4 switchs thermostat_over_switch features"""
|
||||
|
||||
SOURCE_CONFIG = { # pylint: disable=wildcard-import, invalid-name
|
||||
CONF_NAME: "TheOver4SwitchMockName",
|
||||
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: False,
|
||||
CONF_USE_MOTION_FEATURE: False,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: False,
|
||||
}
|
||||
|
||||
TYPE_CONFIG = { # pylint: disable=wildcard-import, invalid-name
|
||||
CONF_HEATER: "switch.mock_switch1",
|
||||
CONF_HEATER_2: "switch.mock_switch2",
|
||||
CONF_HEATER_3: "switch.mock_switch3",
|
||||
CONF_HEATER_4: "switch.mock_switch4",
|
||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||
}
|
||||
|
||||
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=SOURCE_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=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"] == "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"]
|
||||
== SOURCE_CONFIG
|
||||
| TYPE_CONFIG
|
||||
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
|
||||
| MOCK_PRESETS_CONFIG
|
||||
| MOCK_ADVANCED_CONFIG
|
||||
)
|
||||
assert result["result"]
|
||||
assert result["result"].domain == DOMAIN
|
||||
assert result["result"].version == 1
|
||||
assert result["result"].title == "TheOver4SwitchMockName"
|
||||
assert isinstance(result["result"], ConfigEntry)
|
||||
|
||||
401
custom_components/versatile_thermostat/tests/test_movement.py
Normal file
401
custom_components/versatile_thermostat/tests/test_movement.py
Normal file
@@ -0,0 +1,401 @@
|
||||
""" Test the Window management """
|
||||
import asyncio
|
||||
from unittest.mock import patch, call, PropertyMock
|
||||
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import logging
|
||||
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def test_movement_management_time_not_enough(
|
||||
hass: HomeAssistant, skip_hass_states_is_state
|
||||
):
|
||||
"""Test the Presence management when time is not enough"""
|
||||
|
||||
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,
|
||||
"eco_away_temp": 17,
|
||||
"comfort_away_temp": 18,
|
||||
"boost_away_temp": 19,
|
||||
CONF_USE_WINDOW_FEATURE: False,
|
||||
CONF_USE_MOTION_FEATURE: True,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: True,
|
||||
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_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
|
||||
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
|
||||
CONF_MOTION_PRESET: "boost",
|
||||
CONF_NO_MOTION_PRESET: "comfort",
|
||||
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
|
||||
},
|
||||
)
|
||||
|
||||
entity: VersatileThermostat = await create_thermostat(
|
||||
hass, entry, "climate.theoverswitchmockname"
|
||||
)
|
||||
assert entity
|
||||
|
||||
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||
now: datetime = datetime.now(tz=tz)
|
||||
|
||||
# start heating, in boost mode. We block the control_heating to avoid running a cycle
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||
):
|
||||
await entity.async_set_hvac_mode(HVACMode.HEAT)
|
||||
await entity.async_set_preset_mode(PRESET_ACTIVITY)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is None
|
||||
assert entity.presence_state is None
|
||||
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
await send_ext_temperature_change_event(entity, 10, event_timestamp)
|
||||
|
||||
await send_presence_change_event(entity, True, False, event_timestamp)
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
# starts detecting motion
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.helpers.condition.state", return_value=False
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
await send_motion_change_event(entity, True, False, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is None
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
# Change is not confirmed
|
||||
assert mock_heater_on.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
# stop detecting motion with confirmation of stop
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
) as mock_device_active, patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
) as mock_condition:
|
||||
event_timestamp = now - timedelta(minutes=2)
|
||||
await send_motion_change_event(entity, False, True, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is "off"
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
# Change is not confirmed
|
||||
assert mock_heater_on.call_count == 0
|
||||
# Because device is active
|
||||
assert mock_heater_off.call_count == 1
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
|
||||
async def test_movement_management_time_enough_and_presence(
|
||||
hass: HomeAssistant, skip_hass_states_is_state
|
||||
):
|
||||
"""Test the Presence management when time is not enough"""
|
||||
|
||||
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,
|
||||
"eco_away_temp": 17,
|
||||
"comfort_away_temp": 18,
|
||||
"boost_away_temp": 19,
|
||||
CONF_USE_WINDOW_FEATURE: False,
|
||||
CONF_USE_MOTION_FEATURE: True,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: True,
|
||||
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_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
|
||||
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
|
||||
CONF_MOTION_PRESET: "boost",
|
||||
CONF_NO_MOTION_PRESET: "comfort",
|
||||
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
|
||||
},
|
||||
)
|
||||
|
||||
entity: VersatileThermostat = await create_thermostat(
|
||||
hass, entry, "climate.theoverswitchmockname"
|
||||
)
|
||||
assert entity
|
||||
|
||||
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||
now: datetime = datetime.now(tz=tz)
|
||||
|
||||
# start heating, in boost mode. We block the control_heating to avoid running a cycle
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||
):
|
||||
await entity.async_set_hvac_mode(HVACMode.HEAT)
|
||||
await entity.async_set_preset_mode(PRESET_ACTIVITY)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is None
|
||||
assert entity.presence_state is None
|
||||
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
await send_ext_temperature_change_event(entity, 10, event_timestamp)
|
||||
|
||||
await send_presence_change_event(entity, True, False, event_timestamp)
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
# starts detecting motion
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
await send_motion_change_event(entity, True, False, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because motion is detected yet -> switch to Boost mode
|
||||
assert entity.target_temperature == 19
|
||||
assert entity.motion_state is "on"
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
# Change is confirmed. Heater should be started
|
||||
assert mock_heater_on.call_count == 1
|
||||
assert mock_heater_off.call_count == 0
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
# stop detecting motion with confirmation of stop
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=2)
|
||||
await send_motion_change_event(entity, False, True, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is "off"
|
||||
assert entity.presence_state is "on"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_on.call_count == 0
|
||||
# Because heating is no more necessary
|
||||
assert mock_heater_off.call_count == 1
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
|
||||
async def test_movement_management_time_enoughand_not_presence(
|
||||
hass: HomeAssistant, skip_hass_states_is_state
|
||||
):
|
||||
"""Test the Presence management when time is not enough"""
|
||||
|
||||
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,
|
||||
"eco_away_temp": 17.1,
|
||||
"comfort_away_temp": 18.1,
|
||||
"boost_away_temp": 19.1,
|
||||
CONF_USE_WINDOW_FEATURE: False,
|
||||
CONF_USE_MOTION_FEATURE: True,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: True,
|
||||
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_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
|
||||
CONF_MOTION_DELAY: 0, # important to not been obliged to wait
|
||||
CONF_MOTION_PRESET: "boost",
|
||||
CONF_NO_MOTION_PRESET: "comfort",
|
||||
CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor",
|
||||
},
|
||||
)
|
||||
|
||||
entity: VersatileThermostat = await create_thermostat(
|
||||
hass, entry, "climate.theoverswitchmockname"
|
||||
)
|
||||
assert entity
|
||||
|
||||
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||
now: datetime = datetime.now(tz=tz)
|
||||
|
||||
# start heating, in boost mode. We block the control_heating to avoid running a cycle
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||
):
|
||||
await entity.async_set_hvac_mode(HVACMode.HEAT)
|
||||
await entity.async_set_preset_mode(PRESET_ACTIVITY)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet and presence is unknown
|
||||
assert entity.target_temperature == 18
|
||||
assert entity.motion_state is None
|
||||
assert entity.presence_state is None
|
||||
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
await send_ext_temperature_change_event(entity, 10, event_timestamp)
|
||||
|
||||
await send_presence_change_event(entity, False, True, event_timestamp)
|
||||
assert entity.presence_state is "off"
|
||||
|
||||
# starts detecting motion
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
await send_motion_change_event(entity, True, False, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because motion is detected yet -> switch to Boost away mode
|
||||
assert entity.target_temperature == 19.1
|
||||
assert entity.motion_state is "on"
|
||||
assert entity.presence_state is "off"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
# Change is confirmed. Heater should be started
|
||||
assert mock_heater_on.call_count == 1
|
||||
assert mock_heater_off.call_count == 0
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
# stop detecting motion with confirmation of stop
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=2)
|
||||
await send_motion_change_event(entity, False, True, event_timestamp)
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_ACTIVITY
|
||||
# because no motion is detected yet
|
||||
assert entity.target_temperature == 18.1
|
||||
assert entity.motion_state is "off"
|
||||
assert entity.presence_state is "off"
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
# 18.1 starts heating with a low on_percent
|
||||
assert mock_heater_on.call_count == 1
|
||||
assert entity.proportional_algorithm.on_percent == 0.11
|
||||
assert mock_heater_off.call_count == 0
|
||||
assert mock_send_event.call_count == 0
|
||||
@@ -0,0 +1,337 @@
|
||||
""" Test the Multiple switch management """
|
||||
import asyncio
|
||||
from unittest.mock import patch, call, ANY
|
||||
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import logging
|
||||
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def test_one_switch_cycle(
|
||||
hass: HomeAssistant,
|
||||
skip_hass_states_is_state,
|
||||
skip_send_event,
|
||||
):
|
||||
"""Test that when multiple switch are configured the activation is distributed"""
|
||||
|
||||
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||
now: datetime = datetime.now(tz=tz)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="TheOver4SwitchMockName",
|
||||
unique_id="uniqueId",
|
||||
data={
|
||||
CONF_NAME: "TheOver4SwitchMockName",
|
||||
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: 8,
|
||||
CONF_TEMP_MIN: 15,
|
||||
CONF_TEMP_MAX: 30,
|
||||
"eco_temp": 17,
|
||||
"comfort_temp": 18,
|
||||
"boost_temp": 19,
|
||||
CONF_USE_WINDOW_FEATURE: False,
|
||||
CONF_USE_MOTION_FEATURE: False,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: False,
|
||||
CONF_HEATER: "switch.mock_switch1",
|
||||
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||
CONF_SECURITY_DELAY_MIN: 5,
|
||||
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||
CONF_TPI_COEF_INT: 0.3,
|
||||
CONF_TPI_COEF_EXT: 0.01,
|
||||
},
|
||||
)
|
||||
|
||||
entity: VersatileThermostat = await create_thermostat(
|
||||
hass, entry, "climate.theover4switchmockname"
|
||||
)
|
||||
assert entity
|
||||
assert entity.is_over_climate is False
|
||||
|
||||
# start heating, in boost mode. We block the control_heating to avoid running a cycle
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||
):
|
||||
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.window_state is None
|
||||
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
await send_temperature_change_event(entity, 15, event_timestamp)
|
||||
|
||||
# Checks that all heaters are off
|
||||
with patch(
|
||||
"homeassistant.core.StateMachine.is_state", return_value=False
|
||||
) as mock_is_state:
|
||||
assert entity._is_device_active is False
|
||||
|
||||
# Should be call for the Switch
|
||||
assert mock_is_state.call_count == 1
|
||||
|
||||
# Set temperature to a low level
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
) as mock_device_active, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later",
|
||||
return_value=None,
|
||||
) as mock_call_later:
|
||||
await send_ext_temperature_change_event(entity, 5, event_timestamp)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
|
||||
# The first heater should be on but because call_later is mocked heater_on is not called
|
||||
# assert mock_heater_on.call_count == 1
|
||||
assert mock_heater_on.call_count == 0
|
||||
# There is no check if active
|
||||
assert mock_device_active.call_count == 0
|
||||
|
||||
# 4 calls dispatched along the cycle
|
||||
assert mock_call_later.call_count == 1
|
||||
mock_call_later.assert_has_calls(
|
||||
[
|
||||
call.call_later(hass, 0.0, ANY),
|
||||
]
|
||||
)
|
||||
|
||||
# Set a temperature at middle level
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
) as mock_device_active:
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
|
||||
# The first heater should be turned on but is already on but because above we mock call_later the heater is not on. But this time it will be really on
|
||||
assert mock_heater_on.call_count == 1
|
||||
|
||||
# Set another temperature at middle level
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
) as mock_device_active:
|
||||
await send_temperature_change_event(entity, 18.1, event_timestamp)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
|
||||
# The heater is already on cycle. So we wait that the cycle ends and no heater action is done
|
||||
assert mock_heater_on.call_count == 0
|
||||
# assert entity.underlying_entity(0)._should_relaunch_control_heating is True
|
||||
|
||||
# Simulate the relaunch
|
||||
await entity.underlying_entity(0)._turn_on_later(None)
|
||||
# wait restart
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert mock_heater_on.call_count == 1
|
||||
# TODO normal ? assert entity.underlying_entity(0)._should_relaunch_control_heating is False
|
||||
|
||||
# Simulate the end of heater on cycle
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
) as mock_device_active:
|
||||
await entity.underlying_entity(0)._turn_off_later(None)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_on.call_count == 0
|
||||
# The heater should be turned off this time
|
||||
assert mock_heater_off.call_count == 1
|
||||
# assert entity.underlying_entity(0)._should_relaunch_control_heating is False
|
||||
|
||||
# Simulate the start of heater on cycle
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
) as mock_device_active:
|
||||
await entity.underlying_entity(0)._turn_on_later(None)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_on.call_count == 1
|
||||
# The heater should be turned off this time
|
||||
assert mock_heater_off.call_count == 0
|
||||
# assert entity.underlying_entity(0)._should_relaunch_control_heating is False
|
||||
|
||||
|
||||
async def test_multiple_switchs(
|
||||
hass: HomeAssistant,
|
||||
skip_hass_states_is_state,
|
||||
skip_send_event,
|
||||
):
|
||||
"""Test that when multiple switch are configured the activation is distributed"""
|
||||
|
||||
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||
now: datetime = datetime.now(tz=tz)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="TheOver4SwitchMockName",
|
||||
unique_id="uniqueId",
|
||||
data={
|
||||
CONF_NAME: "TheOver4SwitchMockName",
|
||||
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: 8,
|
||||
CONF_TEMP_MIN: 15,
|
||||
CONF_TEMP_MAX: 30,
|
||||
"eco_temp": 17,
|
||||
"comfort_temp": 18,
|
||||
"boost_temp": 19,
|
||||
CONF_USE_WINDOW_FEATURE: False,
|
||||
CONF_USE_MOTION_FEATURE: False,
|
||||
CONF_USE_POWER_FEATURE: False,
|
||||
CONF_USE_PRESENCE_FEATURE: False,
|
||||
CONF_HEATER: "switch.mock_switch1",
|
||||
CONF_HEATER_2: "switch.mock_switch2",
|
||||
CONF_HEATER_3: "switch.mock_switch3",
|
||||
CONF_HEATER_4: "switch.mock_switch4",
|
||||
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||
CONF_SECURITY_DELAY_MIN: 5,
|
||||
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||
CONF_TPI_COEF_INT: 0.3,
|
||||
CONF_TPI_COEF_EXT: 0.01,
|
||||
},
|
||||
)
|
||||
|
||||
entity: VersatileThermostat = await create_thermostat(
|
||||
hass, entry, "climate.theover4switchmockname"
|
||||
)
|
||||
assert entity
|
||||
assert entity.is_over_climate is False
|
||||
|
||||
# start heating, in boost mode. We block the control_heating to avoid running a cycle
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||
):
|
||||
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.window_state is None
|
||||
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
await send_temperature_change_event(entity, 15, event_timestamp)
|
||||
|
||||
# Checks that all heaters are off
|
||||
with patch(
|
||||
"homeassistant.core.StateMachine.is_state", return_value=False
|
||||
) as mock_is_state:
|
||||
assert entity._is_device_active is False
|
||||
|
||||
# Should be call for all Switch
|
||||
assert mock_is_state.call_count == 4
|
||||
|
||||
# Set temperature to a low level
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
) as mock_device_active, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later",
|
||||
return_value=None,
|
||||
) as mock_call_later:
|
||||
await send_ext_temperature_change_event(entity, 5, event_timestamp)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
|
||||
# The first heater should be on but because call_later is mocked heater_on is not called
|
||||
# assert mock_heater_on.call_count == 1
|
||||
assert mock_heater_on.call_count == 0
|
||||
# There is no check if active
|
||||
assert mock_device_active.call_count == 0
|
||||
|
||||
# 4 calls dispatched along the cycle
|
||||
assert mock_call_later.call_count == 4
|
||||
mock_call_later.assert_has_calls(
|
||||
[
|
||||
call.call_later(hass, 0.0, ANY),
|
||||
call.call_later(hass, 120.0, ANY),
|
||||
call.call_later(hass, 240.0, ANY),
|
||||
call.call_later(hass, 360.0, ANY),
|
||||
]
|
||||
)
|
||||
|
||||
# Set a temperature at middle level
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
) as mock_device_active:
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
|
||||
# No special event
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_off.call_count == 0
|
||||
|
||||
# The first heater should be turned on but is already on but because call_later is mocked, it is only turned on here
|
||||
assert mock_heater_on.call_count == 1
|
||||
@@ -81,9 +81,9 @@ async def test_power_management_hvac_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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off:
|
||||
await send_max_power_change_event(entity, 149, datetime.now())
|
||||
assert await entity.check_overpowering() is True
|
||||
@@ -160,9 +160,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off:
|
||||
await send_max_power_change_event(entity, 149, datetime.now())
|
||||
assert await entity.check_overpowering() is True
|
||||
@@ -194,9 +194,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off:
|
||||
await send_power_change_event(entity, 48, datetime.now())
|
||||
assert await entity.check_overpowering() is False
|
||||
@@ -278,13 +278,13 @@ async def test_power_management_energy_over_switch(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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)
|
||||
await send_temperature_change_event(entity, 15, datetime.now())
|
||||
|
||||
assert entity.hvac_mode is HVACMode.HEAT
|
||||
assert entity.preset_mode is PRESET_BOOST
|
||||
@@ -307,9 +307,9 @@ async def test_power_management_energy_over_switch(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off:
|
||||
await send_temperature_change_event(entity, 18, datetime.now())
|
||||
assert tpi_algo.on_percent == 0.3
|
||||
@@ -329,9 +329,9 @@ async def test_power_management_energy_over_switch(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off:
|
||||
await send_temperature_change_event(entity, 20, datetime.now())
|
||||
assert tpi_algo.on_percent == 0.0
|
||||
@@ -357,7 +357,7 @@ async def test_power_management_energy_over_climate(
|
||||
|
||||
the_mock_underlying = MagicMockClimate()
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=the_mock_underlying,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
|
||||
@@ -87,7 +87,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on:
|
||||
event_timestamp = now - timedelta(minutes=6)
|
||||
|
||||
@@ -134,7 +134,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on:
|
||||
await entity.async_set_preset_mode(PRESET_BOOST)
|
||||
|
||||
@@ -149,7 +149,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on:
|
||||
event_timestamp = datetime.now()
|
||||
|
||||
@@ -185,4 +185,5 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
any_order=True,
|
||||
)
|
||||
|
||||
assert mock_heater_on.call_count == 0
|
||||
# Heater is now on
|
||||
assert mock_heater_on.call_count == 1
|
||||
|
||||
@@ -179,6 +179,8 @@ async def test_sensors_over_switch(
|
||||
)
|
||||
assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP
|
||||
|
||||
cancel_switchs_cycles(entity)
|
||||
|
||||
|
||||
async def test_sensors_over_climate(
|
||||
hass: HomeAssistant,
|
||||
@@ -190,7 +192,7 @@ async def test_sensors_over_climate(
|
||||
|
||||
the_mock_underlying = MagicMockClimate()
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=the_mock_underlying,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
@@ -324,7 +326,7 @@ async def test_sensors_over_climate_minimal(
|
||||
|
||||
the_mock_underlying = MagicMockClimate()
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=the_mock_underlying,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
|
||||
@@ -91,7 +91,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
|
||||
return_value=fake_underlying_climate,
|
||||
) as mock_find_climate:
|
||||
entry.add_to_hass(hass)
|
||||
@@ -139,7 +139,76 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
|
||||
)
|
||||
|
||||
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")]
|
||||
assert mock_find_climate.mock_calls[0] == call()
|
||||
mock_find_climate.assert_has_calls([call.find_underlying_entity()])
|
||||
|
||||
|
||||
async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
"""Test the normal full start of a thermostat in thermostat_over_switch with 4 switches type"""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="TheOver4SwitchMockName",
|
||||
unique_id="uniqueId",
|
||||
data=FULL_4SWITCH_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.theover4switchmockname")
|
||||
|
||||
assert entity
|
||||
|
||||
assert entity.name == "TheOver4SwitchMockName"
|
||||
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
|
||||
|
||||
assert entity.nb_underlying_entities == 4
|
||||
|
||||
# Checks that we have the 4 UnderlyingEntity correctly configured
|
||||
for idx in range(4):
|
||||
under = entity.underlying_entity(idx)
|
||||
assert under is not None
|
||||
assert isinstance(under, UnderlyingSwitch)
|
||||
assert under.entity_id == "switch.mock_4switch" + str(idx)
|
||||
assert under.initial_delay_sec == 8 * 60 / 4 * idx
|
||||
|
||||
# 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},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -66,18 +66,16 @@ async def test_window_management_time_not_enough(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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)
|
||||
await send_window_change_event(entity, True, False, datetime.now())
|
||||
# simulate the call to try_window_condition. No need due to 0 WINDOW_DELAY and sleep after event is sent
|
||||
# await try_window_condition(None)
|
||||
|
||||
assert mock_send_event.call_count == 0
|
||||
assert mock_heater_on.call_count == 1
|
||||
@@ -148,49 +146,79 @@ async def test_window_management_time_enough(
|
||||
|
||||
assert entity.window_state is None
|
||||
|
||||
# Open the window, but condition of time is not satisfied and check the thermostat don't turns off
|
||||
# change temperature to force turning on the heater
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
await send_temperature_change_event(entity, 15, datetime.now())
|
||||
|
||||
# Heater shoud turn-on
|
||||
assert mock_heater_on.call_count >= 1
|
||||
assert mock_heater_off.call_count == 0
|
||||
assert mock_send_event.call_count == 0
|
||||
|
||||
# Open the window, condition of time is satisfied, check the thermostat and heater turns off
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
) as mock_condition, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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)
|
||||
await send_window_change_event(entity, True, False, datetime.now())
|
||||
|
||||
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
|
||||
|
||||
# Heater should not be on
|
||||
assert mock_heater_on.call_count == 0
|
||||
# One call in set_hvac_mode turn_off and one call in the control_heating for security
|
||||
assert mock_heater_off.call_count == 2
|
||||
assert mock_condition.call_count == 1
|
||||
|
||||
assert entity.hvac_mode is HVACMode.OFF
|
||||
assert entity.window_state == STATE_ON
|
||||
|
||||
# Close the window
|
||||
try_window_condition = await send_window_change_event(
|
||||
entity, False, True, datetime.now()
|
||||
# Close the window
|
||||
with patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||
) as mock_send_event, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"homeassistant.helpers.condition.state", return_value=True
|
||||
) as mock_condition, patch(
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
try_function = await send_window_change_event(
|
||||
entity, False, True, datetime.now(), sleep=False
|
||||
)
|
||||
# simulate the call to try_window_condition
|
||||
await try_window_condition(None)
|
||||
|
||||
await try_function(None)
|
||||
|
||||
# Wait for initial delay of heater
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
assert entity.window_state == STATE_OFF
|
||||
assert mock_heater_on.call_count == 2
|
||||
assert mock_send_event.call_count == 2
|
||||
assert mock_heater_on.call_count == 1
|
||||
assert mock_send_event.call_count == 1
|
||||
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}
|
||||
),
|
||||
@@ -259,11 +287,11 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
@@ -281,11 +309,11 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
@@ -316,11 +344,11 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
new_callable=PropertyMock,
|
||||
return_value=False,
|
||||
):
|
||||
@@ -341,11 +369,11 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
new_callable=PropertyMock,
|
||||
return_value=False,
|
||||
):
|
||||
@@ -438,11 +466,11 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
@@ -459,18 +487,32 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||
await send_temperature_change_event(entity, 18, event_timestamp, sleep=False)
|
||||
|
||||
# The heater turns on
|
||||
assert mock_send_event.call_count == 2
|
||||
# The heater turns off
|
||||
mock_send_event.assert_has_calls(
|
||||
[
|
||||
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
|
||||
call.send_event(
|
||||
EventType.WINDOW_AUTO_EVENT,
|
||||
{
|
||||
"type": "start",
|
||||
"cause": "slope alert",
|
||||
"curve_slope": -1.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
assert mock_heater_on.call_count == 0
|
||||
assert mock_heater_off.call_count >= 1
|
||||
assert entity.last_temperature_slope == -1
|
||||
@@ -483,11 +525,11 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=False,
|
||||
):
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -564,11 +606,11 @@ async def test_window_auto_no_on_percent(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=4)
|
||||
@@ -586,11 +628,11 @@ async def test_window_auto_no_on_percent(
|
||||
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"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
|
||||
) as mock_heater_on, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off"
|
||||
) as mock_heater_off, patch(
|
||||
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
|
||||
return_value=True,
|
||||
):
|
||||
event_timestamp = now - timedelta(minutes=3)
|
||||
|
||||
@@ -22,12 +22,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Linked entity",
|
||||
"description": "Linked entity attributes",
|
||||
"title": "Linked entities",
|
||||
"description": "Linked entities attributes",
|
||||
"data": {
|
||||
"heater_entity_id": "Heater entity id",
|
||||
"heater_entity_id": "Heater switch",
|
||||
"heater_entity2_id": "2nd Heater switch",
|
||||
"heater_entity3_id": "3rd Heater switch",
|
||||
"heater_entity4_id": "4th Heater switch",
|
||||
"proportional_function": "Algorithm",
|
||||
"climate_entity_id": "Underlying thermostat"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Mandatory heater entity id",
|
||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||
"climate_entity_id": "Underlying thermostat entity id"
|
||||
"climate_entity_id": "Underlying climate entity id"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -142,12 +153,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Linked entity",
|
||||
"description": "Linked entity attributes",
|
||||
"title": "Linked entities",
|
||||
"description": "Linked entities attributes",
|
||||
"data": {
|
||||
"heater_entity_id": "Heater entity id",
|
||||
"heater_entity_id": "Heater switch",
|
||||
"heater_entity2_id": "2nd Heater switch",
|
||||
"heater_entity3_id": "3rd Heater switch",
|
||||
"heater_entity4_id": "4th Heater switch",
|
||||
"proportional_function": "Algorithm",
|
||||
"climate_entity_id": "Underlying thermostat"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Mandatory heater entity id",
|
||||
"heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used",
|
||||
"heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used",
|
||||
"heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used",
|
||||
"proportional_function": "Algorithm to use (TPI is the only one for now)",
|
||||
"climate_entity_id": "Underlying thermostat entity id"
|
||||
"climate_entity_id": "Underlying climate entity id"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -262,16 +284,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_attributes": {
|
||||
"_": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"power": "Shedding",
|
||||
"security": "Security",
|
||||
"none": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Entité liée",
|
||||
"description": "Attributs de l'entité liée",
|
||||
"title": "Entité(s) liée(s)",
|
||||
"description": "Attributs de(s) l'entité(s) liée(s)",
|
||||
"data": {
|
||||
"heater_entity_id": "Radiateur entity id",
|
||||
"heater_entity_id": "1er radiateur",
|
||||
"heater_entity2_id": "2ème radiateur",
|
||||
"heater_entity3_id": "3ème radiateur",
|
||||
"heater_entity4_id": "4ème radiateur",
|
||||
"proportional_function": "Algorithme",
|
||||
"climate_entity_id": "Thermostat sous-jacent"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
|
||||
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
||||
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
||||
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
||||
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
||||
"climate_entity_id": "Thermostat sous-jacent entity id"
|
||||
"climate_entity_id": "Entity id du thermostat sous-jacent"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -142,12 +153,23 @@
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"title": "Entité liée",
|
||||
"description": "Attributs de l'entité liée",
|
||||
"title": "Entité(s) liée(s)",
|
||||
"description": "Attributs de(s) l'entité(s) liée(s)",
|
||||
"data": {
|
||||
"heater_entity_id": "Radiateur entity id",
|
||||
"heater_entity_id": "1er radiateur",
|
||||
"heater_entity2_id": "2ème radiateur",
|
||||
"heater_entity3_id": "3ème radiateur",
|
||||
"heater_entity4_id": "4ème radiateur",
|
||||
"proportional_function": "Algorithme",
|
||||
"climate_entity_id": "Thermostat sous-jacent"
|
||||
},
|
||||
"data_description": {
|
||||
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
|
||||
"heater_entity2_id": "Optionnel entity id du 2ème radiateur",
|
||||
"heater_entity3_id": "Optionnel entity id du 3ème radiateur",
|
||||
"heater_entity4_id": "Optionnel entity id du 4ème radiateur",
|
||||
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
|
||||
"climate_entity_id": "Thermostat sous-jacent entity id"
|
||||
"climate_entity_id": "Entity id du thermostat sous-jacent"
|
||||
}
|
||||
},
|
||||
"tpi": {
|
||||
@@ -262,16 +284,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_attributes": {
|
||||
"_": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"power": "Délestage",
|
||||
"security": "Sécurité",
|
||||
"none": "Manuel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
578
custom_components/versatile_thermostat/underlyings.py
Normal file
578
custom_components/versatile_thermostat/underlyings.py
Normal file
@@ -0,0 +1,578 @@
|
||||
""" Underlying entities classes """
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature
|
||||
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
|
||||
from homeassistant.backports.enum import StrEnum
|
||||
from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
HVACMode,
|
||||
HVACAction,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HUMIDITY,
|
||||
SERVICE_SET_SWING_MODE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
)
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from .const import UnknownEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# remove this
|
||||
# _LOGGER.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class UnderlyingEntityType(StrEnum):
|
||||
"""All underlying device type"""
|
||||
|
||||
# A switch
|
||||
SWITCH = "switch"
|
||||
|
||||
# a climate
|
||||
CLIMATE = "climate"
|
||||
|
||||
|
||||
class UnderlyingEntity:
|
||||
"""Represent a underlying device which could be a switch or a climate"""
|
||||
|
||||
_hass: HomeAssistant
|
||||
# Cannot import VersatileThermostat due to circular reference
|
||||
_thermostat: Any
|
||||
_entity_id: str
|
||||
_type: UnderlyingEntityType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
thermostat: Any,
|
||||
entity_type: UnderlyingEntityType,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the underlying entity"""
|
||||
self._hass = hass
|
||||
self._thermostat = thermostat
|
||||
self._type = entity_type
|
||||
self._entity_id = entity_id
|
||||
|
||||
def __str__(self):
|
||||
return str(self._thermostat) + "-" + self._entity_id
|
||||
|
||||
@property
|
||||
def entity_id(self):
|
||||
"""The entiy id represented by this class"""
|
||||
return self._entity_id
|
||||
|
||||
@property
|
||||
def entity_type(self) -> UnderlyingEntityType:
|
||||
"""The entity type represented by this class"""
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""True if the underlying is initialized"""
|
||||
return True
|
||||
|
||||
def startup(self):
|
||||
"""Startup the Entity"""
|
||||
return
|
||||
|
||||
async def set_hvac_mode(self, hvac_mode: HVACMode):
|
||||
"""Set the HVACmode"""
|
||||
return
|
||||
|
||||
@property
|
||||
def is_device_active(self) -> bool | None:
|
||||
"""If the toggleable device is currently active."""
|
||||
return None
|
||||
|
||||
async def turn_off(self):
|
||||
"""Turn heater toggleable device off."""
|
||||
_LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
|
||||
# This may fails if called after shutdown
|
||||
try:
|
||||
data = {ATTR_ENTITY_ID: self._entity_id}
|
||||
await self._hass.services.async_call(
|
||||
HA_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
data,
|
||||
)
|
||||
except ServiceNotFound as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
async def turn_on(self):
|
||||
"""Turn heater toggleable device on."""
|
||||
_LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id)
|
||||
try:
|
||||
data = {ATTR_ENTITY_ID: self._entity_id}
|
||||
await self._hass.services.async_call(
|
||||
HA_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
data,
|
||||
)
|
||||
except ServiceNotFound as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
async def set_temperature(self, temperature, max_temp, min_temp):
|
||||
"""Set the target temperature"""
|
||||
return
|
||||
|
||||
async def remove_entity(self):
|
||||
"""Remove the underlying entity"""
|
||||
return
|
||||
|
||||
# override to be able to mock the call
|
||||
def call_later(
|
||||
self, hass: HomeAssistant, delay_sec: int, called_method
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Call the method after a delay"""
|
||||
return async_call_later(hass, delay_sec, called_method)
|
||||
|
||||
|
||||
class UnderlyingSwitch(UnderlyingEntity):
|
||||
"""Represent a underlying switch"""
|
||||
|
||||
_initialDelaySec: int
|
||||
_on_time_sec: int
|
||||
_off_time_sec: int
|
||||
_hvac_mode: HVACMode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
thermostat: Any,
|
||||
switch_entity_id: str,
|
||||
initial_delay_sec: int,
|
||||
) -> None:
|
||||
"""Initialize the underlying switch"""
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
thermostat=thermostat,
|
||||
entity_type=UnderlyingEntityType.SWITCH,
|
||||
entity_id=switch_entity_id,
|
||||
)
|
||||
self._initial_delay_sec = initial_delay_sec
|
||||
self._async_cancel_cycle = None
|
||||
self._should_relaunch_control_heating = False
|
||||
self._on_time_sec = 0
|
||||
self._off_time_sec = 0
|
||||
|
||||
@property
|
||||
def initial_delay_sec(self):
|
||||
"""The initial delay for this class"""
|
||||
return self._initial_delay_sec
|
||||
|
||||
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
|
||||
"""Set the HVACmode. Returns true if we need to redo a control_heating"""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
if self.is_device_active:
|
||||
await self.turn_off()
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_device_active(self):
|
||||
"""If the toggleable device is currently active."""
|
||||
return self._hass.states.is_state(self._entity_id, STATE_ON)
|
||||
|
||||
async def check_initial_state(self, hvac_mode: HVACMode):
|
||||
"""Prevent the heater to be on but thermostat is off"""
|
||||
if hvac_mode == HVACMode.OFF and self.is_device_active:
|
||||
_LOGGER.warning(
|
||||
"%s - The hvac mode is OFF, but the switch device is ON. Turning off device %s",
|
||||
self,
|
||||
self._entity_id,
|
||||
)
|
||||
await self.turn_off()
|
||||
|
||||
async def start_cycle(
|
||||
self,
|
||||
hvac_mode: HVACMode,
|
||||
on_time_sec: int,
|
||||
off_time_sec: int,
|
||||
force=False,
|
||||
):
|
||||
"""Starting cycle for switch"""
|
||||
_LOGGER.debug(
|
||||
"%s - Starting new cycle hvac_mode=%s on_time_sec=%d off_time_sec=%d force=%s",
|
||||
self,
|
||||
hvac_mode,
|
||||
on_time_sec,
|
||||
off_time_sec,
|
||||
force,
|
||||
)
|
||||
|
||||
self._on_time_sec = on_time_sec
|
||||
self._off_time_sec = off_time_sec
|
||||
self._hvac_mode = hvac_mode
|
||||
|
||||
# Cancel eventual previous cycle if any
|
||||
if self._async_cancel_cycle is not None:
|
||||
if force:
|
||||
_LOGGER.debug("%s - we force a new cycle", self)
|
||||
await self._cancel_cycle()
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - A previous cycle is alredy running and no force -> waits for its end",
|
||||
self,
|
||||
)
|
||||
# self._should_relaunch_control_heating = True
|
||||
_LOGGER.debug("%s - End of cycle (2)", self)
|
||||
return
|
||||
|
||||
# If we should heat, starts the cycle with delay
|
||||
if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0:
|
||||
# Starts the cycle after the initial delay
|
||||
self._async_cancel_cycle = self.call_later(
|
||||
self._hass, self._initial_delay_sec, self._turn_on_later
|
||||
)
|
||||
_LOGGER.debug("%s - _async_cancel_cycle=%s", self, self._async_cancel_cycle)
|
||||
|
||||
# if we not heat but device is active
|
||||
elif self.is_device_active:
|
||||
_LOGGER.info(
|
||||
"%s - stop heating (2) for %d min %d sec",
|
||||
self,
|
||||
off_time_sec // 60,
|
||||
off_time_sec % 60,
|
||||
)
|
||||
await self.turn_off()
|
||||
else:
|
||||
_LOGGER.debug("%s - nothing to do", self)
|
||||
|
||||
async def _cancel_cycle(self):
|
||||
"""Cancel the cycle"""
|
||||
if self._async_cancel_cycle:
|
||||
self._async_cancel_cycle()
|
||||
self._async_cancel_cycle = None
|
||||
_LOGGER.debug("%s - Stopping cycle during calculation", self)
|
||||
|
||||
async def _turn_on_later(self, _):
|
||||
"""Turn the heater on after a delay"""
|
||||
_LOGGER.debug(
|
||||
"%s - calling turn_on_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
|
||||
self,
|
||||
self._hvac_mode,
|
||||
self._should_relaunch_control_heating,
|
||||
self._on_time_sec,
|
||||
)
|
||||
|
||||
await self._cancel_cycle()
|
||||
|
||||
if self._hvac_mode == HVACMode.OFF:
|
||||
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
|
||||
if self.is_device_active:
|
||||
await self.turn_off()
|
||||
return
|
||||
|
||||
if await self._thermostat.check_overpowering():
|
||||
_LOGGER.debug("%s - End of cycle (3)", self)
|
||||
return
|
||||
# Security mode could have change the on_time percent
|
||||
await self._thermostat.check_security()
|
||||
time = self._on_time_sec
|
||||
|
||||
action_label = "start"
|
||||
# if self._should_relaunch_control_heating:
|
||||
# _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label)
|
||||
# self._should_relaunch_control_heating = False
|
||||
# # self.hass.create_task(self._async_control_heating())
|
||||
# await self.start_cycle(
|
||||
# self._hvac_mode, self._on_time_sec, self._off_time_sec
|
||||
# )
|
||||
# _LOGGER.debug("%s - End of cycle (3)", self)
|
||||
# return
|
||||
|
||||
if time > 0:
|
||||
_LOGGER.info(
|
||||
"%s - %s heating for %d min %d sec",
|
||||
self,
|
||||
action_label,
|
||||
time // 60,
|
||||
time % 60,
|
||||
)
|
||||
await self.turn_on()
|
||||
else:
|
||||
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
|
||||
self._async_cancel_cycle = self.call_later(
|
||||
self._hass,
|
||||
time,
|
||||
self._turn_off_later,
|
||||
)
|
||||
|
||||
async def _turn_off_later(self, _):
|
||||
"""Turn the heater off and call the next cycle after the delay"""
|
||||
_LOGGER.debug(
|
||||
"%s - calling turn_off_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
|
||||
self,
|
||||
self._hvac_mode,
|
||||
self._should_relaunch_control_heating,
|
||||
self._off_time_sec,
|
||||
)
|
||||
await self._cancel_cycle()
|
||||
|
||||
if self._hvac_mode == HVACMode.OFF:
|
||||
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
|
||||
if self.is_device_active:
|
||||
await self.turn_off()
|
||||
return
|
||||
|
||||
action_label = "stop"
|
||||
# if self._should_relaunch_control_heating:
|
||||
# _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label)
|
||||
# self._should_relaunch_control_heating = False
|
||||
# # self.hass.create_task(self._async_control_heating())
|
||||
# await self.start_cycle(
|
||||
# self._hvac_mode, self._on_time_sec, self._off_time_sec
|
||||
# )
|
||||
# _LOGGER.debug("%s - End of cycle (3)", self)
|
||||
# return
|
||||
|
||||
time = self._off_time_sec
|
||||
|
||||
if time > 0:
|
||||
_LOGGER.info(
|
||||
"%s - %s heating for %d min %d sec",
|
||||
self,
|
||||
action_label,
|
||||
time // 60,
|
||||
time % 60,
|
||||
)
|
||||
await self.turn_off()
|
||||
else:
|
||||
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
|
||||
self._async_cancel_cycle = self.call_later(
|
||||
self._hass,
|
||||
time,
|
||||
self._turn_on_later,
|
||||
)
|
||||
|
||||
# increment energy at the end of the cycle
|
||||
self._thermostat.incremente_energy()
|
||||
|
||||
async def remove_entity(self):
|
||||
"""Remove the entity"""
|
||||
await self._cancel_cycle()
|
||||
|
||||
|
||||
class UnderlyingClimate(UnderlyingEntity):
|
||||
"""Represent a underlying climate"""
|
||||
|
||||
_underlying_climate: ClimateEntity
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
thermostat: Any,
|
||||
climate_entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the underlying climate"""
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
thermostat=thermostat,
|
||||
entity_type=UnderlyingEntityType.CLIMATE,
|
||||
entity_id=climate_entity_id,
|
||||
)
|
||||
self._underlying_climate = None
|
||||
|
||||
def find_underlying_climate(self) -> ClimateEntity:
|
||||
"""Find the underlying climate entity"""
|
||||
component: EntityComponent[ClimateEntity] = self._hass.data[CLIMATE_DOMAIN]
|
||||
for entity in component.entities:
|
||||
if self.entity_id == entity.entity_id:
|
||||
return entity
|
||||
return None
|
||||
|
||||
def startup(self):
|
||||
"""Startup the Entity"""
|
||||
# Get the underlying climate
|
||||
self._underlying_climate = self.find_underlying_climate()
|
||||
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,
|
||||
self.entity_id,
|
||||
)
|
||||
# #56 keep the over_climate and try periodically to find the underlying climate
|
||||
# self._is_over_climate = False
|
||||
raise UnknownEntity(f"Underlying entity {self.entity_id} not found")
|
||||
return
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""True if the underlying climate was found"""
|
||||
return self._underlying_climate is not None
|
||||
|
||||
async def set_hvac_mode(self, hvac_mode: HVACMode):
|
||||
"""Set the HVACmode of the underlying climate"""
|
||||
if not self.is_initialized:
|
||||
return
|
||||
|
||||
data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode}
|
||||
await self._hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
data,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_device_active(self):
|
||||
"""If the toggleable device is currently active."""
|
||||
if self.is_initialized:
|
||||
return self._underlying_climate.hvac_action not in [
|
||||
HVACAction.IDLE,
|
||||
HVACAction.OFF,
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
async def set_fan_mode(self, fan_mode):
|
||||
"""Set new target fan mode."""
|
||||
if not self.is_initialized:
|
||||
return
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
"fan_mode": fan_mode,
|
||||
}
|
||||
|
||||
await self._hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
data,
|
||||
)
|
||||
|
||||
async def set_humidity(self, humidity: int):
|
||||
"""Set new target humidity."""
|
||||
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
|
||||
if not self.is_initialized:
|
||||
return
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
"humidity": humidity,
|
||||
}
|
||||
|
||||
await self._hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HUMIDITY,
|
||||
data,
|
||||
)
|
||||
|
||||
async def set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing operation."""
|
||||
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
|
||||
if not self.is_initialized:
|
||||
return
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
"swing_mode": swing_mode,
|
||||
}
|
||||
|
||||
await self._hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_SWING_MODE,
|
||||
data,
|
||||
)
|
||||
|
||||
async def set_temperature(self, temperature, max_temp, min_temp):
|
||||
"""Set the target temperature"""
|
||||
if not self.is_initialized:
|
||||
return
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
"temperature": temperature,
|
||||
"target_temp_high": max_temp,
|
||||
"target_temp_low": min_temp,
|
||||
}
|
||||
|
||||
await self._hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
data,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Get the hvac action of the underlying"""
|
||||
if not self.is_initialized:
|
||||
return None
|
||||
return self._underlying_climate.hvac_action
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Get the hvac mode of the underlying"""
|
||||
if not self.is_initialized:
|
||||
return None
|
||||
return self._underlying_climate.hvac_mode
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Get the fan_mode of the underlying"""
|
||||
if not self.is_initialized:
|
||||
return None
|
||||
return self._underlying_climate.fan_mode
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Get the swing_mode of the underlying"""
|
||||
if not self.is_initialized:
|
||||
return None
|
||||
return self._underlying_climate.swing_mode
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Get the supported features of the climate"""
|
||||
if not self.is_initialized:
|
||||
return ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
return self._underlying_climate.supported_features
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Get the hvac_modes"""
|
||||
if not self.is_initialized:
|
||||
return []
|
||||
return self._underlying_climate.hvac_modes
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
"""Get the fan_modes"""
|
||||
if not self.is_initialized:
|
||||
return []
|
||||
return self._underlying_climate.fan_modes
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str]:
|
||||
"""Get the swing_modes"""
|
||||
if not self.is_initialized:
|
||||
return []
|
||||
return self._underlying_climate.swing_modes
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Get the temperature_unit"""
|
||||
if not self.is_initialized:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
return self._underlying_climate.temperature_unit
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> str:
|
||||
"""Get the target_temperature_step"""
|
||||
if not self.is_initialized:
|
||||
return 1
|
||||
return self._underlying_climate.target_temperature_step
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 37 KiB |
BIN
images/multi-switch-activation.png
Normal file
BIN
images/multi-switch-activation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Reference in New Issue
Block a user